928 lines · 31.7 KB · JavaScript
RawDownload
import fs from "node:fs";import process from "node:process"; export function findTlc() {  const { TLA2TOOLS, JAVA } = process.env;  if (!TLA2TOOLS)    throw new Error(      "Cannot find TLC. Download tla2tools.jar from a TLA+ release:\n  https://github.com/tlaplus/tlaplus/releases\nThen set TLA2TOOLS to the path of tla2tools.jar, and JAVA to a java binary (Java 11 or later).",    );  if (!fs.existsSync(TLA2TOOLS)) throw new Error(`TLA2TOOLS is ${TLA2TOOLS}, which does not exist.`);  return { jar: TLA2TOOLS, java: JAVA || "java" };} export function parseValue(text) {  const src = text.trim();  let i = 0;  const ws = () => {    while (i < src.length && /\s/.test(src[i])) i++;  };  const peek = (s) => src.startsWith(s, i);  const eat = (s) => {    ws();    if (!peek(s)) throw new Error(`expected ${s} at ${i} in ${src}`);    i += s.length;  };  const list = (close, item) => {    const out = [];    ws();    if (peek(close)) {      i += close.length;      return out;    }    for (;;) {      out.push(item());      ws();      if (peek(",")) {        i++;        continue;      }      eat(close);      return out;    }  };  const value = () => {    ws();    if (peek("<<")) {      i += 2;      return list(">>", value);    }    if (peek("{")) {      i++;      return list("}", value);    }    if (peek("[")) {      i++;      const pairs = list("]", () => {        ws();        const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i));        if (!m) throw new Error(`bad record field at ${i}`);        i += m[0].length;        eat("|->");        return [m[0], value()];      });      return Object.fromEntries(pairs);    }    if (peek("(")) {      i++;      const fn = {};      for (;;) {        const k = value();        eat(":>");        fn[typeof k === "string" ? k : JSON.stringify(k)] = value();        ws();        if (peek("@@")) {          i += 2;          continue;        }        eat(")");        return fn;      }    }    if (peek('"')) {      i++;      let s = "";      while (i < src.length && src[i] !== '"') {        if (src[i] === "\\") i++;        s += src[i++];      }      i++;      return s;    }    const m = /^-?\d+|^[A-Za-z_][A-Za-z0-9_]*/.exec(src.slice(i));    if (!m) throw new Error(`bad value at ${i} in ${src}`);    i += m[0].length;    if (m[0] === "TRUE") return true;    if (m[0] === "FALSE") return false;    return /^-?\d+$/.test(m[0]) ? Number(m[0]) : m[0];  };  try {    const v = value();    ws();    return i === src.length ? v : src;  } catch {    return src;  }} // One state as TLC prints it: "/\ x = 1" lines, or "x = 1" for one variable.function parseState(text) {  const lines = text.split("\n").filter((l) => l.trim() !== "");  const conjuncts = [];  for (const line of lines) {    if (/^\s*\/\\ /.test(line) || conjuncts.length === 0)      conjuncts.push(line.replace(/^\s*\/\\ /, ""));    else conjuncts[conjuncts.length - 1] += ` ${line.trim()}`;  }  const vars = {};  for (const c of conjuncts) {    const eq = c.indexOf(" = ");    if (eq < 0) continue;    vars[c.slice(0, eq).trim()] = parseValue(c.slice(eq + 3));  }  return vars;} export const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v);const canon = (v) =>  Array.isArray(v)    ? v.map(canon)    : isRecord(v)      ? Object.fromEntries(          Object.keys(v)            .sort()            .map((k) => [k, canon(v[k])]),        )      : v;export const stateKey = (vars) => JSON.stringify(canon(vars)); export function ruleSentences(tla) {  const out = new Map();  const lines = tla.split("\n");  lines.forEach((line, i) => {    const name = /^([A-Za-z_]\w*)\s*==/.exec(line)?.[1];    if (!name) return;    const said = [];    for (let j = i - 1; j >= 0 && /^\s*\\\*/.test(lines[j]); j--)      said.unshift(lines[j].replace(/^\s*\\\*\s?/, "").trim());    const sentence = said.filter(Boolean).join(" ");    if (sentence) out.set(name, sentence);  });  return out;} export const tlaVariables = (code) =>  [...code.matchAll(/\bVARIABLES?\s+(\w+(?:\s*,\s*\w+)*)/g)].flatMap((m) => m[1].split(/\s*,\s*/)); function splitCommentsKeepingOffsets(text) {  let code = "";  let notes = "";  let block = 0;  let line = false;  let string = false;  const put = (c, n) => {    code += c;    notes += n;  };  for (let i = 0; i < text.length; i++) {    const c = text[i];    const two = text.slice(i, i + 2);    if (c === "\n") {      line = string = false;      put(c, c);    } else if (block) {      if (two === "(*" || two === "*)") {        block += two === "(*" ? 1 : -1;        put("  ", two);        i++;      } else put(" ", c);    } else if (line) put(" ", c);    else if (string) {      if (c === "\\" && text[i + 1] && text[i + 1] !== "\n") {        put(two, "  ");        i++;      } else {        string = c !== '"';        put(c, " ");      }    } else if (two === "\\*" || two === "(*") {      if (two === "\\*") line = true;      else block = 1;      put("  ", two);      i++;    } else {      string = c === '"';      put(c, " ");    }  }  return { code, notes };} const IDENTIFIER = /(?<![\\.\w])[A-Za-z_]\w*(?!\w)(?!\s*\|->)/g;const CITATION = /[\w./-]+\.[A-Za-z]\w*:\d+|#L\d+/; const DEPTH = { "(": 1, "[": 1, "{": 1, ")": -1, "]": -1, "}": -1 };const LOOSER_THAN_AND = /^(\\\/|=>|<=>|~>|-\+->|\\equiv\b)/;const RUNS_TO_END = /^(\\[EA]|IF|LET|CASE|CHOOSE)\b/;export const PRIMES_OR_UNCHANGED = /[\w)\]>}]'|\bUNCHANGED\b/; function conjuncts(bare, from, to, lineStart) {  const first = from + bare.slice(from, to).search(/\S/);  if (first < from) return null;  if (/^\\E\b/.test(bare.slice(first, to))) {    for (let i = first, depth = 0; i < to; i++) {      depth += DEPTH[bare[i]] ?? 0;      if (depth === 0 && bare[i] === ":") return conjuncts(bare, i + 1, to, lineStart);    }    return null;  }  if (bare.startsWith("/\\", first)) {    const column = first - lineStart(first);    const bullets = [first];    for (const m of bare.slice(first, to).matchAll(/\n([ \t]*)(?=\S)/g)) {      if (m[1].length < column) return null;      const at = first + m.index + m[0].length;      if (m[1].length === column && bare.startsWith("/\\", at)) bullets.push(at);    }    return bullets.flatMap((b, i) => {      const end = bullets[i + 1] ?? to;      return conjuncts(bare, b + 2, end, lineStart) ?? [[b + 2, end]];    });  }  const cuts = [];  let depth = 0;  for (let i = first; i < to; i++) {    const two = bare.slice(i, i + 2);    const ahead = bare.slice(i, i + 7);    if (two === "<<" || two === ">>") {      depth += two === "<<" ? 1 : -1;      i++;    } else if (DEPTH[bare[i]]) depth += DEPTH[bare[i]];    else if (depth > 0) continue;    else if (LOOSER_THAN_AND.test(ahead)) return null;    else if (!/\w/.test(bare[i - 1] ?? "") && RUNS_TO_END.test(ahead)) break;    else if (two === "/\\") cuts.push(i++);  }  return [first, ...cuts.map((c) => c + 2)].map((a, i) => [a, cuts[i] ?? to]);} export function uncitedConditions(text) {  const { code, notes } = splitCommentsKeepingOffsets(text);  const bare = code.replace(/"(?:[^"\\\n]|\\.)*"/g, (s) => " ".repeat(s.length));  const variables = new Set(tlaVariables(bare));  const starts = [0, ...[...bare.matchAll(/\n/g)].map((m) => m.index + 1)];  const lineOf = (at) => {    let lo = 0;    let hi = starts.length - 1;    while (lo < hi) {      const mid = (lo + hi + 1) >> 1;      if (starts[mid] <= at) lo = mid;      else hi = mid - 1;    }    return lo;  };  const lineStart = (at) => starts[lineOf(at)];  const lines = { bare: bare.split("\n"), notes: notes.split("\n") };   const defs = new Map();  const nextItem = /^(?![ \t\n]|\/\\|\\\/)/gm;  for (const m of bare.matchAll(    /^(?:LOCAL[ \t]+)?([A-Za-z_]\w*)[ \t]*(?:\([^)]*\)|\[[^\]]*\])?[ \t]*==/gm,  )) {    const from = m.index + m[0].length;    nextItem.lastIndex = from;    defs.set(m[1], { from, to: nextItem.exec(bare)?.index ?? bare.length, line: lineOf(m.index) });  }  const direct = (body) => {    const ids = body.match(IDENTIFIER) ?? [];    return {      reads: ids.filter((id) => variables.has(id)),      writes: [...body.matchAll(/(?<![\\.\w])([A-Za-z_]\w*)'/g)]        .map((m) => m[1])        .filter((id) => variables.has(id)),      acts: PRIMES_OR_UNCHANGED.test(body),      calls: [...new Set(ids.filter((id) => defs.has(id)))],    };  };  const own = new Map([...defs].map(([name, d]) => [name, direct(bare.slice(d.from, d.to))]));  const through = (start) => {    const out = { reads: new Set(start.reads), writes: new Set(start.writes), acts: start.acts };    const seen = new Set(start.calls);    const todo = [...seen];    while (todo.length) {      const d = own.get(todo.pop());      d.reads.forEach((v) => out.reads.add(v));      d.writes.forEach((v) => out.writes.add(v));      out.acts ||= d.acts;      for (const c of d.calls.filter((c) => !seen.has(c))) {        seen.add(c);        todo.push(c);      }    }    return out;  };  const cited = (from, to, { above }) => {    for (let l = from; l <= to; l++) if (CITATION.test(lines.notes[l])) return true;    for (let l = from - 1; above && !lines.bare[l].trim() && lines.notes[l].trim(); l--)      if (CITATION.test(lines.notes[l])) return true;    return false;  };   const found = [];  for (const [action, d] of defs) {    if (!own.get(action).acts) continue;    const changes = through(own.get(action)).writes;    for (const [a, b] of conjuncts(bare, d.from, d.to, lineStart) ?? []) {      const part = through(direct(bare.slice(a, b)));      if (part.acts || [...part.reads].every((v) => changes.has(v))) continue;      const first = lineOf(a + bare.slice(a, b).search(/\S/));      const last = lineOf(a + bare.slice(a, b).trimEnd().length - 1);      if (cited(first, last, { above: first > d.line })) continue;      found.push({        line: first + 1,        action,        condition: code.slice(a, b).replace(/\s+/g, " ").trim(),      });    }  }  return found;} function unescapeDot(s) {  let out = "";  for (let i = 0; i < s.length; i++) {    if (s[i] === "\\" && i + 1 < s.length) {      const n = s[++i];      out += n === "n" ? "\n" : n;    } else out += s[i];  }  return out;} export function parseDot(text) {  const nodes = new Map();  const edges = [];  const loops = [];  const seen = new Set();  const LABEL = '"((?:[^"\\\\]|\\\\.)*)"';  const nodeRe = new RegExp(`^(-?\\d+) \\[label=${LABEL}(.*)\\];?\\s*$`);  const edgeRe = new RegExp(`^(-?\\d+) -> (-?\\d+)(?: \\[label=${LABEL})?`);  for (const line of text.split("\n")) {    let m = edgeRe.exec(line);    if (m) {      const e = { from: m[1], to: m[2], action: m[3] ? unescapeDot(m[3]) : "" };      const id = `${e.from} ${e.to} ${e.action}`;      if (seen.has(id)) continue;      seen.add(id);      (e.from === e.to ? loops : edges).push(e);      continue;    }    m = nodeRe.exec(line);    if (m)      nodes.set(m[1], {        id: m[1],        vars: parseState(unescapeDot(m[2])),        init: /style\s*=\s*filled/.test(m[3]),      });  }  if (nodes.size === 0)    throw new Error("no states found; dump the graph with TLC -dump dot,actionlabels <file>");  const missing = [...edges, ...loops].find((e) => !nodes.has(e.from) || !nodes.has(e.to));  if (missing)    throw new Error(      `transition ${missing.from} -> ${missing.to} names a state the file does not define`,    );  if (edges.length > 0 && [...edges, ...loops].every((e) => !e.action))    throw new Error(      "the graph has no action names; dump it with TLC -dump dot,actionlabels <file>",    );  return { nodes, edges, loops };} export function parseTraces(text) {  const traces = [];  let invariant = null;  let current = null;  const lines = text.split("\n");  const stateAt = (i) => {    const body = [];    while (i + 1 < lines.length && lines[i + 1].trim() !== "") body.push(lines[++i]);    return [parseState(body.join("\n")), i];  };  for (let i = 0; i < lines.length; i++) {    const line = lines[i];    const inv = /^Error: Invariant (\S+) is violated( by the initial state:)?/.exec(line);    if (inv) {      invariant = inv[1];      if (inv[2]) {        const [vars, end] = stateAt(i);        traces.push({ invariant, steps: [{ action: null, vars }] });        i = end;      }      continue;    }    if (line.startsWith("Error: ") && !line.startsWith("Error: The behavior up to this point is"))      invariant = null;    const head = /^State (\d+): <([^\s>(]+)/.exec(line);    if (!head) continue;    const [vars, end] = stateAt(i);    i = end;    if (head[1] === "1") {      current = { invariant, steps: [] };      traces.push(current);    }    current?.steps.push({ action: head[2] === "Initial" ? null : head[2], vars });  }  return traces;} // TLC words a broken PROPERTY by its form: [](state predicate) as an invariant, [][A]_v as an// action property, a bare state predicate, which TLC checks only in the initial states, as a// property of the initial state, and any other formula as a temporal property.export const BROKEN_PROPERTY =  /^Error: (Invariant \S+ is violated|Action property \S+ is violated\.|Property \S+ is violated by the initial state:|Temporal properties were violated\.)/m; // With -continue, TLC prints "Model checking completed" even after broken invariants, and not// when it stopped early. TLC can also throw a Java exception of its own while it reports a state// that breaks a rule, and stop early. An error in the rule's formula throws one of TLC's own// exceptions in place of java.*.export function tlcOutputWarning(text, file) {  const lines = text.split("\n");  const named = (re) => new Set(lines.map((l) => re.exec(l)?.[1]).filter(Boolean));  const broken = named(/^Error: Invariant (\S+) is violated/);  const failed = [...named(/^Error: Evaluating invariant (\S+) failed\./)];  const thrown = [...text.matchAll(/^The exception was a (\S+)\n: (?:([\w.$]+):)?(.*)$/gm)].map(    ([, cls, cause, message]) => `${cause ?? cls}: ${message.trim()}`,  );  const crashed =    failed.length > 0 &&    failed.every((r) => broken.has(r)) &&    thrown.length > 0 &&    thrown.every((t) => t.startsWith("java."));  const reported = crashed    ? /^Error: (Invariant \S+ is violated|The behavior up to this point is|Evaluating invariant \S+ failed\.|TLC threw an unexpected exception\.|The error occurred when TLC was evaluating the nested)/    : /^Error: (Invariant \S+ is violated|Action property \S+ is violated\.|Property \S+ is violated by the initial state:|The behavior up to this point is|Temporal properties were violated\.|The following behavior constitutes a counter-example:)/;  const other = lines.find((l) => l.startsWith("Error: ") && !reported.test(l));  if (other)    throw new Error(      `${file}: TLC stopped on an error that is not a broken rule: ${other.slice(7)}`,    );  if (crashed) {    const rules = failed.join(", ");    return `TLC found that ${rules} ${failed.length === 1 ? "is" : "are"} broken, and then failed with ${thrown[0]}. TLC stopped before it checked every state, so the charts can miss states that break ${rules}. The failure is in TLC, not in the rule. Do not weaken ${rules} because of it. A smaller constant can let TLC finish.`;  }  // TLC stops at the first broken temporal property, and at an initial state that breaks a  // PROPERTY, and then does not print that it completed.  if (    !/^(Model checking completed\.|Error: (Temporal properties were violated\.|Property \S+ is violated by the initial state:))/m.test(      text,    )  )    throw new Error(      `${file}: TLC did not finish, so its counterexamples are not all there; read the file`,    );  return null;} export function wrap(text, width = 26) {  const words = String(text).split(/\s+/);  const rows = [];  let row = "";  for (const w of words) {    if (row && (row + " " + w).length > width) {      rows.push(row);      row = w;    } else row = row ? `${row} ${w}` : w;  }  if (row) rows.push(row);  return rows.join("<br/>");} export const joinAnd = (items) =>  items.length < 3 ? items.join(" and ") : `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`; const OPENS_A_PHRASE = new Set(  "a an the your my our their its this that to of in on at for with from by and or".split(" "),);export function joinLabels(labels) {  if (labels.length < 2) return labels.join(" or ");  const words = labels.map((l) => String(l).split(" "));  let n = 0;  while (words.every((w) => n < w.length - 1 && w[n] === words[0][n])) n++;  while (n > 0 && OPENS_A_PHRASE.has(words[0][n - 1])) n--;  if (n < 2) return labels.join(" or ");  const rest = words.map((w) => w.slice(n).join(" "));  const list =    rest.length === 2 ? rest.join(" or ") : `${rest.slice(0, -1).join(", ")}, or ${rest.at(-1)}`;  return `${words[0].slice(0, n).join(" ")} ${list}`;} const unwrap = (s) =>

Lines 1–500 of 928.