SPB Git

Repository Public MIT Selected

profile.mjs 8.6 KB 156 lines javascript

1/**2 * ─────────────────────────────────────────────3 *  SPB Git — Personal Git Platform4 * ─────────────────────────────────────────────5 *  Author  : Simon-Pierre Boucher6 *  Contact : contact@spboucher.ai7 *  File    : src/research/profile.mjs8 *  Purpose : Research Mode — read-only scholarly profile of a repository (manuscripts, LaTeX, figures, tables, reproducibility, outputs, versions)9 *  License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213const MAX_LIST = 40;14const TEX_PROBE_BYTES = 256 * 1024;15const IMAGE_RE = /\.(png|jpe?g|svg|gif|webp)$/i;16const FIG_DIR_RE = /(^|\/)(fig|figs|figures|plots|img|images|graphics)\//i;17const OUTPUT_DIR_RE = /(^|\/)(results?|outputs?|experiments?|runs|logs|artifacts)\//i;18const DATA_RE = /\.(csv|tsv|json|jsonl|parquet|txt|npz|npy|md|log)$/i;19const TABLE_DIR_RE = /(^|\/)(tables?)\//i;2021/** Rank a manuscript candidate: root-level and "main/paper" names first. */22function manuscriptRank(path) {23  const lower = path.toLowerCase();24  const base = lower.slice(lower.lastIndexOf('/') + 1);25  let rank = lower.split('/').length * 10;26  if (/^(main|paper|manuscript|article|thesis|report|draft)/.test(base)) rank -= 50;27  if (/(^|\/)(paper|manuscript|tex|latex|doc|docs)\//.test(lower)) rank -= 5;28  if (/(slides|poster|supp|appendix)/.test(base)) rank += 20;29  return rank;30}3132/**33 * Classify repository paths into research facets. Pure, synchronous — the34 * caller supplies the file listing so this is cheap to unit-test.35 * @param {Array<{path: string, size: number}>} files36 * @returns {object}37 */38export function classifyResearchFiles(files) {39  const pdfs = [];40  const tex = [];41  const bib = [];42  const figures = [];43  const tables = [];44  const outputs = [];45  const notebooks = [];46  const repro = {47    readme: null, license: null, requirements: null, environment: null, pyproject: null, lock: null,48    makefile: null, dockerfile: null, scripts: 0, data: 0, tests: 0, citation: null, seeds: 0,49  };50  for (const f of files) {51    const p = f.path;52    const lower = p.toLowerCase();53    const base = lower.slice(lower.lastIndexOf('/') + 1);54    const isFigure = FIG_DIR_RE.test(lower) && (IMAGE_RE.test(lower) || lower.endsWith('.pdf'));55    if (isFigure) figures.push({ path: p, size: f.size });56    else if (lower.endsWith('.pdf')) pdfs.push({ path: p, size: f.size, rank: manuscriptRank(p) });57    else if (lower.endsWith('.tex')) tex.push({ path: p, size: f.size });58    else if (lower.endsWith('.bib')) bib.push({ path: p, size: f.size });59    else if (lower.endsWith('.ipynb')) notebooks.push({ path: p, size: f.size });60    if (TABLE_DIR_RE.test(lower) && /\.(tex|csv|md|html)$/.test(lower)) tables.push({ path: p, size: f.size });61    if (OUTPUT_DIR_RE.test(lower) && DATA_RE.test(lower)) outputs.push({ path: p, size: f.size });62    if (/^readme(\.(md|rst|txt))?$/.test(base) && !p.includes('/')) repro.readme = p;63    if (/^(license|licence|copying)(\.(md|txt))?$/.test(base) && !p.includes('/')) repro.license = p;64    if (base === 'requirements.txt' || /^requirements[-_.].*\.txt$/.test(base)) repro.requirements ??= p;65    if (base === 'environment.yml' || base === 'environment.yaml' || base === 'conda.yml') repro.environment ??= p;66    if (base === 'pyproject.toml' || base === 'setup.py' || base === 'package.json' || base === 'cargo.toml' || base === 'go.mod' || base === 'renv.lock' || base === 'description') repro.pyproject ??= p;67    if (/^(poetry|uv|package-lock|pnpm-lock|yarn|cargo)\.(lock|json|yaml)$/.test(base) || base === 'yarn.lock') repro.lock ??= p;68    if (base === 'makefile' || base === 'justfile' || base === 'snakefile' || base === 'dvc.yaml') repro.makefile ??= p;69    if (base === 'dockerfile' || base === 'containerfile' || base.endsWith('.dockerfile') || base === 'apptainer.def' || base === 'singularity.def') repro.dockerfile ??= p;70    if (base === 'citation.cff' || base === 'citation.bib') repro.citation ??= p;71    if (/(^|\/)(scripts?|bin)\//.test(lower)) repro.scripts += 1;72    if (/(^|\/)(data|datasets?)\//.test(lower)) repro.data += 1;73    if (/(^|\/)(tests?|spec)\//.test(lower) || /^test_.*\.py$/.test(base) || /\.test\.(m?js|ts)$/.test(base)) repro.tests += 1;74    if (/seed/.test(base)) repro.seeds += 1;75  }76  pdfs.sort((a, b) => a.rank - b.rank || a.path.localeCompare(b.path));77  tex.sort((a, b) => manuscriptRank(a.path) - manuscriptRank(b.path));78  return {79    pdfs: pdfs.slice(0, MAX_LIST),80    tex: tex.slice(0, MAX_LIST),81    bib: bib.slice(0, MAX_LIST),82    figures: figures.slice(0, 60),83    tables: tables.slice(0, MAX_LIST),84    outputs: outputs.slice(0, MAX_LIST),85    notebooks: notebooks.slice(0, MAX_LIST),86    repro,87    counts: { pdfs: pdfs.length, tex: tex.length, bib: bib.length, figures: figures.length, tables: tables.length, outputs: outputs.length, notebooks: notebooks.length },88  };89}9091/** Extract title / authors / documentclass from a LaTeX source (no compilation). */92export function parseTexMeta(source) {93  const text = String(source);94  const pick = (re) => {95    const m = re.exec(text);96    return m ? m[1].replace(/\\\\/g, ' ').replace(/\\[a-zA-Z]+\*?(\[[^\]]*\])?\{?/g, '').replace(/[{}]/g, '').replace(/\s+/g, ' ').trim() : null;97  };98  return {99    isMain: /\\documentclass/.test(text),100    documentclass: (/\\documentclass(?:\[[^\]]*\])?\{([^}]+)\}/.exec(text) ?? [])[1] ?? null,101    title: pick(/\\title(?:\[[^\]]*\])?\{((?:[^{}]|\{[^{}]*\})*)\}/),102    authors: pick(/\\author(?:\[[^\]]*\])?\{((?:[^{}]|\{[^{}]*\})*)\}/),103    inputs: [...text.matchAll(/\\(?:input|include)\{([^}]+)\}/g)].map((m) => m[1]).slice(0, 40),104    figures: (text.match(/\\begin\{figure\*?\}/g) ?? []).length,105    tables: (text.match(/\\begin\{table\*?\}/g) ?? []).length,106    citations: (text.match(/\\cite[a-z]*\{/g) ?? []).length,107    bibliography: (/\\(?:bibliography|addbibresource)\{([^}]+)\}/.exec(text) ?? [])[1] ?? null,108  };109}110111/** The reproducibility checklist as presentable rows. */112export function reproChecklist(repro) {113  return [114    { key: 'readme', label: 'README with instructions', ok: Boolean(repro.readme), path: repro.readme },115    { key: 'env', label: 'Declared environment (requirements / environment.yml / pyproject / lockfile)', ok: Boolean(repro.requirements || repro.environment || repro.pyproject || repro.lock), path: repro.requirements || repro.environment || repro.pyproject || repro.lock },116    { key: 'lock', label: 'Pinned dependencies (lockfile)', ok: Boolean(repro.lock), path: repro.lock },117    { key: 'container', label: 'Container definition (Dockerfile / Apptainer)', ok: Boolean(repro.dockerfile), path: repro.dockerfile },118    { key: 'pipeline', label: 'Pipeline entry point (Makefile / justfile / Snakefile / dvc.yaml)', ok: Boolean(repro.makefile), path: repro.makefile },119    { key: 'scripts', label: 'Scripts directory', ok: repro.scripts > 0, note: repro.scripts ? `${repro.scripts} files` : null },120    { key: 'data', label: 'Data directory or data access notes', ok: repro.data > 0, note: repro.data ? `${repro.data} files` : null },121    { key: 'tests', label: 'Automated tests', ok: repro.tests > 0, note: repro.tests ? `${repro.tests} files` : null },122    { key: 'seeds', label: 'Explicit random seeds (file names)', ok: repro.seeds > 0, note: repro.seeds ? `${repro.seeds} files` : null },123    { key: 'license', label: 'License', ok: Boolean(repro.license), path: repro.license },124    { key: 'citation', label: 'Citation metadata (CITATION.cff / .bib)', ok: Boolean(repro.citation), path: repro.citation },125  ];126}127128/**129 * Full research profile of a repository at a commit (cached by the caller).130 * Reads at most TEX_PROBE_BYTES of each LaTeX main candidate; never runs code,131 * never compiles LaTeX.132 * @param {{repos: object}} ctx133 * @param {string} name134 * @param {string} sha135 */136export async function researchProfile(ctx, name, sha) {137  const files = await ctx.repos.allFiles(name, sha);138  const facets = classifyResearchFiles(files);139  const mains = [];140  for (const t of facets.tex.slice(0, 12)) {141    const blob = await ctx.repos.blob(name, sha, t.path, { maxBytes: TEX_PROBE_BYTES });142    if (!blob || !blob.content) continue;143    const meta = parseTexMeta(blob.content.toString('utf8'));144    if (meta.isMain) mains.push({ path: t.path, ...meta });145  }146  const tags = await ctx.repos.tags(name);147  return {148    ...facets,149    manuscripts: mains,150    primaryPdf: facets.pdfs[0] ?? null,151    checklist: reproChecklist(facets.repro),152    versions: tags.slice(0, 20),153    fileCount: files.length,154  };155}156
Files main
Menu

Theme