diff --git a/.github/banned-terms.txt b/.github/banned-terms.txt new file mode 100644 index 0000000..ad758b1 --- /dev/null +++ b/.github/banned-terms.txt @@ -0,0 +1,31 @@ +# Terms this corpus does not use, one ripgrep pattern per line. Run with +# --pcre2 over the stripped copy of every page, so a real action name, a real +# payload, and a real account name inside code are already gone before a line +# reaches this file. A finding here is prose. + +# The category head term. Mirrors the site's own vocabulary source, which the +# built-output gate compiles from the same string, so the two cannot drift: +# packages/atomic-brand/src/vocabulary.ts. The protocol's own nouns +# (collection, schema, template, asset, offer, sale) carry the meaning. +\bnfts?\b|non[-_\s]?fungible + +# Era branding rather than a protocol fact. Name the chain, the contract, or +# the API instead. +(?i)\bweb ?3(\.0)?\b + +# An asset is an asset. A template is a template. "Token instance" is neither +# contract vocabulary nor API vocabulary. +(?i)\btoken instances?\b + +# Wallet where the referent is an account. On Antelope the holder of an asset +# or a token balance is an account, and a wallet is the signing software in +# front of it, so the word points a reader at the wrong layer. +# +# Three senses are sanctioned and none of them takes these constructions: +# Cloud Wallet is a product name, "wallet" naming signing software is the word +# used correctly, and a wallet address is an address. Sample account names +# built on the word sit in code and the stripping step has already removed +# them. +(?i)\b(?:\w+'s|their|its|your|our|his|her|my)\s+wallets?\b(?!\s+addresses?\b) +(?i)\b(?:in|into|inside|from|to|between|out\s+of)\s+(?:the\s+|an?\s+)?wallets?\b(?!\s+addresses?\b) +(?i)\bwallets?\s+(?:holds?|contains?|owns?|receives?|stores?|keeps?)\b diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1bf4f49 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# The only dependencies this repository declares are the actions its workflow +# pins by commit. Dependabot keeps those pins moving; the corpus itself has no +# package manifest to watch. +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + commit-message: + prefix: ci diff --git a/.github/scripts/check-frontmatter.mjs b/.github/scripts/check-frontmatter.mjs new file mode 100644 index 0000000..164b1ce --- /dev/null +++ b/.github/scripts/check-frontmatter.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +/** + * The frontmatter arms that a JSON Schema cannot express, plus the extraction + * ajv validates. + * + * Each arm mirrors a failure that would otherwise surface in the docs-site + * repository at pin-bump time, where the person who caused it is not looking: + * the site takes a page title from the leading H1 and a meta description from + * `scope`, and it throws on a page whose body does not open with an H1. + * + * Usage: node .github/scripts/check-frontmatter.mjs [root] + */ +import { access, mkdir, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { leadingHeading, pagesUnder, readPage, FrontmatterError } from './lib/pages.mjs'; + +/** Every tree the schema binds. `skills/` carries a skill's own frontmatter and README.md carries none. */ +const TREES = ['reference', 'guides', 'tutorials', 'concepts', 'learning']; +const ROOT_PAGES = ['AGENTS.md', 'CLAUDE.md', 'validation-log.md']; + +/** + * The trees the docs site renders as routes. The band below is a meta + * description budget, so it binds a page that becomes one and says nothing + * about a page the site excludes from rendering. + */ +const RENDERED = ['reference/', 'guides/', 'tutorials/', 'concepts/']; + +/** The site composes a title from the H1 and fails its own build over the budget. */ +const MAX_HEADING = 40; + +/** The meta description band the site's SEO gate holds a rendered page to. */ +const SCOPE_BAND = { min: 140, max: 160 }; + +const [outdir, root = process.cwd()] = process.argv.slice(2); + +if (outdir === undefined) { + console.error('usage: node .github/scripts/check-frontmatter.mjs [root]'); + process.exit(2); +} + +const from = resolve(root); +const to = resolve(outdir); +const findings = []; + +async function exists(path) { + try { + await access(join(from, path)); + + return true; + } catch { + return false; + } +} + +const pages = []; +for (const tree of TREES) pages.push(...(await pagesUnder(from, tree))); +for (const page of ROOT_PAGES) if (await exists(page)) pages.push(page); + +await mkdir(to, { recursive: true }); + +for (const page of pages) { + let read; + try { + read = await readPage(from, page); + } catch (error) { + if (!(error instanceof FrontmatterError)) throw error; + findings.push(error.message); + continue; + } + + await writeFile(join(to, `${page.replaceAll('/', '__').replace(/\.md$/, '')}.yml`), `${read.block}\n`); + + const heading = leadingHeading(read.body); + if (heading === null) findings.push(`${page} body does not open with an H1`); + else if (heading.length > MAX_HEADING) { + findings.push(`${page} H1 is ${heading.length} characters, over ${MAX_HEADING}: ${heading}`); + } + + const scope = read.values.get('scope'); + if (typeof scope === 'string' && RENDERED.some((tree) => page.startsWith(tree))) { + if (scope.length < SCOPE_BAND.min || scope.length > SCOPE_BAND.max) { + findings.push(`${page} scope is ${scope.length} characters, outside ${SCOPE_BAND.min} to ${SCOPE_BAND.max}`); + } + } + + for (const entry of read.values.get('depends-on') ?? []) { + if (!(await exists(entry))) findings.push(`${page} depends-on names a page that does not exist: ${entry}`); + } +} + +console.log(`frontmatter: read ${pages.length} pages, wrote ${pages.length} frontmatter blocks for ajv`); + +for (const finding of findings) console.error(`error: ${finding}`); + +process.exit(findings.length === 0 ? 0 : 1); diff --git a/.github/scripts/check-validation-consistency.mjs b/.github/scripts/check-validation-consistency.mjs new file mode 100644 index 0000000..d4b4fde --- /dev/null +++ b/.github/scripts/check-validation-consistency.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +/** + * Holds the provenance ledger and the pages to each other. This is the check + * nobody downstream can run: a consumer of this corpus sees a page and a tier, + * and cannot tell that the tier belongs to a page that no longer exists or that + * a page was never graded at all. + * + * Three arms: + * - a reference or guides page with no row in the ledger, + * - a ledger row naming a page that does not exist, + * - a page whose `key-modules` names a baseline the ledger does not pin. + * + * Usage: node .github/scripts/check-validation-consistency.mjs [root] + */ +import { readFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { pagesUnder, readPage } from './lib/pages.mjs'; + +/** The trees the ledger grades. Tutorials and concepts carry no tier by design. */ +const GRADED = ['reference', 'guides']; + +/** Every tree that carries `key-modules`, so a new one is covered when it lands. */ +const PINNED = ['reference', 'guides', 'tutorials', 'concepts']; + +/** The ledger's own path. U8 moves it into the rendered tree; both spellings resolve. */ +const LEDGER = ['validation-log.md', 'reference/validation.md']; + +const root = resolve(process.argv[2] ?? process.cwd()); + +/** The body of one `## ` section, by its exact heading text. */ +function section(source, heading) { + const pattern = new RegExp(String.raw`^## ${heading}\s*$([\s\S]*?)(?=^## |\Z)`, 'm'); + const found = pattern.exec(source); + + return found === null ? null : found[1]; +} + +async function readLedger() { + for (const path of LEDGER) { + try { + return { path, source: await readFile(join(root, path), 'utf8') }; + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + } + + return null; +} + +const ledger = await readLedger(); +const findings = []; + +if (ledger === null) { + console.error(`error: no provenance ledger at ${LEDGER.join(' or ')}`); + process.exit(1); +} + +const baselinesSection = section(ledger.source, 'Pinned baselines'); +const pagesSection = section(ledger.source, 'Pages'); + +if (baselinesSection === null) findings.push(`${ledger.path} has no "## Pinned baselines" section`); +if (pagesSection === null) findings.push(`${ledger.path} has no "## Pages" section`); + +/** + * A pin rather than a name: a version, a commit, or a branch. One bullet can + * pin two baselines at once, so every backticked token on a bullet line is a + * candidate and the pins are what gets dropped. + */ +function isPin(token) { + return /^v?\d/.test(token) || /^[0-9a-f]{7,40}$/.test(token) || token === 'main'; +} + +const baselines = [...(baselinesSection ?? '').matchAll(/^- .*$/gm)] + .flatMap((line) => [...line[0].matchAll(/`([^`]+)`/g)].map((found) => found[1])) + .filter((token) => !isPin(token)); + +/** The first cell of each table row is the page the row grades. */ +const rows = new Map(); +for (const found of (pagesSection ?? '').matchAll(/^\| *`([^`]+)` *\|/gm)) { + rows.set(found[1], (rows.get(found[1]) ?? 0) + 1); +} + +const graded = []; +for (const tree of GRADED) graded.push(...(await pagesUnder(root, tree))); + +for (const page of graded) { + if (page === ledger.path) continue; + if (!rows.has(page)) findings.push(`${page} has no row in ${ledger.path}`); +} + +for (const [page, count] of rows) { + if (!graded.includes(page)) findings.push(`${ledger.path} grades a page that does not exist: ${page}`); + if (count > 1) findings.push(`${ledger.path} grades ${page} in ${count} rows`); +} + +const pinned = []; +for (const tree of PINNED) pinned.push(...(await pagesUnder(root, tree))); + +for (const page of pinned) { + if (page === ledger.path) continue; + const { values } = await readPage(root, page); + + for (const entry of values.get('key-modules') ?? []) { + if (baselines.some((baseline) => entry.includes(baseline))) continue; + findings.push(`${page} key-modules names a baseline ${ledger.path} does not pin: ${entry}`); + } +} + +console.log( + `validation-consistency: ${graded.length} graded pages, ${rows.size} ledger rows, ${baselines.length} pinned baselines`, +); + +for (const finding of findings) console.error(`error: ${finding}`); + +process.exit(findings.length === 0 ? 0 : 1); diff --git a/.github/scripts/install-ripgrep.sh b/.github/scripts/install-ripgrep.sh new file mode 100755 index 0000000..1d1cb37 --- /dev/null +++ b/.github/scripts/install-ripgrep.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Installs ripgrep into a job-owned directory and puts it on the job's PATH. +# +# The runner image does not carry ripgrep, and three checks are ripgrep rules. +# The version and its checksum are pinned here rather than taken from apt, +# because a rule that silently changes engine between runs is a rule nobody can +# reason about: the wallet arm and the casing arm both need PCRE2 lookarounds, +# which this build carries and a distribution build need not. +set -euo pipefail + +VERSION='15.2.0' +SHA256='33e15bcf1624b25cdd2a55813a47a2f95dbe126268203e76aa6a585d1e7b149c' +TARGET="ripgrep-${VERSION}-x86_64-unknown-linux-musl" + +archive="${RUNNER_TEMP}/${TARGET}.tar.gz" + +curl --fail --silent --show-error --location --output "${archive}" \ + "https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${TARGET}.tar.gz" + +echo "${SHA256} ${archive}" | sha256sum --check --status + +tar --extract --gzip --file "${archive}" --directory "${RUNNER_TEMP}" + +echo "${RUNNER_TEMP}/${TARGET}" >> "${GITHUB_PATH}" +"${RUNNER_TEMP}/${TARGET}/rg" --version | head -1 diff --git a/.github/scripts/lib/pages.mjs b/.github/scripts/lib/pages.mjs new file mode 100644 index 0000000..e06a789 --- /dev/null +++ b/.github/scripts/lib/pages.mjs @@ -0,0 +1,147 @@ +/** + * Reads the frontmatter block every page in this corpus carries, and walks the + * page trees the checks run over. + * + * The reader covers the shapes `.github/frontmatter.schema.json` admits and + * nothing else: a scalar, a flow sequence, and a block sequence, each of + * strings. Anything else throws by name and line rather than parsing to + * something plausible, because a check that silently reads a shape it does not + * understand reports green on a page it never looked at. The schema itself is + * validated by ajv, which is a real YAML and JSON Schema implementation; this + * reader serves the arms ajv cannot express. + */ +import { readdir, readFile } from 'node:fs/promises'; +import { join, relative } from 'node:path'; + +const KEY = /^([A-Za-z][\w-]*):[ \t]*(.*)$/; +const ITEM = /^[ \t]+-[ \t]+(.*)$/; + +/** Strips one layer of matching quotes, which is all YAML needs here. */ +function unquote(value) { + const trimmed = value.trim(); + const quote = trimmed.slice(0, 1); + + if ((quote === '"' || quote === "'") && trimmed.length > 1 && trimmed.endsWith(quote)) { + return trimmed.slice(1, -1); + } + + return trimmed; +} + +/** Splits a flow sequence on the commas that sit outside a quoted item. */ +function splitFlow(body) { + const items = []; + let item = ''; + let quote = null; + + for (const character of body) { + if (quote !== null) { + if (character === quote) quote = null; + item += character; + continue; + } + if (character === '"' || character === "'") { + quote = character; + item += character; + continue; + } + if (character === ',') { + items.push(item); + item = ''; + continue; + } + item += character; + } + items.push(item); + + return items.map(unquote).filter((entry) => entry !== ''); +} + +export class FrontmatterError extends Error {} + +/** + * Returns the frontmatter as a map, the raw block for ajv, and the body that + * follows it. A page with no block throws: the site reads `scope` off every + * page it renders, so an absent block is a failed sync rather than a default. + */ +export function readFrontmatter(source, file) { + const lines = source.split('\n'); + + if (lines[0] !== '---') throw new FrontmatterError(`${file}:1 no frontmatter block`); + + const close = lines.indexOf('---', 1); + if (close === -1) throw new FrontmatterError(`${file}:1 frontmatter block is never closed`); + + const block = lines.slice(1, close); + const values = new Map(); + let current = null; + + for (const [offset, line] of block.entries()) { + const at = `${file}:${offset + 2}`; + if (line.trim() === '') continue; + + const item = ITEM.exec(line); + if (item !== null) { + if (current === null) throw new FrontmatterError(`${at} list item before any key`); + const list = values.get(current); + if (!Array.isArray(list)) throw new FrontmatterError(`${at} list item under a scalar key`); + list.push(unquote(item[1])); + continue; + } + + const key = KEY.exec(line); + if (key === null) throw new FrontmatterError(`${at} unreadable frontmatter line: ${line}`); + + const [, name, rest] = key; + current = name; + + if (rest === '') { + values.set(name, []); + continue; + } + if (rest.startsWith('[')) { + if (!rest.endsWith(']')) throw new FrontmatterError(`${at} flow sequence is never closed`); + values.set(name, splitFlow(rest.slice(1, -1))); + continue; + } + values.set(name, unquote(rest)); + } + + return { values, block: block.join('\n'), body: lines.slice(close + 1), bodyOffset: close + 2 }; +} + +/** The leading H1 of a body, or null when the body does not open with one. */ +export function leadingHeading(body) { + for (const line of body) { + if (line.trim() === '') continue; + const heading = /^# +(.*?)\s*$/.exec(line); + + return heading === null ? null : heading[1]; + } + + return null; +} + +/** Every markdown page under `directory`, repository-relative, sorted. */ +export async function pagesUnder(root, directory, found = []) { + let entries; + try { + entries = await readdir(join(root, directory), { withFileTypes: true }); + } catch (error) { + if (error.code === 'ENOENT') return found; + throw error; + } + + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + const path = join(directory, entry.name); + if (entry.isDirectory()) await pagesUnder(root, path, found); + else if (entry.isFile() && entry.name.endsWith('.md')) found.push(relative('.', path)); + } + + return found; +} + +/** The frontmatter of one page, with the page path carried for the message. */ +export async function readPage(root, page) { + return readFrontmatter(await readFile(join(root, page), 'utf8'), page); +} diff --git a/.github/scripts/strip-code.mjs b/.github/scripts/strip-code.mjs new file mode 100644 index 0000000..ebc769c --- /dev/null +++ b/.github/scripts/strip-code.mjs @@ -0,0 +1,129 @@ +#!/usr/bin/env node +/** + * Writes a copy of every markdown page with its code removed, so a prose rule + * never reads a code sample. + * + * Fenced blocks and inline code spans become blank space of the same shape: + * every character goes to a space and every newline survives, so a finding in + * the stripped copy carries the line and column of the source line it came + * from. The corpus writes account names, ABI type names, and shell snippets in + * code, and a prose rule that cannot tell code from prose either fails on a + * sample or gets turned off. + * + * Usage: node .github/scripts/strip-code.mjs [root] + */ +import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, join, relative, resolve } from 'node:path'; + +const SKIP = new Set(['.git', 'node_modules', '.github']); + +/** Every markdown page under `root`, repository-relative, sorted. */ +async function markdownFiles(root, directory = root, found = []) { + const entries = await readdir(directory, { withFileTypes: true }); + + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (entry.isDirectory()) { + if (SKIP.has(entry.name)) continue; + await markdownFiles(root, join(directory, entry.name), found); + continue; + } + if (entry.isFile() && entry.name.endsWith('.md')) { + found.push(relative(root, join(directory, entry.name))); + } + } + + return found; +} + +/** Same width, same newlines, no content. */ +function blank(text) { + return text.replaceAll(/[^\n]/g, ' '); +} + +/** + * Blanks fenced blocks first, because a fence is one opaque region: a backtick + * inside a shell sample is not a code-span delimiter, and pairing it with a + * later one would swallow the prose between them. + */ +function stripFences(source) { + const lines = source.split('\n'); + let fence = null; + + return lines + .map((line) => { + const opener = /^ {0,3}(`{3,}|~{3,})/.exec(line); + + if (fence === null) { + if (opener === null) return line; + fence = opener[1][0].repeat(opener[1].length); + return blank(line); + } + + const closer = new RegExp(String.raw`^ {0,3}${fence[0]}{${fence.length},}\s*$`).exec(line); + if (closer !== null) fence = null; + + return blank(line); + }) + .join('\n'); +} + +/** + * A code span opens on a backtick run and closes on the next run of the same + * length, which is the CommonMark rule. A run with no partner is a literal + * backtick and stays. + */ +function stripCodeSpans(source) { + const characters = [...source]; + const runs = []; + + for (let at = 0; at < characters.length; ) { + if (characters[at] !== '`') { + at += 1; + continue; + } + let end = at; + while (end < characters.length && characters[end] === '`') end += 1; + runs.push({ start: at, length: end - at }); + at = end; + } + + const open = new Map(); + const spans = []; + + for (const run of runs) { + const partner = open.get(run.length); + if (partner === undefined) { + open.set(run.length, run); + continue; + } + spans.push([partner.start, run.start + run.length]); + open.clear(); + } + + let stripped = source; + for (const [start, end] of spans.reverse()) { + stripped = stripped.slice(0, start) + blank(stripped.slice(start, end)) + stripped.slice(end); + } + + return stripped; +} + +export function strip(source) { + return stripCodeSpans(stripFences(source)); +} + +const [outdir, root = process.cwd()] = process.argv.slice(2); + +if (outdir === undefined) { + console.error('usage: node .github/scripts/strip-code.mjs [root]'); + process.exit(2); +} + +const from = resolve(root); +const to = resolve(outdir); + +for (const page of await markdownFiles(from)) { + const target = join(to, page); + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, strip(await readFile(join(from, page), 'utf8'))); +} diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..575adad --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,354 @@ +# Every check this repository has. No job carries a `name`, so the check a +# reader sees and the check branch protection requires are both the job id. +# +# Every job can fail a pull request, because there is no other gate here. The +# two network-dependent arms are the exception and both sit off the +# pull-request path: external URL checking and starter signing run on the weekly +# schedule, so a third-party host outage never blocks a merge. +# +# Actions are pinned by commit sha with the version in the trailing comment. A +# tag is a moving target, and this workflow is what stands between the corpus +# and a bad merge. +name: checks + +on: + pull_request: + push: + branches: [main] + schedule: + # Weekly. The online link arm and the signing starters run here. + - cron: '17 6 * * 1' + +# Every job reads the tree and writes nothing back. +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + # A superseded pull-request run has nothing left to say. A push to the + # default branch is the record of what landed, so it runs to the end. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + # A relative markdown link whose target does not exist. + links: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Relative links resolve + if: github.event_name != 'schedule' + uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0 + with: + lycheeVersion: v0.24.2 + args: --offline --no-progress --exclude-path .git './**/*.md' + fail: true + + - name: External hosts answer + if: github.event_name == 'schedule' + uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0 + with: + lycheeVersion: v0.24.2 + args: --no-progress --max-concurrency 4 --timeout 20 --max-retries 2 --exclude-path .git './**/*.md' + fail: true + + # A `#fragment` with no matching heading id. This mirrors the docs site's own + # link gate and moves the failure to the pull request that caused it. + anchors: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # A fragment resolves inside the checkout, so this arm is offline on + # every event. It reports a missing file as well as a missing + # fragment, because lychee has no fragments-only mode. + - name: Heading fragments resolve + uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0 + with: + lycheeVersion: v0.24.2 + args: --offline --include-fragments --no-progress --exclude-path .git './**/*.md' + fail: true + + # The frontmatter block every page carries, against the committed schema, + # plus the three arms a JSON Schema cannot express. + frontmatter: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + + # Also extracts each block as YAML for ajv below. `skills/` carries a + # skill's own frontmatter and README.md carries none, so the walk + # covers neither. + - name: H1 length, scope band, and depends-on targets + run: node .github/scripts/check-frontmatter.mjs "${RUNNER_TEMP}/frontmatter" + + - name: Frontmatter matches the schema + run: | + npx --yes ajv-cli@5.0.0 validate \ + --spec=draft2020 \ + -s .github/frontmatter.schema.json \ + -d "${RUNNER_TEMP}/frontmatter/*.yml" \ + --all-errors + + # Terms this corpus does not use, read on the stripped copy. + banned-terms: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + + - name: Install ripgrep + run: .github/scripts/install-ripgrep.sh + + - name: Strip code from every page + run: node .github/scripts/strip-code.mjs "${RUNNER_TEMP}/stripped" + + - name: No banned term in prose + run: | + # The pattern file carries comments and blank lines for the + # reader. A blank line is an empty regex that matches every + # line, so both go before ripgrep sees the file. + grep -vE '^[[:space:]]*(#|$)' .github/banned-terms.txt > "${RUNNER_TEMP}/banned-terms.rg" + + cd "${RUNNER_TEMP}/stripped" + rg --pcre2 --line-number --no-heading --color never -f "${RUNNER_TEMP}/banned-terms.rg" . && found=0 || found=$? + + case "${found}" in + 0) echo "::error::a banned term reached prose, see the matches above"; exit 1 ;; + 1) echo "no banned term in prose" ;; + *) echo "::error::ripgrep failed with ${found}"; exit "${found}" ;; + esac + + # An em-dash and a curly quote fail. A bold-header bullet fails on the two + # trees a reader arrives at and warns elsewhere. The AI-frequent vocabulary + # warns, because one use in a paragraph is permitted prose. + prose-bans: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + + - name: Install ripgrep + run: .github/scripts/install-ripgrep.sh + + - name: Strip code from every page + run: node .github/scripts/strip-code.mjs "${RUNNER_TEMP}/stripped" + + - name: No AI tell in prose + run: | + cd "${RUNNER_TEMP}/stripped" + failed=0 + + # One helper for both severities, so a rule cannot drift into + # reporting the opposite of what it means. ripgrep answers 0 + # for a match, 1 for none, and anything else is a tool failure + # that must not read as a pass. + scan() { + local severity="$1" message="$2" pattern="$3" + shift 3 + rg --pcre2 --line-number --no-heading --color never -e "${pattern}" "$@" && found=0 || found=$? + + case "${found}" in + 0) + echo "::${severity}::${message}" + if [ "${severity}" = error ]; then failed=1; fi + ;; + 1) ;; + *) + echo "::error::ripgrep failed with ${found}" + exit "${found}" + ;; + esac + } + + # The character classes are written as escapes so this file + # does not have to carry the characters it bans. + scan error 'em-dash: use a hyphen, a comma, a period, or parentheses' '\x{2014}' . + scan error 'curly quote: this corpus writes straight quotes' '[\x{2018}\x{2019}\x{201C}\x{201D}]' . + scan error 'bold-header bullet: write the sentence instead of a label and a colon' \ + '^\s*[-*+]\s+\*\*[^*]*[A-Za-z][^*]*(:\s*\*\*|\*\*\s*:)' reference guides + scan warning 'bold-header bullet outside the reference and guides trees' \ + '^\s*[-*+]\s+\*\*[^*]*[A-Za-z][^*]*(:\s*\*\*|\*\*\s*:)' --glob '!reference/**' --glob '!guides/**' . + # "key" and "landscape" are on the standard's list and off this + # one: this corpus writes about primary keys and key modules, + # and those false positives would bury the real findings. + scan warning 'AI-frequent vocabulary, rewrite a paragraph that carries three' \ + '(?i)\b(additionally|comprehensive|crucial|delve|deep dive|dive into|empower|enhance|facilitate|foster|garner|intricate|leverage|pivotal|showcase|streamline|tapestry|testament|underscore|utilize|vital|seamless|robust|boasts)\b' . + + exit "${failed}" + + # A misspelling in prose. Stripping is what lets a real account name pass as + # code and fail as prose. + spelling: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + + - name: Strip code from every page + run: node .github/scripts/strip-code.mjs "${RUNNER_TEMP}/stripped" + + - name: No misspelling in prose + uses: crate-ci/typos@8a48f81b6c64dcfea44b3633223084c4be58ac5f # v1.49.0 + with: + files: ${{ runner.temp }}/stripped + config: ./_typos.toml + + # A bare product token that is not title-cased. The corpus writes the account + # name and the product name in one sentence, and stripping is what separates + # them, which is why this is not folded into spelling. + casing: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + + - name: Install ripgrep + run: .github/scripts/install-ripgrep.sh + + - name: Strip code from every page + run: node .github/scripts/strip-code.mjs "${RUNNER_TEMP}/stripped" + + - name: Product names keep their casing + run: | + cd "${RUNNER_TEMP}/stripped" + + # A bare token only. A repository name, a package name, a + # hostname, and a path each carry a separator on one side, and + # each of those is a lowercase identifier a reader has to be + # able to copy. + pattern='(?