diff --git a/.github/workflows/actions.lock b/.github/workflows/actions.lock index a28bbd2e2..3bd04c292 100644 --- a/.github/workflows/actions.lock +++ b/.github/workflows/actions.lock @@ -45,6 +45,7 @@ workflows: - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' '.github/workflows/deed-conformance.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' + - 'oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6' '.github/workflows/dependabot-lock-regen.yml': - 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1' - 'actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1' diff --git a/.github/workflows/deed-conformance.yml b/.github/workflows/deed-conformance.yml index 54b3db873..3820a3a33 100644 --- a/.github/workflows/deed-conformance.yml +++ b/.github/workflows/deed-conformance.yml @@ -2,8 +2,21 @@ # SPDX-License-Identifier: MPL-2.0 name: deed-conformance -# Conformance lane for the DEED grammar (standards#837). Three gates: -# validator self-test → fixture corpus → every committed *.deed file. +# Conformance lane for the DEED grammar (standards#837). +# +# In this repo (push / pull_request) three gates run: validator self-test, +# fixture corpus, every committed *.deed file. +# +# As a reusable (workflow_call) it lints the CALLER's committed *.deed files +# with the linter at THIS workflow's own commit (job.workflow_sha), so a +# caller pinned to a standards SHA gets exactly that linter. +# +# Caller: +# jobs: +# deed: +# uses: hyperpolymath/standards/.github/workflows/deed-conformance.yml@ +# with: +# require-files: true # fail when the caller has no .deed at all on: push: @@ -16,6 +29,13 @@ on: - '**.deed' - '1-formats/deed/**' - '.github/workflows/deed-conformance.yml' + workflow_call: + inputs: + require-files: + description: "Fail when the caller has no committed .deed file (a zero-file run is otherwise a pass)." + type: boolean + required: false + default: false permissions: {} @@ -23,20 +43,59 @@ jobs: lint: runs-on: ubuntu-latest timeout-minutes: 10 + permissions: + contents: read + env: + # In this repo the PR's own linter is in the first checkout; a caller + # gets the linter from the canon checkout at job.workflow_sha. + T: ${{ github.repository == 'hyperpolymath/standards' && '1-formats/deed/tools' || '.deed-canon/1-formats/deed/tools' }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: checkout the deed linter at this workflow's commit + if: github.repository != 'hyperpolymath/standards' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: hyperpolymath/standards + ref: ${{ job.workflow_sha }} + path: .deed-canon + persist-credentials: false + sparse-checkout: "1-formats/deed/tools\n" + sparse-checkout-cone-mode: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - name: validator self-test - run: python3 1-formats/deed/tools/deed_lint.py --self-test + if: github.repository == 'hyperpolymath/standards' + run: bun "$T/deed_lint.js" --self-test + - name: validator unit tests + if: github.repository == 'hyperpolymath/standards' + run: bun test "$T/" - name: fixture corpus (valid must pass, invalid must fail) - run: python3 1-formats/deed/tools/deed_lint.py --fixtures 1-formats/deed/tools/fixtures + if: github.repository == 'hyperpolymath/standards' + run: bun "$T/deed_lint.js" --fixtures "$T/fixtures" + - name: python/bun parity (byte-identical output over every committed deed) + if: github.repository == 'hyperpolymath/standards' + run: | + set -euo pipefail + find . -name '*.deed' -not -path './.deed-canon/*' -print | sort > "$RUNNER_TEMP/all" + test -s "$RUNNER_TEMP/all" + xargs python3 "$T/deed_lint.py" < "$RUNNER_TEMP/all" > "$RUNNER_TEMP/py" || true + xargs bun "$T/deed_lint.js" < "$RUNNER_TEMP/all" > "$RUNNER_TEMP/js" || true + wc -l "$RUNNER_TEMP/py" "$RUNNER_TEMP/js" + diff -u "$RUNNER_TEMP/py" "$RUNNER_TEMP/js" - name: lint every committed deed + env: + REQUIRE_FILES: ${{ inputs.require-files || false }} run: | - set -e - FILES=$(find . -name '*.deed' -not -path './1-formats/deed/tools/fixtures/invalid/*' -print) - if [ -z "$FILES" ]; then - echo "no committed .deed files yet — fixtures carry the gate" + set -euo pipefail + find . -name '*.deed' -not -path './.deed-canon/*' -not -path './1-formats/deed/tools/fixtures/invalid/*' -print | sort > "$RUNNER_TEMP/deeds" + if [ ! -s "$RUNNER_TEMP/deeds" ]; then + if [ "$REQUIRE_FILES" = "true" ]; then + echo "::error::require-files is set and no committed .deed file was found" + exit 1 + fi + echo "no committed .deed files — nothing to lint" exit 0 fi - echo "$FILES" | xargs python3 1-formats/deed/tools/deed_lint.py + echo "linting $(wc -l < "$RUNNER_TEMP/deeds") file(s)" + xargs bun "$T/deed_lint.js" < "$RUNNER_TEMP/deeds" diff --git a/1-formats/deed/tools/README.adoc b/1-formats/deed/tools/README.adoc index 1a66328ab..07edab2d6 100644 --- a/1-formats/deed/tools/README.adoc +++ b/1-formats/deed/tools/README.adoc @@ -7,9 +7,21 @@ Campaign: standards#837 · Grammar: link:../spec/abnf/deed.abnf[spec/abnf/deed.a *Nothing invalid ever lands.* Every emitted deed passes the validator before it may be written; every `.deed` committed in this repo is linted in CI. -== `deed_lint.py` — the validator +== `deed_lint.js` / `deed_lint.py` — the validator -Stdlib-only implementation of the normative grammar. Enforces: +Two implementations of the normative grammar, held byte-identical: + +* `deed_lint.js` — bun, zero dependencies. **This is the CI gate** + (`.github/workflows/deed-conformance.yml`, callable from other repos via + `workflow_call`). Unit tests: `bun test 1-formats/deed/tools/`. +* `deed_lint.py` — stdlib-only. Kept because `a2ml_to_deed.py` imports it + in-process to lint what it emits. Retire it with the translator. + +The workflow's parity step runs both over every committed `.deed` +(invalid fixtures included) and fails on any byte of difference in their +output, so the two cannot drift while both exist. + +Enforced rules: * SPDX header required; one form; full input consumed * doc-head whitelist (`estate-deed / repo-deed / estate-atlas-deed / praxis-deed`) @@ -25,11 +37,11 @@ Stdlib-only implementation of the normative grammar. Enforces: (K9-consistent); bare CR rejected (CRLF or LF line-ends only) * quoted values restricted to symbols and lists -Usage: +Usage (`deed_lint.py` takes the same arguments): - python3 deed_lint.py FILE.deed... - python3 deed_lint.py --self-test # 14-case embedded corpus - python3 deed_lint.py --fixtures fixtures/ # valid/ must pass, invalid/ must fail + bun deed_lint.js FILE.deed... + bun deed_lint.js --self-test # 14-case embedded corpus + bun deed_lint.js --fixtures fixtures/ # valid/ must pass, invalid/ must fail == `a2ml_to_deed.py` — the translator diff --git a/1-formats/deed/tools/deed_lint.js b/1-formats/deed/tools/deed_lint.js new file mode 100755 index 000000000..9d3b55fff --- /dev/null +++ b/1-formats/deed/tools/deed_lint.js @@ -0,0 +1,514 @@ +#!/usr/bin/env bun +// SPDX-License-Identifier: MPL-2.0 +// Port of deed_lint.py to plain JavaScript (bun, zero dependencies). +const DOC = String.raw`deed-lint — conformance validator for the DEED grammar (deed.abnf v1.0.0). + +Stdlib-only. Implements the normative grammar faithfully: + * header = 1* spdx-line (";;" SP "SPDX-" …) + * form = "(" doc-head 1*(sep (field/clause)) [sep] ")" + * doc-head ∈ {estate-deed, repo-deed, estate-atlas-deed, praxis-deed} + * field = keyword sep value clause = "(" symbol *(sep (field/clause)) [sep] ")" + * value = string / symbol / integer / boolean / uuid5 / quoted / list + * boolean = #t | #f (lowercase ONLY; true/false/1/0 are parse errors) + * uuid5 = %s"#u5" string (body is the RFC 4122 §4.3 NAME input) + * escapes exactly {" \\ \n \t} (\r, \uXXXX and all others INVALID) + * sep = 1*(SP / line-end / comment) (HTAB is INVALID — K9-consistent) + * symbol = ALPHA *(ALPHA/DIGIT/./"*"//"<"/">"/"="/"!"/"?"/"+"/"-"/"_") + * quoted = "'" (symbol / list) (only symbols/lists may be quoted) +Semantic checks (validator-enforced, per grammar): + * :schema-version STRING exactly once at form top level + * entire input consumed + * filename↔doc-head dispatch (estate-first side condition, stem≠"estate") + +Usage: deed_lint.js FILE... exit 0 iff all files conform + deed_lint.js --self-test +`; + +import { readdirSync, readFileSync } from "node:fs"; +import { basename, join } from "node:path"; + +const SYMBOL_START = /^[A-Za-z]$/; +const SYMBOL_CONT = /^[A-Za-z0-9.*/<>=!?+_-]$/; + +/** Python-style repr() of a string, used so messages match the Python linter. */ +function pyRepr(s) { + const q = s.includes("'") && !s.includes('"') ? '"' : "'"; + let out = q; + for (const ch of s) { + const o = ch.codePointAt(0); + if (ch === "\\") out += "\\\\"; + else if (ch === q) out += "\\" + q; + else if (ch === "\n") out += "\\n"; + else if (ch === "\r") out += "\\r"; + else if (ch === "\t") out += "\\t"; + else if (o < 0x20 || o === 0x7f) out += "\\x" + o.toString(16).padStart(2, "0"); + else out += ch; + } + return out + q; +} + +/** A single conformance failure with a 1-based line number. */ +export class LintError extends Error { + /** Build an error from its message and 1-based line number. */ + constructor(msg, line) { + super(msg); + this.name = "LintError"; + this.msg = msg; + this.line = line; + } + + /** Render as `line N: message`, like the Python __str__. */ + toString() { + return `line ${this.line}: ${this.msg}`; + } +} + +/** Cursor over the source text. */ +class Lexer { + /** Create a lexer positioned at the start of `text`. */ + constructor(text) { + this.t = text; + this.i = 0; + this.n = text.length; + } + + /** 1-based line number of offset `at` (default: current position). */ + line(at = null) { + const end = at === null ? this.i : at; + let c = 1; + for (let k = 0; k < end && k < this.n; k++) if (this.t[k] === "\n") c++; + return c; + } + + /** Character at offset i+k, or "" past the end. */ + peek(k = 0) { + const j = this.i + k; + return j < this.n ? this.t[j] : ""; + } + + /** token-sep = 1*(SP / line-end / comment). Returns 1 if any separator was seen, else 0. */ + skipSep() { + let seen = 0; + while (this.i < this.n) { + const c = this.t[this.i]; + if (c === " ") { + this.i += 1; + seen = 1; + } else if (c === "\n") { + this.i += 1; + seen = 1; + } else if (c === "\r") { + if (this.t.slice(this.i, this.i + 2) === "\r\n") { + this.i += 2; + seen = 1; + } else { + throw new LintError("bare CR is not a line-end (CRLF or LF only)", this.line()); + } + } else if (c === ";") { + while (this.i < this.n && this.t[this.i] !== "\r" && this.t[this.i] !== "\n") this.i += 1; + seen = 1; + } else { + break; + } + } + return seen; + } +} + +/** string = DQUOTE *( str-char / escape ) DQUOTE ; exactly 4 escapes. */ +function lexString(lx) { + const start = lx.i; + const out = []; + lx.i += 1; + for (;;) { + if (lx.i >= lx.n) throw new LintError("unterminated string", lx.line(start)); + const c = lx.t[lx.i]; + if (c === '"') { + lx.i += 1; + return ["string", out.join("")]; + } + if (c === "\\") { + if (lx.i + 1 >= lx.n) throw new LintError("dangling backslash", lx.line(start)); + const e = lx.t[lx.i + 1]; + if (e !== '"' && e !== "\\" && e !== "n" && e !== "t") { + throw new LintError( + `illegal escape \\${pyRepr(e)} — only \\" \\\\ \\n \\t exist in DEED ` + + "(\\r and \\uXXXX are parse errors; embed non-ASCII as raw UTF-8)", + lx.line(start), + ); + } + out.push(e === "n" ? "\n" : e === "t" ? "\t" : e); + lx.i += 2; + continue; + } + const o = c.codePointAt(0); + if (o < 0x20) { + throw new LintError( + `raw control character U+${o.toString(16).toUpperCase().padStart(4, "0")} inside string (use legal escapes)`, + lx.line(), + ); + } + out.push(c); + lx.i += 1; + } +} + +/** Lex an optionally negative decimal integer; returns ["integer", null] or null. */ +function lexNumber(lx) { + const m = /^-?[0-9]+/.exec(lx.t.slice(lx.i)); + if (m) { + lx.i += m[0].length; + return ["integer", null]; + } + return null; +} + +/** Lex a symbol (ALPHA followed by symbol-continuation chars); returns ["symbol", s] or null. */ +function lexSymbol(lx) { + if (!SYMBOL_START.test(lx.peek())) return null; + let j = lx.i + 1; + while (j < lx.n && SYMBOL_CONT.test(lx.t[j])) j += 1; + const s = lx.t.slice(lx.i, j); + lx.i = j; + return ["symbol", s]; +} + +/** True if c is an ASCII digit. */ +function isDigit(c) { + return c !== "" && c >= "0" && c <= "9"; +} + +/** value = string / symbol / integer / boolean / uuid5 / quoted / list */ +function lexValue(lx) { + const c = lx.peek(); + if (c === '"') return lexString(lx); + if (c === "#") { + const two = lx.t.slice(lx.i, lx.i + 3); + if (two.startsWith("#u5")) { + lx.i += 3; + if (lx.peek() !== '"') { + throw new LintError('uuid5 must be followed immediately by a string: #u5"name"', lx.line()); + } + lexString(lx); + return ["uuid5", null]; + } + if (two.slice(0, 2) === "#t" || two.slice(0, 2) === "#f") { + const nxt = lx.peek(2); + if (nxt && !" \r\n()".includes(nxt)) { + const tok = lx.t.slice(lx.i + 2, lx.i + 24).trim().split(/\s+/)[0].slice(0, 20); + throw new LintError( + `booleans are exactly #t/#f (got ${pyRepr(two + tok)}); true/false/1/0 are parse errors`, + lx.line(), + ); + } + lx.i += 2; + return ["boolean", two]; + } + throw new LintError('unrecognised #-form: only #t, #f, #u5"…" are legal', lx.line()); + } + if (c === "(") { + lx.i += 1; + const items = []; + for (;;) { + lx.skipSep(); + if (lx.peek() === ")") { + lx.i += 1; + return ["list", items]; + } + items.push(lexValue(lx)); + } + } + if (c === "'") { + lx.i += 1; + lx.skipSep(); + const v = lexValue(lx); + if (v[0] !== "symbol" && v[0] !== "list") { + throw new LintError(`only symbols and lists may be quoted, not ${v[0]}`, lx.line()); + } + return ["quoted", v]; + } + if (c === ":") throw new LintError("stray keyword — a keyword may only lead a field", lx.line()); + if (isDigit(c) || (c === "-" && isDigit(lx.peek(1)))) { + const v = lexNumber(lx); + const nxt = lx.peek(); + if (nxt && (SYMBOL_CONT.test(nxt) || /^\p{L}$/u.test(nxt))) { + throw new LintError("malformed token: number followed by identifier characters", lx.line()); + } + return v; + } + const v = lexSymbol(lx); + if (v) return v; + throw new LintError( + `cannot lex value starting at ${pyRepr(c)} ('=' as a field separator is not a deed)`, + lx.line(), + ); +} + +/** field = ":" keyword sep value, or clause = "(" symbol *(field / clause) ")". */ +function lexFieldOrClause(lx) { + const c = lx.peek(); + if (c === ":") { + lx.i += 1; + const kw = lexSymbol(lx); + if (!kw) throw new LintError("malformed keyword: ':' must be followed by a symbol", lx.line()); + if (!lx.skipSep()) { + throw new LintError(`keyword :${kw[1]} must be followed by a separator before its value`, lx.line()); + } + const val = lexValue(lx); + if (val[0] === "symbol" && ["true", "false", "yes", "no"].includes(val[1])) { + // Grammar note: "Never true, false, yes, no." These lex as symbols, so the + // ban is enforced here as a value-level semantic rule. + throw new LintError( + `boolean meaning must use #t/#f — bare symbol ${pyRepr(val[1])} is forbidden as a value`, + lx.line(), + ); + } + return ["field", kw[1], val]; + } + if (c === "(") { + lx.i += 1; + const head = lexSymbol(lx); + if (!head) { + throw new LintError("clause '(' must be followed immediately by a clause symbol (no separator)", lx.line()); + } + const items = []; + for (;;) { + lx.skipSep(); + if (lx.peek() === ")") { + lx.i += 1; + return ["clause", head[1], items]; + } + if (lx.peek() === "" && lx.i >= lx.n) { + throw new LintError(`unbalanced parens: clause (${head[1]}) never closes`, lx.line()); + } + items.push(lexFieldOrClause(lx)); + } + } + if (c === "") throw new LintError("unexpected end of input (unbalanced parens)", lx.line()); + throw new LintError(`expected field (':keyword …') or clause ('(symbol …)'), got ${pyRepr(c)}`, lx.line()); +} + +/** Parse the single top-level form; returns [docHead, items]. No separator after '('. */ +function parseForm(lx) { + if (lx.peek() !== "(") throw new LintError("a deed form must start with '('", lx.line()); + lx.i += 1; + const head = lexSymbol(lx); + const heads = ["estate-deed", "repo-deed", "estate-atlas-deed", "praxis-deed"]; + if (!head || !heads.includes(head[1])) { + const got = head ? head[1] : lx.peek(); + throw new LintError(`invalid doc-head ${pyRepr(got)}; valid heads: ${heads.join(", ")}`, lx.line()); + } + if (!lx.skipSep()) { + throw new LintError(`doc-head ${head[1]} must be followed by a separator before the first field`, lx.line()); + } + const items = []; + for (;;) { + lx.skipSep(); + if (lx.peek() === ")") { + lx.i += 1; + break; + } + if (lx.peek() === "") throw new LintError("unbalanced parens: form never closes", lx.line()); + items.push(lexFieldOrClause(lx)); + } + const schema = items.filter((it) => it[0] === "field" && it[1] === "schema-version"); + if (schema.length !== 1) { + throw new LintError( + `form must carry exactly one :schema-version STRING field (found ${schema.length})`, + lx.line(), + ); + } + if (schema[0][2][0] !== "string") throw new LintError(":schema-version must be a STRING value", lx.line()); + lx.skipSep(); + if (lx.i < lx.n) { + throw new LintError("trailing content after the form's closing ')' — a deed is exactly one form", lx.line()); + } + return [head[1], items]; +} + +/** header = 1*spdx-line; each ";; SPDX-" line must be non-empty. Returns the line count. */ +function parseHeader(lx) { + let count = 0; + for (;;) { + if (lx.t.startsWith(";; SPDX-", lx.i)) { + const eol = lx.t.indexOf("\n", lx.i); + if (eol === -1) throw new LintError("SPDX header line has no line-end", lx.line()); + const payload = lx.t.slice(lx.i + 8, eol); + if (!payload.trim()) throw new LintError("SPDX header line is empty after ';; SPDX-'", lx.line()); + lx.i = eol + 1; + count += 1; + continue; + } + break; + } + if (count === 0) { + throw new LintError( + "deed must begin with at least one SPDX header line (';; SPDX-License-Identifier: …')", + lx.line(), + ); + } + return count; +} + +/** Validate DEED source text. Returns [head, items] on success; throws LintError. */ +export function validate(text, filename = null) { + const tab = text.indexOf("\t"); + if (tab !== -1) { + let ln = 1; + for (let k = 0; k < tab; k++) if (text[k] === "\n") ln++; + throw new LintError("HTAB (tab) is an invalid separator anywhere in a deed (K9-consistent)", ln); + } + const lx = new Lexer(text); + parseHeader(lx); + lx.skipSep(); + const [head, items] = parseForm(lx); + if (filename) checkFilenameDispatch(filename, head); + return [head, items]; +} + +/** estate-file exact-first; stems may contain dots (split on the final suffix). */ +export function checkFilenameDispatch(filename, head) { + const base = basename(filename); + let want; + if (base === "estate_chora.deed") { + want = "estate-deed"; + } else if (base === "ATLAS.deed") { + want = "estate-atlas-deed"; + } else if (base.endsWith("_praxis.deed")) { + want = "praxis-deed"; + } else if (base.endsWith("_chora.deed")) { + const stem = base.slice(0, -"_chora.deed".length); + if (stem === "estate") return; + if (!/^[A-Za-z0-9\-._]+$/.test(stem)) throw new LintError(`illegal deed filename stem ${pyRepr(stem)}`, 1); + want = "repo-deed"; + } else { + throw new LintError( + `filename ${pyRepr(base)} matches no deed dispatch pattern ` + + "(estate_chora.deed | ATLAS.deed | _chora.deed | _praxis.deed)", + 1, + ); + } + if (head !== want) { + throw new LintError(`doc-head/filename mismatch: ${base} dispatches to ${want} but parses as ${head}`, 1); + } +} + +const H = ";; SPDX-License-Identifier: CC-BY-SA-4.0\n"; + +/** The embedded self-test corpus (same 14 cases, order and expectations as the Python). */ +export const SELF_TEST_CASES = [ + ["valid-minimal", true, + H + '(repo-deed :schema-version "1.0.0" :canonical-name "x" :repo-uuid #u5"github.com/o/x" :beholding-chora #u5"estate/chora")\n'], + ["valid-nested", true, + H + '(repo-deed\n :schema-version "1.0.0"\n:canonical-name "x" ; comment between\n (lineage :type hub :parent "" :previous-names ()) )\n'], + ["valid-booleans-uuid", true, + H + '(repo-deed :schema-version "1.0.0" (status :present #t :ended #f :note "legal escapes: \\n and \\t and \\\\ and \\"q\\""))\n'], + ["invalid-equals", false, + H + '(repo-deed :schema-version "1.0.0" :canonical-name = "x")\n'], + ["invalid-section", false, + H + '(repo-deed :schema-version "1.0.0"\n[status]\nphase = "active")\n'], + ["invalid-missing-schema", false, + H + '(repo-deed :canonical-name "x")\n'], + ["invalid-head", false, + H + '(chora-deed :schema-version "1.0.0")\n'], + ["invalid-true-literal", false, + H + '(repo-deed :schema-version "1.0.0" (status :present true))\n'], + ["invalid-tab", false, + H + '(repo-deed\t:schema-version "1.0.0")\n'], + ["invalid-escape-u", false, + H + '(repo-deed :schema-version "1.0.0" (m :s "bad \\u0041"))\n'], + ["invalid-trailing", false, + H + '(repo-deed :schema-version "1.0.0") trailing\n'], + ["invalid-no-header", false, + '(repo-deed :schema-version "1.0.0")\n'], + // Python labels this "invalid-string-after-head" then overrides index 12 to + // this valid case (proves quoted lists, 007 and bare symbols parse). + ["valid-quoted-list-symbols-007", true, + H + '(repo-deed :schema-version "not-string-issue" :other 007 :sym github-actions :q \'(a b))\n'], + ["invalid-unbalanced", false, + H + '(repo-deed :schema-version "1.0.0" (status :present #t)\n'], +].map(([name, expectOk, src]) => ({ name, expectOk, src })); + +/** Validate source; returns [ok, errorString]. */ +function tryValidate(src, filename) { + try { + validate(src, filename); + return [true, ""]; + } catch (e) { + if (e instanceof LintError) return [false, e.toString()]; + throw e; + } +} + +/** Run the embedded corpus, printing PASS/FAIL per case; returns exit code. */ +function selfTest() { + let ok = true; + for (const { name, expectOk, src } of SELF_TEST_CASES) { + const [got, err] = tryValidate(src); + const passed = got === expectOk; + ok = ok && passed; + const detail = passed ? "" : ` (expected ${expectOk ? "valid" : "error"}; got ${got ? "valid" : err})`; + console.log(`${passed ? "PASS " : "FAIL "}${name}${detail}`); + } + console.log("SELF-TEST " + (ok ? "OK" : "FAILED")); + return ok ? 0 : 1; +} + +/** valid/ must parse; invalid/ must fail. Returns exit code. */ +function fixtures(d) { + let bad = 0; + for (const [sub, expect] of [["valid", true], ["invalid", false]]) { + let names = []; + try { + names = readdirSync(join(d, sub)).filter((n) => n.endsWith(".deed")).sort(); + } catch { + // missing directory: nothing to check (Python glob yields no matches) + } + for (const n of names) { + const f = join(d, sub, n); + let got, err; + try { + [got, err] = tryValidate(readFileSync(f, "utf8"), f); + } catch (e) { + got = false; + err = String(e.message); + } + if (got !== expect) bad += 1; + console.log(`${got === expect ? "PASS " : "FAIL "}${f}` + (got === expect ? "" : ` (unexpected: ${err || "valid"})`)); + } + } + return bad ? 1 : 0; +} + +/** CLI entry: dispatch on --self-test / --fixtures / file list; returns exit code. */ +function main(argv) { + if (argv.includes("--self-test")) return selfTest(); + if (argv.includes("--fixtures")) { + const d = argv[argv.indexOf("--fixtures") + 1]; + if (d === undefined) { + console.log("--fixtures requires a DIR argument"); + return 2; + } + const rc = fixtures(d); + console.log("FIXTURES " + (rc === 0 ? "OK" : "FAILED")); + return rc; + } + const files = argv.filter((a) => !a.startsWith("-")); + if (files.length === 0) { + console.log(DOC); + return 2; + } + let bad = 0; + for (const f of files) { + try { + validate(readFileSync(f, "utf8"), f); + console.log(`OK ${f}`); + } catch (e) { + bad += 1; + console.log(`FAIL ${f}: ${e instanceof LintError ? e.toString() : e.message}`); + } + } + return bad ? 1 : 0; +} + +if (import.meta.main) process.exit(main(process.argv.slice(2))); diff --git a/1-formats/deed/tools/deed_lint.test.js b/1-formats/deed/tools/deed_lint.test.js new file mode 100644 index 000000000..76b7dc11c --- /dev/null +++ b/1-formats/deed/tools/deed_lint.test.js @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: MPL-2.0 +// bun test for deed_lint.js (port of deed_lint.py). Run: bun test 1-formats/deed/tools/ +import { describe, expect, test } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { validate, LintError, SELF_TEST_CASES } from "./deed_lint.js"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const LINT = join(HERE, "deed_lint.js"); +const FIX = join(HERE, "fixtures"); +const HDR = ";; SPDX-License-Identifier: CC-BY-SA-4.0\n"; + +/** Run validate on source; returns null if valid, else the error message. */ +function verdict(src, filename) { + try { + validate(src, filename); + return null; + } catch (e) { + if (e instanceof LintError) return e.message; + throw e; + } +} + +// Expected verdicts of the 14 embedded Python self-test cases (name, valid?). +const EXPECTED = [ + ["valid-minimal", true], + ["valid-nested", true], + ["valid-booleans-uuid", true], + ["invalid-equals", false], + ["invalid-section", false], + ["invalid-missing-schema", false], + ["invalid-head", false], + ["invalid-true-literal", false], + ["invalid-tab", false], + ["invalid-escape-u", false], + ["invalid-trailing", false], + ["invalid-no-header", false], + ["valid-quoted-list-symbols-007", true], + ["invalid-unbalanced", false], +]; + +describe("embedded self-test cases", () => { + test("14 cases, names and order match the Python", () => { + expect(SELF_TEST_CASES.map((c) => [c.name, c.expectOk])).toEqual(EXPECTED); + }); + for (const [name, ok] of EXPECTED) { + test(`${name} -> ${ok ? "valid" : "error"}`, () => { + const c = SELF_TEST_CASES.find((x) => x.name === name); + expect(c).toBeDefined(); + expect(verdict(c.src) === null).toBe(ok); + }); + } +}); + +// fixture filename -> regex the failure message must match (rule it names) +const RULE = { + "inequals_chora.deed": /'=' as a field separator/, + "inescape-u_chora.deed": /illegal escape/, + "inhead_chora.deed": /invalid doc-head/, + "inmissing-schema_chora.deed": /exactly one :schema-version/, + "inno-header_chora.deed": /at least one SPDX header/, + "insection_chora.deed": /expected field/, + "intab_chora.deed": /HTAB \(tab\)/, + "intrailing_chora.deed": /trailing content/, + "intrue-literal_chora.deed": /bare symbol 'true' is forbidden/, + "inunbalanced_chora.deed": /unbalanced parens/, +}; + +describe("fixtures/valid", () => { + for (const f of readdirSync(join(FIX, "valid")).filter((n) => n.endsWith(".deed")).sort()) { + test(f, () => { + const p = join(FIX, "valid", f); + expect(verdict(readFileSync(p, "utf8"), p)).toBeNull(); + }); + } +}); + +describe("fixtures/invalid", () => { + const files = readdirSync(join(FIX, "invalid")).filter((n) => n.endsWith(".deed")).sort(); + test("every invalid fixture has a rule mapping", () => { + expect(files).toEqual(Object.keys(RULE).sort()); + }); + for (const f of files) { + test(`${f} fails for its named rule`, () => { + const p = join(FIX, "invalid", f); + const msg = verdict(readFileSync(p, "utf8"), p); + expect(msg).not.toBeNull(); + expect(msg).toMatch(RULE[f]); + }); + } +}); + +describe("extra rules", () => { + test("filename dispatch mismatch", () => { + expect(verdict(HDR + '(repo-deed :schema-version "1")\n', "x_praxis.deed")).toMatch(/doc-head\/filename mismatch/); + }); + test("filename matching no pattern", () => { + expect(verdict(HDR + '(repo-deed :schema-version "1")\n', "x.deed")).toMatch(/no deed dispatch pattern/); + }); + test("estate_chora.deed needs estate-deed", () => { + expect(verdict(HDR + '(estate-deed :schema-version "1")\n', "estate_chora.deed")).toBeNull(); + expect(verdict(HDR + '(repo-deed :schema-version "1")\n', "estate_chora.deed")).toMatch(/mismatch/); + }); + test("bare CR rejected", () => { + expect(verdict(HDR + '(repo-deed\r:schema-version "1")\n')).toMatch(/bare CR/); + }); + test("CRLF accepted", () => { + expect(verdict(";; SPDX-License-Identifier: X\r\n(repo-deed\r\n :schema-version \"1\")\r\n")).toBeNull(); + }); + test("no trailing newline accepted", () => { + expect(verdict(HDR + '(repo-deed :schema-version "1")')).toBeNull(); + }); + test("bad boolean token #true", () => { + expect(verdict(HDR + '(repo-deed :schema-version "1" :a #true)\n')).toMatch(/booleans are exactly/); + }); + test("non-string schema-version", () => { + expect(verdict(HDR + "(repo-deed :schema-version 1)\n")).toMatch(/must be a STRING/); + }); + test("number followed by identifier chars", () => { + expect(verdict(HDR + '(repo-deed :schema-version "1" :a 12ab)\n')).toMatch(/malformed token/); + }); + test("only symbols/lists may be quoted", () => { + expect(verdict(HDR + '(repo-deed :schema-version "1" :a \'"s")\n')).toMatch(/may be quoted/); + }); + test("unterminated string", () => { + expect(verdict(HDR + '(repo-deed :schema-version "1" :a "oops)')).toMatch(/unterminated string/); + }); + test("error carries line number", () => { + const e = (() => { try { validate(HDR + "(chora-deed)\n"); } catch (x) { return x; } })(); + expect(e.line).toBe(2); + expect(String(e)).toMatch(/^line 2: /); + }); +}); + +/** Spawn `bun deed_lint.js ...args`; returns {code, out}. */ +function cli(...args) { + const r = Bun.spawnSync([process.execPath, LINT, ...args]); + return { code: r.exitCode, out: r.stdout.toString() + r.stderr.toString() }; +} + +describe("CLI", () => { + test("valid fixture exits 0", () => { + const p = join(FIX, "valid", "minimal_chora.deed"); + const r = cli(p); + expect(r.code).toBe(0); + expect(r.out).toContain(`OK ${p}`); + }); + test("invalid fixture exits nonzero", () => { + const p = join(FIX, "invalid", "intab_chora.deed"); + const r = cli(p); + expect(r.code).not.toBe(0); + expect(r.out).toContain(`FAIL ${p}: line 2: HTAB`); + }); + test("mixed files exit nonzero", () => { + expect(cli(join(FIX, "valid", "minimal_chora.deed"), join(FIX, "invalid", "intab_chora.deed")).code).toBe(1); + }); + test("missing file exits nonzero", () => { + expect(cli(join(FIX, "nope_chora.deed")).code).toBe(1); + }); + test("no args prints usage, exit 2", () => { + const r = cli(); + expect(r.code).toBe(2); + expect(r.out).toContain("deed-lint"); + }); + test("--self-test exits 0", () => { + const r = cli("--self-test"); + expect(r.code).toBe(0); + expect(r.out).toContain("SELF-TEST OK"); + }); + test("--fixtures exits 0", () => { + const r = cli("--fixtures", FIX); + expect(r.code).toBe(0); + expect(r.out).toContain("FIXTURES OK"); + }); +});