Repository Public MIT Selected
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