SPB Git

spb/autoscholar

Public
23.7 KB · 605 lines typescript
1import { CONTRACT_PATHS, checkArtifactContract, MODE_PROFILES } from "@autoscholar/research-contract";2import {3  DataManifestFile,4  ExperimentLogEntry,5  FeasibilityFile,6  type GateResult,7  ManuscriptMetadata,8  MetricsFile,9  type ResearchMode,10  ReviewReport,11  SourceMapFile,12  SourcesFile,13} from "@autoscholar/schemas";14import { citeKeysInTex, parseBib } from "./bib.js";15import { inspectPdf } from "./pdf.js";1617export interface ArtifactView {18  path: string;19  kind: string;20  sha256: string;21  bytes: number;22  synthetic: boolean;23  read: () => Promise<Buffer>;24  storedSha256: () => Promise<string | null>;25}2627export interface GateContext {28  mode: ResearchMode;29  secondaryModes: string[];30  artifacts: ArtifactView[];31  attemptOutcome: string | null;32  cancelRequested: boolean;33  workerSimulated: boolean;34  reviewFindingsOpenBlockers: number;35}3637const P = CONTRACT_PATHS;38const SECRET_PATTERNS = [39  /sk-ant-[A-Za-z0-9_-]{20,}/,40  /asw_[A-Za-z0-9_-]{20,}/,41  /-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----/,42  /AKIA[0-9A-Z]{16}/,43  /ghp_[A-Za-z0-9]{30,}/,44  /xox[bp]-[A-Za-z0-9-]{20,}/,45];46const TEXT_EXT = /\.(md|tex|bib|json|jsonl|txt|csv|py|r|jl|sh|yaml|yml|toml|cfg|ini|sha256|log)$/i;4748const pass = (49  gate: string,50  severity: "hard" | "soft",51  message: string,52  evidence: Record<string, unknown> = {},53): GateResult => ({ gate, severity, status: "pass", message, evidence });54const fail = (55  gate: string,56  severity: "hard" | "soft",57  message: string,58  evidence: Record<string, unknown> = {},59): GateResult => ({ gate, severity, status: severity === "hard" ? "fail" : "warn", message, evidence });60const skip = (gate: string, severity: "hard" | "soft", message: string): GateResult => ({61  gate,62  severity,63  status: "skip",64  message,65  evidence: {},66});6768function find(ctx: GateContext, path: string): ArtifactView | undefined {69  return ctx.artifacts.find((a) => a.path === path);70}71async function readJson<T>(72  a: ArtifactView | undefined,73  parse: (x: unknown) => T,74): Promise<{ value?: T; error?: string }> {75  if (!a) return { error: "missing" };76  try {77    return { value: parse(JSON.parse((await a.read()).toString("utf8"))) };78  } catch (e) {79    return { error: (e as Error).message.slice(0, 300) };80  }81}82function needs(ctx: GateContext, ...modes: ResearchMode[]): boolean {83  return modes.includes(ctx.mode) || ctx.secondaryModes.some((m) => modes.includes(m as ResearchMode));84}85/** Does the tex contain the reported number (tolerant to formatting: thousands separators, trailing zeros, percent)? */86export function texContainsNumber(tex: string, value: number | string): boolean {87  const s = String(value).trim();88  if (!/^-?\d/.test(s)) return tex.includes(s);89  const n = Number(s.replace(/,/g, ""));90  if (!Number.isFinite(n)) return tex.includes(s);91  const candidates = new Set<string>([s, s.replace(/,/g, ""), n.toString()]);92  for (const d of [0, 1, 2, 3, 4]) candidates.add(n.toFixed(d));93  const compact = tex.replace(/,(?=\d{3})/g, "").replace(/\\,|;|\\ |~|\{|\}|\$/g, "");94  for (const c of candidates) {95    const re = new RegExp(`(?<![\\d.])${c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?![\\d])`);96    if (re.test(compact)) return true;97  }98  return false;99}100101export async function runGates(ctx: GateContext): Promise<GateResult[]> {102  const out: GateResult[] = [];103  const profile = MODE_PROFILES[ctx.mode];104105  // G1 final attempt106  out.push(107    ctx.attemptOutcome === "completed" && !ctx.cancelRequested108      ? pass("attempt.final", "hard", "worker attempt completed and run not cancelled")109      : fail(110          "attempt.final",111          "hard",112          `attempt outcome is ${ctx.attemptOutcome ?? "none"}${ctx.cancelRequested ? " (cancellation requested)" : ""}`,113        ),114  );115116  // G2 not synthetic / not simulated worker117  const synth = ctx.artifacts.filter((a) => a.synthetic).map((a) => a.path);118  out.push(119    ctx.workerSimulated || synth.length > 0120      ? fail(121          "worker.real",122          "hard",123          "artifacts were produced by a simulated worker or are flagged synthetic; never publishable",124          { synthetic: synth.slice(0, 20), workerSimulated: ctx.workerSimulated },125        )126      : pass("worker.real", "hard", "artifacts come from a real worker"),127  );128129  // G3 contract130  const contract = checkArtifactContract(ctx.mode, ctx.artifacts);131  out.push(132    contract.ok133      ? pass("contract.artifacts", "hard", `all ${contract.present.length} mode-required artifacts present`, {134          mode: ctx.mode,135        })136      : fail("contract.artifacts", "hard", `missing ${contract.missing.length} required artifact(s)`, {137          missing: contract.missing,138        }),139  );140141  // G4 checksums142  const mismatches: string[] = [];143  for (const a of ctx.artifacts) {144    const stored = await a.storedSha256();145    if (stored && stored !== a.sha256) mismatches.push(a.path);146  }147  let checksumFileOk = false;148  const cf = find(ctx, P.checksums);149  if (cf) {150    const lines = (await cf.read()).toString("utf8").split(/\r?\n/).filter(Boolean);151    const map = new Map(lines.map((l) => [l.slice(66).trim().replace(/^\*/, ""), l.slice(0, 64)]));152    const bad = ctx.artifacts153      .filter(154        (a) =>155          a.path !== P.checksums &&156          a.path !== P.events &&157          a.path !== P.runSummary &&158          map.has(a.path) &&159          map.get(a.path) !== a.sha256,160      )161      .map((a) => a.path);162    const covered = ctx.artifacts.filter((a) => map.has(a.path)).length;163    checksumFileOk = bad.length === 0 && covered >= Math.max(1, Math.floor(ctx.artifacts.length * 0.5));164    if (bad.length) mismatches.push(...bad.map((b) => `${b} (checksums.sha256)`));165  }166  out.push(167    mismatches.length === 0 && checksumFileOk168      ? pass("checksums.valid", "hard", "all stored digests match and checksums.sha256 agrees", {169          artifacts: ctx.artifacts.length,170        })171      : fail(172          "checksums.valid",173          "hard",174          mismatches.length175            ? `digest mismatches: ${mismatches.length}`176            : "checksums.sha256 missing or covers too few artifacts",177          { mismatches: mismatches.slice(0, 20) },178        ),179  );180181  // G5 feasibility182  const feas = await readJson(find(ctx, P.feasibility), (x) => FeasibilityFile.parse(x));183  out.push(184    feas.value?.feasible && feas.value.requiredApprovals.length === 0185      ? pass("feasibility.ok", "hard", "preflight judged the study feasible with no outstanding approvals")186      : fail(187          "feasibility.ok",188          "hard",189          feas.error190            ? `feasibility.json invalid: ${feas.error}`191            : `not feasible or approvals outstanding: ${feas.value?.requiredApprovals.join(", ") || feas.value?.rejectReason}`,192        ),193  );194195  // G6 metadata + manuscript196  const meta = await readJson(find(ctx, P.metadata), (x) => ManuscriptMetadata.parse(x));197  const texA = find(ctx, P.mainTex);198  const tex = texA ? (await texA.read()).toString("utf8") : "";199  if (!meta.value) out.push(fail("metadata.valid", "hard", `metadata.json invalid: ${meta.error}`));200  else {201    const problems: string[] = [];202    if (!tex.includes("\\begin{document}")) problems.push("main.tex has no document body");203    if (!/\\title\{/.test(tex) && !/\\Title\{/.test(tex) && !/title=\{/.test(tex))204      problems.push("main.tex has no title");205    if (meta.value.synthetic && !/synthetic/i.test(meta.value.limitations + meta.value.abstract))206      problems.push("synthetic flag set but not disclosed in abstract/limitations");207    out.push(208      problems.length209        ? fail("metadata.valid", "hard", problems.join("; "))210        : pass("metadata.valid", "hard", "manuscript metadata and LaTeX source are well-formed", {211            title: meta.value.title,212            keywords: meta.value.keywords.length,213          }),214    );215  }216217  // G7 PDF218  const pdfA = find(ctx, P.mainPdf);219  if (!pdfA) out.push(fail("pdf.valid", "hard", "manuscript/main.pdf missing"));220  else {221    const insp = await inspectPdf(await pdfA.read());222    const minPages = ctx.mode === "theoretical" ? 4 : 6;223    const problems = [...insp.errors];224    if (insp.pages < minPages)225      problems.push(`only ${insp.pages} pages (minimum ${minPages} for ${ctx.mode})`);226    if (insp.fonts.unembedded.length > 0)227      problems.push(`unembedded fonts: ${insp.fonts.unembedded.join(", ")}`);228    out.push(229      problems.length230        ? fail("pdf.valid", "hard", problems.join("; "), { pages: insp.pages, fonts: insp.fonts })231        : pass(232            "pdf.valid",233            "hard",234            `${insp.pages} pages, ${insp.textChars} text chars, ${insp.fonts.embedded}/${insp.fonts.total} fonts embedded`,235            { pages: insp.pages, fonts: insp.fonts.names },236          ),237    );238    const t = insp.text.replace(/\s+/g, " ");239    const hasWP = /AutoScholar Working Paper/i.test(t);240    const hasAI = /AI-generated|generated autonomously|autonomous(ly)? (AI|research)/i.test(t);241    const hasNoPeer = /not (formally |been )?peer[- ]reviewed/i.test(t);242    const hasMac = /MacLustr/.test(t);243    out.push(244      hasWP && hasAI && hasNoPeer && hasMac245        ? pass(246            "disclosure.present",247            "hard",248            "PDF carries working-paper, AI-generation, non-peer-review and MacLustr statements",249          )250        : fail("disclosure.present", "hard", "PDF lacks required disclosures", {251            workingPaper: hasWP,252            aiGenerated: hasAI,253            notPeerReviewed: hasNoPeer,254            maclustr: hasMac,255          }),256    );257    if (meta.value) {258      const titleWords = meta.value.title259        .toLowerCase()260        .replace(/[^a-z0-9 ]/g, " ")261        .split(/\s+/)262        .filter((w) => w.length > 3);263      const hit = titleWords.filter((w) => t.toLowerCase().includes(w)).length;264      out.push(265        titleWords.length === 0 || hit / titleWords.length >= 0.6266          ? pass("pdf.title_matches", "soft", "PDF text contains the declared title")267          : fail("pdf.title_matches", "soft", "declared title not found in PDF text", {268              hit,269              titleWords: titleWords.length,270            }),271      );272    }273    if (ctx.mode !== "theoretical" && !/Figure \d/.test(t) && !/\\begin\{figure\}/.test(tex))274      out.push(fail("pdf.figures", "soft", "no figures detected in a non-theoretical paper"));275  }276277  // G8 references278  const sources = await readJson(find(ctx, P.sources), (x) => SourcesFile.parse(x));279  const bibA = find(ctx, P.manuscriptBib);280  const bib = bibA ? parseBib((await bibA.read()).toString("utf8")) : [];281  const bibKeys = new Set(bib.map((b) => b.key));282  const cited = citeKeysInTex(tex);283  if (!sources.value) out.push(fail("references.verified", "hard", `sources.json invalid: ${sources.error}`));284  else {285    const srcKeys = new Map(sources.value.sources.map((s) => [s.key, s]));286    const unverified = sources.value.sources.filter((s) => !s.verified).map((s) => s.key);287    const citedUnknown = cited.filter((k) => !bibKeys.has(k));288    const citedUnverified = cited.filter(289      (k) => bibKeys.has(k) && (!srcKeys.has(k) || !srcKeys.get(k)!.verified),290    );291    const bibWithoutSource = bib.filter((b) => !srcKeys.has(b.key)).map((b) => b.key);292    const problems: string[] = [];293    if (citedUnknown.length) problems.push(`${citedUnknown.length} \\cite keys not in references.bib`);294    if (citedUnverified.length)295      problems.push(`${citedUnverified.length} cited references not verified in sources.json`);296    if (bibWithoutSource.length)297      problems.push(`${bibWithoutSource.length} bib entries without a verified source record`);298    if (cited.length === 0) problems.push("manuscript cites nothing");299    const bibMissingId = bib300      .filter((b) => !(b.fields.doi || b.fields.url || b.fields.eprint || b.fields.arxivid || b.fields.isbn))301      .map((b) => b.key);302    const minSources = ctx.mode === "review" ? 10 : 5;303    out.push(304      problems.length305        ? fail("references.verified", "hard", problems.join("; "), {306            citedUnknown: citedUnknown.slice(0, 20),307            citedUnverified: citedUnverified.slice(0, 20),308            bibWithoutSource: bibWithoutSource.slice(0, 20),309            unverified: unverified.slice(0, 20),310          })311        : pass(312            "references.verified",313            "hard",314            `${cited.length} cited keys all verified (${sources.value.sources.length} sources)`,315            { cited: cited.length, sources: sources.value.sources.length },316          ),317    );318    out.push(319      bibMissingId.length320        ? fail(321            "references.identifiers",322            "soft",323            `${bibMissingId.length} bib entries lack DOI/URL/arXiv identifiers`,324            { keys: bibMissingId.slice(0, 20) },325          )326        : pass("references.identifiers", "soft", "every bib entry carries an identifier"),327    );328    out.push(329      sources.value.sources.length >= minSources330        ? pass("references.count", "soft", `${sources.value.sources.length} sources`)331        : fail(332            "references.count",333            "soft",334            `only ${sources.value.sources.length} sources (expected ≥ ${minSources})`,335          ),336    );337  }338339  // G9 claims traced340  const smap = await readJson(find(ctx, P.sourceMap), (x) => SourceMapFile.parse(x));341  if (!smap.value) out.push(fail("claims.traced", "hard", `source-map.json invalid: ${smap.error}`));342  else {343    const srcKeys = new Set(sources.value?.sources.map((s) => s.key) ?? []);344    const paths = new Set(ctx.artifacts.map((a) => a.path));345    const untraced = smap.value.claims.filter(346      (c) => c.sourceKeys.every((k) => !srcKeys.has(k)) && c.artifactPaths.every((p) => !paths.has(p)),347    );348    out.push(349      smap.value.claims.length >= 3 && untraced.length === 0350        ? pass(351            "claims.traced",352            "hard",353            `${smap.value.claims.length} key claims mapped to verified sources or artifacts`,354          )355        : fail(356            "claims.traced",357            "hard",358            smap.value.claims.length < 3359              ? `only ${smap.value.claims.length} claims mapped (minimum 3)`360              : `${untraced.length} claims have no existing supporting source/artifact`,361            { untraced: untraced.slice(0, 10).map((c) => c.claim) },362          ),363    );364  }365366  // G10 results consistency (modes with metrics)367  if (needs(ctx, "empirical", "computational", "experimental")) {368    const metrics = await readJson(find(ctx, P.metrics), (x) => MetricsFile.parse(x));369    if (!metrics.value)370      out.push(fail("results.consistency", "hard", `metrics.json invalid: ${metrics.error}`));371    else {372      const rv = metrics.value.reportedValues;373      const reportTexts: string[] = [];374      for (const a of ctx.artifacts)375        if (/^results\/.*\.(md|tex)$/.test(a.path) && a.bytes < 4 * 1024 * 1024)376          reportTexts.push((await a.read()).toString("utf8"));377      const corpus = `${tex}\n${reportTexts.join("\n")}`;378      const missing = rv.filter((v) => !texContainsNumber(corpus, v.value));379      out.push(380        rv.length >= 3 && missing.length === 0381          ? pass(382              "results.consistency",383              "hard",384              `${rv.length} reported values found verbatim in the manuscript`,385            )386          : fail(387              "results.consistency",388              "hard",389              rv.length < 3390                ? `only ${rv.length} reported values recorded (minimum 3)`391                : `${missing.length} reported values absent from main.tex`,392              { missing: missing.slice(0, 10) },393            ),394      );395      if (metrics.value.synthetic && ctx.mode === "empirical")396        out.push(fail("results.real", "hard", "empirical results flagged synthetic"));397    }398    // execution evidence: platform reproduction (strongest), then the experiment log (tolerant of the agent's log shape)399    const repro = await readJson(400      find(ctx, "results/reproduction.json"),401      (x) =>402        x as {403          reproduceScript?: { ran?: boolean; exitCode?: number | null };404          metricsCompared?: number;405          metricsMismatched?: unknown[];406          metricsMissingInRerun?: number;407        },408    );409    const reproOk = repro.value?.reproduceScript?.ran === true && repro.value.reproduceScript.exitCode === 0;410    const logA = find(ctx, P.experimentLog);411    const rawEntries = logA412      ? (await logA.read())413          .toString("utf8")414          .split(/\r?\n/)415          .filter(Boolean)416          .map((l) => {417            try {418              return JSON.parse(l) as Record<string, unknown>;419            } catch {420              return null;421            }422          })423          .filter((e): e is Record<string, unknown> => !!e)424      : [];425    const okEntries = rawEntries.filter((e) => {426      const strict = ExperimentLogEntry.safeParse(e);427      if (strict.success) return strict.data.exitCode === 0;428      const code = e.exitCode ?? e.exit_code ?? e.returncode ?? e.status;429      if (code !== undefined) return code === 0 || code === "0" || code === "ok" || code === "success";430      return !!(e.script || e.command || e.name) && !(e.error || e.failed === true);431    }).length;432    const codeFiles = ctx.artifacts.filter(433      (a) => a.path.startsWith("code/") && a.path !== P.codeReadme && !/\.(md|txt)$/.test(a.path),434    ).length;435    const mismatched = repro.value?.metricsMismatched?.length ?? 0;436    if ((reproOk || okEntries >= 1) && codeFiles >= 1) {437      out.push(438        pass(439          "execution.evidence",440          "hard",441          `${reproOk ? "platform reproduction succeeded; " : ""}${okEntries} logged execution(s), ${codeFiles} code files`,442          { reproOk, okEntries, codeFiles, metricsCompared: repro.value?.metricsCompared, mismatched },443        ),444      );445    } else {446      out.push(447        fail(448          "execution.evidence",449          "hard",450          `insufficient execution evidence (platform reproduction ok: ${reproOk}, logged successful executions: ${okEntries}, code files: ${codeFiles})`,451          { reproOk, okEntries, codeFiles },452        ),453      );454    }455    if (repro.value?.reproduceScript?.ran) {456      out.push(457        reproOk && mismatched === 0458          ? pass(459              "reproduction.clean",460              "soft",461              `reproduce.sh re-ran cleanly; ${repro.value.metricsCompared ?? 0} metrics compared, 0 mismatches`,462            )463          : fail(464              "reproduction.clean",465              "soft",466              reproOk467                ? `${mismatched} metric(s) differed on re-execution`468                : `reproduce.sh exited with ${repro.value.reproduceScript.exitCode}`,469              { mismatched: repro.value.metricsMismatched?.slice(0, 10) },470            ),471      );472    }473  } else out.push(skip("results.consistency", "hard", "not required for this mode"));474475  // G11 data provenance476  if (needs(ctx, "empirical", "experimental", "review", "humanities")) {477    const dm = await readJson(find(ctx, P.dataManifest), (x) => DataManifestFile.parse(x));478    if (!dm.value) out.push(fail("data.provenance", "hard", `data/manifest.json invalid: ${dm.error}`));479    else {480      const ds = dm.value.datasets;481      const problems: string[] = [];482      if (ds.length === 0) problems.push("no datasets/records declared");483      for (const d of ds) {484        if (!d.license) problems.push(`${d.name}: no license`);485        if (!d.source) problems.push(`${d.name}: no source`);486      }487      if (ctx.mode === "empirical" && ds.length && ds.every((d) => d.synthetic))488        problems.push("all datasets are synthetic in empirical mode");489      if (ds.some((d) => d.synthetic) && !meta.value?.synthetic)490        problems.push("synthetic dataset present but manuscript metadata does not disclose synthetic data");491      out.push(492        problems.length493          ? fail("data.provenance", "hard", problems.slice(0, 5).join("; "), { problems })494          : pass("data.provenance", "hard", `${ds.length} dataset/record set(s) with source and license`),495      );496    }497  } else out.push(skip("data.provenance", "hard", "not required for this mode"));498499  // G12 theoretical rigor500  if (needs(ctx, "theoretical")) {501    const hasThm = /\\begin\{(theorem|proposition|lemma|corollary)\}/.test(tex);502    const hasProof = /\\begin\{proof\}/.test(tex);503    const rob = find(ctx, P.robustness);504    out.push(505      hasThm && hasProof && rob506        ? pass(507            "theory.formal",508            "hard",509            "formal statements, proofs and a counterexample/robustness note are present",510          )511        : fail(512            "theory.formal",513            "hard",514            `missing: ${[!hasThm && "theorem/proposition environments", !hasProof && "proof environments", !rob && "results/robustness.md"].filter(Boolean).join(", ")}`,515          ),516    );517  }518519  // G13 review520  const rr = await readJson(find(ctx, "review/review-1.json"), (x) => ReviewReport.parse(x));521  const revisions = find(ctx, P.revisions);522  if (!rr.value)523    out.push(fail("review.completed", "hard", `review/review-1.json invalid or missing: ${rr.error}`));524  else {525    const openBlockers = rr.value.findings.filter((f) => f.severity === "blocker" && f.resolution === "open");526    const openMajors = rr.value.findings.filter((f) => f.severity === "major" && f.resolution === "open");527    out.push(528      openBlockers.length === 0 && revisions && rr.value.recommendation !== "reject"529        ? pass(530            "review.completed",531            "hard",532            `independent review round ${rr.value.round}: ${rr.value.findings.length} findings, 0 open blockers, recommendation ${rr.value.recommendation}`,533            { rubric: rr.value.rubric, recommendation: rr.value.recommendation },534          )535        : fail(536            "review.completed",537            "hard",538            openBlockers.length539              ? `${openBlockers.length} blocker finding(s) unresolved`540              : !revisions541                ? "no response-to-review document"542                : "reviewer recommended reject",543            { openBlockers: openBlockers.map((f) => f.title) },544          ),545    );546    out.push(547      openMajors.length === 0548        ? pass("review.majors", "soft", "no open major findings")549        : fail("review.majors", "soft", `${openMajors.length} open major finding(s)`, {550            titles: openMajors.map((f) => f.title),551          }),552    );553    const avg = rr.value.rubric.length554      ? rr.value.rubric.reduce((s, r) => s + r.score, 0) / rr.value.rubric.length555      : 0;556    const low = rr.value.rubric.filter((r) => r.score <= 1);557    out.push(558      low.length === 0559        ? pass("review.rubric", "soft", `rubric average ${avg.toFixed(2)}/5`)560        : fail(561            "review.rubric",562            "soft",563            `rubric dimensions scored ≤1: ${low.map((l) => l.dimension).join(", ")}`,564          ),565    );566  }567568  // G14 secrets / dangerous content in promotable artifacts569  const leaks: string[] = [];570  for (const a of ctx.artifacts) {571    if (!TEXT_EXT.test(a.path) || a.bytes > 8 * 1024 * 1024 || a.path.startsWith("input/")) continue;572    const t = (await a.read()).toString("utf8");573    if (SECRET_PATTERNS.some((re) => re.test(t))) leaks.push(a.path);574  }575  const dangerous = ctx.artifacts576    .filter((a) => /\.(exe|dll|dylib|so|app|pkg|dmg|bat|cmd|scr)$/i.test(a.path))577    .map((a) => a.path);578  out.push(579    leaks.length === 0 && dangerous.length === 0580      ? pass("secrets.none", "hard", "no credential patterns or executable binaries in artifacts")581      : fail(582          "secrets.none",583          "hard",584          `${leaks.length} artifact(s) contain secret-like strings; ${dangerous.length} dangerous binaries`,585          { leaks, dangerous },586        ),587  );588589  // G15 abstract sanity590  if (meta.value) {591    const words = meta.value.abstract.split(/\s+/).length;592    out.push(593      words >= 100 && words <= 400594        ? pass("abstract.length", "soft", `${words} words`)595        : fail("abstract.length", "soft", `abstract has ${words} words (expected 100–400)`),596    );597  }598599  // G16 profile evidence summary600  out.push(601    pass("profile.evidence", "soft", `mode ${ctx.mode} requires: ${profile.requiredEvidence.join(", ")}`),602  );603  return out;604}605
Files · b1229548352bd25ab6d221039426fecbffb2b0fa
Menu

Theme