#!/usr/bin/env node import { readFile, writeFile, stat } from 'fs/promises'; import { existsSync, realpathSync } from 'fs'; import { resolve, dirname, basename, join } from 'path'; import { fileURLToPath } from 'url'; import { tmpdir } from 'os'; import { escapeLatex, sanitizeUrl } from './lib/latex-escape.mjs'; import { resolveTemplate } from './cv-templates.mjs'; import { stripEmptySections } from './cv-sections-core.mjs'; import { hasRequiredFields, hasText, validatePayload } from './lib/cv-payload-schema.mjs'; import { latexPayloadFactHtml } from './lib/cv-payload-facts.mjs'; import { resolvePageFormat } from './lib/page-format.mjs'; import { getCareerOpsRoot, getProfilePath } from './path-resolver.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); const TEMPLATE_PATH = resolve(__dirname, 'templates', 'cv-template.tex'); const PLACEHOLDER_RE = /\{\{[A-Z_]+\}\}/g; // Markdown bold inside bullets — the LaTeX half of #1728, which taught the HTML // path to render `**text**` as (normalizeTextForATS in generate-pdf.mjs). // escapeLatex() leaves `*` alone because it is not a LaTeX special character, so // the markers reached the .tex verbatim and printed as literal asterisks (#3351). // // Order is the safety property, and it mirrors the HTML twin: escapeLatex() runs // FIRST, so every backslash and brace in the payload is already neutralized // (`\` becomes \textbackslash{}, braces become \{ \}). Nothing the candidate wrote // can survive as a real control sequence — this pass only reinterprets the `**` // markers, which escaping deliberately left untouched. Same regex as the HTML // path so the two twins agree on what counts as bold. // // The gate covers every field this builder emits inside a \resumeItem: experience // bullets, project bullets, and the education coursework line. Coursework does not // carry the payload key `bullets`, but it renders as a bullet, and a bullet whose // emphasis silently prints as `**` is the bug being fixed — the shape of the // output decides what goes through the gate, not the name of the payload field. const MARKDOWN_BOLD_RE = /\*\*([^*]+?)\*\*/g; /** * Escape bullet text, then restore markdown bold as \textbf. * * Use this for every value that ends up inside a \resumeItem; use escapeLatex * directly everywhere else, where `**` is meant to stay literal. * * @param {string} text raw payload text, not yet escaped * @returns {string} LaTeX-safe text with `**…**` spans rendered as \textbf{…} */ function escapeLatexBullet(text) { // Replacer FUNCTION, not a string: escaped text is full of `\$` and `\&`, and a // string replacement would reinterpret `$&` and friends as match references // (same trap the render path documents below). return escapeLatex(text).replace(MARKDOWN_BOLD_RE, (_, inner) => `\\textbf{${inner}}`); } /** * Render the Education section as \resumeSubheading blocks. * * An entry's optional `coursework` becomes a single \resumeItem line, which is * why it goes through escapeLatexBullet rather than escapeLatex. * * @param {Array} entries `education[]` from the payload * @returns {string} LaTeX for the section body, or '' when there is nothing to render */ function buildEducation(entries) { if (!Array.isArray(entries) || entries.length === 0) return ''; const blocks = []; for (const e of entries) { if (!hasRequiredFields(e, 'education', 'tex')) continue; let block = ` \\resumeSubheading\n {${escapeLatex(e.institution)}}{${escapeLatex(e.location)}}\n {${escapeLatex(e.degree)}}{${escapeLatex(e.dates)}}`; if (Array.isArray(e.coursework) && e.coursework.length > 0) { const courses = e.coursework.map(c => escapeLatexBullet(c)).join(', '); block += `\n \\resumeItemListStart\n \\resumeItem{\\textbf{Coursework:} ${courses}}\n \\resumeItemListEnd`; } blocks.push(block); } return blocks.join('\n\n'); } /** * Render the Work Experience section as \resumeSubheading blocks. * * @param {Array} entries `experience[]` from the payload * @returns {string} LaTeX for the section body, or '' when there is nothing to render */ function buildExperience(entries) { if (!Array.isArray(entries) || entries.length === 0) return ''; const blocks = []; for (const e of entries) { if (!hasRequiredFields(e, 'experience', 'tex')) continue; const bullets = Array.isArray(e.bullets) ? e.bullets.map(b => ` \\resumeItem{${escapeLatexBullet(b)}}`).join('\n') : ''; blocks.push(` \\resumeSubheading\n {${escapeLatex(e.company)}}{${escapeLatex(e.dates)}}\n {${escapeLatex(e.role)}}{${escapeLatex(e.location)}}\n \\resumeItemListStart\n${bullets}\n \\resumeItemListEnd`); } return blocks.join('\n\n'); } /** * Render the Projects section as \resumeProjectHeading blocks. * * A valid `url` turns the project name into an \href link (#3198); the name * itself stays escaped either way. * * @param {Array} entries `projects[]` from the payload * @returns {string} LaTeX for the section body, or '' when there is nothing to render */ function buildProjects(entries) { if (!Array.isArray(entries) || entries.length === 0) return ''; const blocks = []; for (const e of entries) { if (!hasRequiredFields(e, 'projects', 'tex')) continue; const context = e.context ? ` \\emph{$|$ ${escapeLatex(e.context)}}` : ''; const url = sanitizeUrl(e.url); const nameFormatted = url ? `\\href{${escapeLatex(url, 'url')}}{\\textbf{${escapeLatex(e.name)}}}` : `\\textbf{${escapeLatex(e.name)}}`; const bullets = Array.isArray(e.bullets) ? e.bullets.map(b => ` \\resumeItem{${escapeLatexBullet(b)}}`).join('\n') : ''; blocks.push(` \\resumeProjectHeading\n {${nameFormatted}${context}}{${escapeLatex(e.dates || '')}}\n \\resumeItemListStart\n${bullets}\n \\resumeItemListEnd`); } return blocks.join('\n\n'); } // Awards are one line each — no bullet list — so they reuse // \resumeProjectHeading (bold left column, year right) rather than // \resumeSubheading, which would leave an empty second row. The issuing body // follows the title in the same $|$ style buildProjects() uses for context. function buildAwards(entries) { if (!Array.isArray(entries) || entries.length === 0) return ''; const blocks = []; for (const e of entries) { if (!hasRequiredFields(e, 'awards', 'tex')) continue; const org = e.org ? ` \\emph{$|$ ${escapeLatex(e.org)}}` : ''; blocks.push(` \\resumeProjectHeading\n {\\textbf{${escapeLatex(e.title)}}${org}}{${escapeLatex(e.year)}}`); } return blocks.join('\n\n'); } function buildSkills(categories) { if (!Array.isArray(categories) || categories.length === 0) return ''; return categories.map(c => { if (!hasRequiredFields(c, 'skills', 'tex')) return ''; const items = Array.isArray(c.items) ? c.items.join(', ') : (c.items || ''); // category is optional (the spec requires only items), so an entry without // one must not render an empty bold group and a leading ": " — the HTML // builder drops the prefix the same way. const prefix = hasText(c.category) ? `\\textbf{${escapeLatex(c.category)}}{: }` : ''; return ` ${prefix}{${escapeLatex(items)}} \\\\`; }).filter(Boolean).join('\n'); } async function main() { const args = process.argv.slice(2); if (args.length === 0 || args.includes('--help')) { console.error('Usage:'); console.error(' node build-cv-latex.mjs '); console.error(' node build-cv-latex.mjs --test'); process.exit(1); } if (args.includes('--test')) { await runSelfTest(); return; } const [inputPath, outputPath] = args; if (!inputPath || !outputPath) { console.error('Usage: node build-cv-latex.mjs '); process.exit(1); } const absInput = resolve(inputPath); const absOutput = resolve(outputPath); const outDir = dirname(absOutput); if (!existsSync(absInput)) { console.error(`Input file not found: ${absInput}`); process.exit(1); } const inputStat = await stat(absInput, { bigint: true }); const outputStat = existsSync(absOutput) ? await stat(absOutput, { bigint: true }) : null; if (outputStat && (realpathSync.native(absOutput) === realpathSync.native(absInput) || (inputStat.ino !== 0n && inputStat.dev === outputStat.dev && inputStat.ino === outputStat.ino))) { console.error(`Output path is the input file: ${absOutput}`); process.exit(1); } let payload; try { const raw = await readFile(absInput, 'utf-8'); payload = JSON.parse(raw); } catch (err) { console.error(`Failed to parse input JSON: ${err.message}`); process.exit(1); } const { errors, warnings } = validatePayload(payload, 'tex'); if (errors.length) { console.error('Invalid CV payload:'); for (const message of errors) console.error(` - ${message}`); console.error(JSON.stringify({ valid: false, errors, warnings }, null, 2)); process.exit(1); } for (const message of warnings) console.error(`Warning: ${message}`); // Honor a selected .tex template variant (cv.template default or --template=), // falling back to the base cv-template.tex when no variant exists. const texName = (process.argv.find((a) => a.startsWith('--template=')) || '').split('=')[1]; let TEMPLATE_PATH_RESOLVED; try { TEMPLATE_PATH_RESOLVED = resolveTemplate('cv', texName, { format: 'tex', fallback: true }); } catch { TEMPLATE_PATH_RESOLVED = TEMPLATE_PATH; } if (!existsSync(TEMPLATE_PATH_RESOLVED)) { console.error(`Template not found: ${TEMPLATE_PATH_RESOLVED}`); process.exit(1); } let template = await readFile(TEMPLATE_PATH_RESOLVED, 'utf-8'); // Drop the optional sections (projects, education) that have no entries, so // an absent one leaves no bare header behind. See cv-sections-core.mjs. template = stripEmptySections(template, payload, 'tex'); // Only the bundled templates have the field/slot contract this projection // understands. Arbitrary TeX and custom templates keep the compiler's final // text gate; their macro semantics cannot be inferred from a JSON key. let factCheck = { verdict: 'skipped', reason: 'structured fact gate supports bundled LaTeX templates only' }; const bundledTemplates = [TEMPLATE_PATH, resolve(__dirname, 'templates', 'cv-template.cjk.tex')]; if (bundledTemplates.includes(resolve(TEMPLATE_PATH_RESOLVED))) { const root = getCareerOpsRoot(); if (!existsSync(join(root, 'cv.md'))) { factCheck = { verdict: 'skipped', reason: `no cv.md in ${root}` }; } else { const { assertFacts, printFactWarnings } = await import('./verify-cv-facts.mjs'); try { const result = assertFacts(latexPayloadFactHtml(payload, template), { sourcePaths: [join(root, 'cv.md'), join(root, 'article-digest.md')], configPath: join(root, 'config', 'cv-facts.json'), label: basename(absOutput), }); factCheck = { verdict: result.verdict, configMissing: result.configMissing }; if (result.configMissing) console.error('Warning: no config/cv-facts.json — forbidden/advisory phrase checks did not run.'); if (result.verdict === 'warn') printFactWarnings(result, console.error); } catch (err) { console.error(err.message); console.log(JSON.stringify({ valid: false, errors: [err.message], warnings, factCheck: { verdict: 'block', reason: err.message } }, null, 2)); process.exit(1); } } } const emailUrl = sanitizeUrl(payload.email?.url || ''); const emailDisplay = payload.email?.display || emailUrl; const linkedinUrl = sanitizeUrl(payload.linkedin?.url || ''); const linkedinDisplay = payload.linkedin?.display || ''; const githubUrl = sanitizeUrl(payload.github?.url || ''); const githubDisplay = payload.github?.display || ''; const substitutions = { NAME: escapeLatex(payload.name || ''), // The sheet, decided once for every renderer by lib/page-format.mjs: an // explicit payload `page_format` outranks the profile, and `auto` hands it // to the employer's country. The template used to hardcode letterpaper, so // a `page_format: a4` user got a Letter sheet from the LaTeX path while the // HTML path beside it honored the setting (modes/pdf.md promises it // "already reaches every renderer"). PAPER: resolvePageFormat(payload.page_format, { profilePath: getProfilePath(), country: payload.country, }) === 'a4' ? 'a4paper' : 'letterpaper', CONTACT_LINE: escapeLatex(payload.contact_line || ''), EMAIL_URL: emailUrl, EMAIL_DISPLAY: escapeLatex(emailDisplay), LINKEDIN_URL: linkedinUrl, LINKEDIN_DISPLAY: escapeLatex(linkedinDisplay), GITHUB_URL: githubUrl, GITHUB_DISPLAY: escapeLatex(githubDisplay), EDUCATION: buildEducation(payload.education), EXPERIENCE: buildExperience(payload.experience), PROJECTS: buildProjects(payload.projects), AWARDS: buildAwards(payload.awards), SKILLS: buildSkills(payload.skills), }; // Replacer FUNCTION, not a string: escapeLatex turns `$` into `\$` but leaves // the next character alone, so a bullet containing `$'` survives as the JS // replacement pattern meaning "everything after the match" and splices the // rest of the template into the document — silently, with a valid-looking // exit 0. A replacer function's return value is inserted literally. for (const [key, value] of Object.entries(substitutions)) { template = template.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), () => value); } const unresolved = template.match(PLACEHOLDER_RE); if (unresolved) { console.error(`Unresolved placeholders: ${[...new Set(unresolved)].join(', ')}`); process.exit(1); } if (!existsSync(outDir)) { const { mkdirSync } = await import('fs'); mkdirSync(outDir, { recursive: true }); } await writeFile(absOutput, template, 'utf-8'); const fileInfo = await stat(absOutput); const sizeKB = (fileInfo.size / 1024).toFixed(1); const report = { file: basename(absOutput), path: absOutput, sizeKB: parseFloat(sizeKB), counts: { educationEntries: (payload.education || []).length, experienceEntries: (payload.experience || []).length, projectEntries: (payload.projects || []).length, awardEntries: (payload.awards || []).length, skillCategories: (payload.skills || []).length, totalBullets: (() => { const ex = Array.isArray(payload.experience) ? payload.experience.flatMap(e => Array.isArray(e?.bullets) ? e.bullets : []) : []; const pr = Array.isArray(payload.projects) ? payload.projects.flatMap(p => Array.isArray(p?.bullets) ? p.bullets : []) : []; return ex.length + pr.length; })(), }, warnings, factCheck, valid: true, }; console.log(JSON.stringify(report, null, 2)); process.exit(0); } async function runSelfTest() { const sample = { name: 'Test Candidate', contact_line: 'City, State | +1 234 567 8900', email: { url: 'test@example.com', display: 'test@example.com' }, linkedin: { url: 'https://linkedin.com/in/test', display: 'linkedin.com/in/test' }, github: { url: 'https://github.com/test', display: 'github.com/test' }, education: [{ institution: 'Test University', location: 'City, State', degree: 'Bachelor of Science in Testing', dates: '2020 - 2024', coursework: ['Data Structures', 'Algorithms', 'Machine Learning'], }], experience: [{ company: 'Test Corp', role: 'Test Engineer', location: 'Remote', dates: 'June 2024 - Present', bullets: [ 'Built automated testing pipelines with CI/CD integration', 'Reduced regression test time by 60% through parallel execution', ], }], projects: [{ name: 'Test Project', context: 'Python, FastAPI, Docker', dates: '2024', bullets: [ 'Built a REST API with automated test coverage exceeding 90%', ], }], awards: [ { title: 'Gold Medal, International Olympiad in Informatics', org: 'IOI', year: '2023' }, { title: "Dean's List", org: 'Test University', year: '2022' }, ], skills: [ { category: 'Languages', items: 'Python, JavaScript, TypeScript' }, { category: 'Frameworks', items: 'FastAPI, React, PyTorch' }, ], }; // Guard the payload key contract (#3523). The LaTeX and HTML templates do // NOT share an education schema — this one is {institution, degree, dates, // coursework}, the HTML one is {title, org, year, description} — so a payload // written for the wrong builder must be rejected by name, not rendered as a // \resumeSubheading full of empty braces. const htmlStyleEducation = [{ title: 'Bachelor of Science in Computer Science', org: 'Test University', year: '2024', description: 'Coursework: Data Structures.', }]; const wrongKeys = validatePayload({ ...sample, education: htmlStyleEducation }, 'tex'); if (wrongKeys.errors.length === 0) { console.error('Self-test failed: education entry using the HTML key names was accepted'); process.exit(1); } if (!wrongKeys.errors[0].includes('education[0]') || !wrongKeys.errors[0].includes('institution') || !wrongKeys.errors[0].includes('title')) { console.error(`Self-test failed: unhelpful error for wrong education keys: ${wrongKeys.errors[0]}`); process.exit(1); } if (buildEducation(htmlStyleEducation) !== '') { console.error('Self-test failed: buildEducation emitted a block for an entry with no institution/degree'); process.exit(1); } // The valid sample must stay clean: no errors, no warnings. const clean = validatePayload(sample, 'tex'); if (clean.errors.length || clean.warnings.length) { console.error(`Self-test failed: valid sample payload reported ${JSON.stringify(clean)}`); process.exit(1); } // A payload root that is not an object must be rejected. for (const badRoot of [[], null, 'x']) { if (validatePayload(badRoot, 'tex').errors.length === 0) { console.error(`Self-test failed: payload root ${JSON.stringify(badRoot)} was accepted`); process.exit(1); } } // skills[].items accepts a string or a non-empty array; nothing else renders. for (const items of ['Python, JavaScript', ['FastAPI', 'React']]) { const ok = validatePayload({ ...sample, skills: [{ category: 'L', items }] }, 'tex'); if (ok.errors.length) { console.error(`Self-test failed: valid skills items ${JSON.stringify(items)} rejected`); process.exit(1); } } for (const items of [[], [' '], '', {}, null]) { if (validatePayload({ ...sample, skills: [{ category: 'L', items }] }, 'tex').errors.length === 0) { console.error(`Self-test failed: unrenderable skills items ${JSON.stringify(items)} accepted`); process.exit(1); } } if (buildSkills([{ label: 'Languages', values: ['JS'] }]) !== '') { console.error('Self-test failed: buildSkills emitted markup for an unrenderable entry'); process.exit(1); } // A mistyped SECTION name must be reported, not silently dropped. const typoSection = validatePayload({ ...sample, educations: sample.education }, 'tex'); if (!typoSection.warnings.some(w => w.includes('educations')) || typoSection.errors.length !== 0) { console.error(`Self-test failed: mistyped section name not reported: ${JSON.stringify(typoSection)}`); process.exit(1); } if (validatePayload(sample, 'tex').warnings.length !== 0) { console.error('Self-test failed: valid sample warned about its own root keys'); process.exit(1); } // One non-text element is enough to break the joined line. if (validatePayload({ ...sample, skills: [{ category: 'L', items: ['JS', {}] }] }, 'tex').errors.length === 0) { console.error('Self-test failed: skills items array with a non-text element was accepted'); process.exit(1); } // category is optional, and its absence must not leave an empty bold group // and a dangling ": " on the line. const noCategory = buildSkills([{ items: 'Docker, K8s' }]); if (noCategory.includes('textbf{}') || noCategory.includes('{: }')) { console.error(`Self-test failed: category-less skills line renders an empty prefix: ${noCategory}`); process.exit(1); } if (!noCategory.includes('Docker, K8s')) { console.error('Self-test failed: category-less skills line lost its items'); process.exit(1); } // One key, one warning: a section the .tex template cannot render is both // absent from KNOWN_ROOT_KEYS and listed in UNRENDERED_SECTIONS, and used to // collect a message from each path. const certWarnings = validatePayload({ ...sample, certifications: [{ title: 'CKA' }] }, 'tex').warnings .filter(w => w.startsWith('certifications:')); if (certWarnings.length !== 1) { console.error(`Self-test failed: expected exactly 1 certifications warning, got ${certWarnings.length}: ${JSON.stringify(certWarnings)}`); process.exit(1); } // The surviving one must be the specific message, not the typo-style guess. if (!certWarnings[0].includes('has no certifications section')) { console.error(`Self-test failed: the wrong certifications warning survived: ${certWarnings[0]}`); process.exit(1); } // An object-valued unknown key counts as populated here too. if (!validatePayload({ ...sample, educations: { institution: 'U' } }, 'tex').warnings.some(w => w.includes('educations'))) { console.error('Self-test failed: object-valued unknown root key was not reported'); process.exit(1); } // An unsupported section given a scalar value must warn too: the .tex // template drops summary whatever shape it arrives in. for (const scalar of [2026, 0, true, false, 'hi']) { if (!validatePayload({ ...sample, summary: scalar }, 'tex').warnings.some(w => w.startsWith('summary:'))) { console.error(`Self-test failed: scalar unsupported section ${JSON.stringify(scalar)} was not reported`); process.exit(1); } } for (const empty of [null, undefined, '', ' ']) { if (validatePayload({ ...sample, summary: empty }, 'tex').warnings.some(w => w.startsWith('summary:'))) { console.error(`Self-test failed: empty unsupported section ${JSON.stringify(empty)} warned`); process.exit(1); } } // Every list section carries the guard, not just education. for (const [section, bad] of [ ['education', [{ school: 'Test University', qualification: 'BSc' }]], ['experience', [{ employer: 'Acme', title: 'Engineer' }]], ['projects', [{ project_name: 'Thing' }]], ['awards', [{ award: 'Gold Medal' }]], ]) { if (validatePayload({ ...sample, [section]: bad }, 'tex').errors.length === 0) { console.error(`Self-test failed: ${section} entry with wrong key names was accepted`); process.exit(1); } } // escapeLatex() returns '' for anything that is not a string, so a // non-string required field must fail like an absent one. for (const badInstitution of [{}, [], 0, true, null]) { const bad = [{ institution: badInstitution, degree: 'BSc' }]; if (validatePayload({ ...sample, education: bad }, 'tex').errors.length === 0) { console.error(`Self-test failed: education institution ${JSON.stringify(badInstitution)} was accepted as text`); process.exit(1); } } // A section the .tex template cannot render must warn rather than vanish: // certifications exist in the HTML template only. const certWarn = validatePayload({ ...sample, certifications: [{ title: 'CKA' }] }, 'tex'); if (certWarn.errors.length !== 0 || !certWarn.warnings.some(w => w.includes('certifications'))) { console.error(`Self-test failed: certifications passed to the tex builder did not warn: ${JSON.stringify(certWarn)}`); process.exit(1); } const testOutput = join(tmpdir(), 'build-cv-latex-test.tex'); const raw = JSON.stringify(sample, null, 2); const tmpInput = join(tmpdir(), 'build-cv-latex-test-input.json'); await writeFile(tmpInput, raw, 'utf-8'); const absInput = resolve(tmpInput); const absOutput = resolve(testOutput); if (!existsSync(TEMPLATE_PATH)) { console.error(`Self-test failed: template not found at ${TEMPLATE_PATH}`); process.exit(1); } let template = await readFile(TEMPLATE_PATH, 'utf-8'); const emailUrl = sanitizeUrl(sample.email?.url || ''); const emailDisplay = sample.email?.display || emailUrl; const linkedinUrl = sanitizeUrl(sample.linkedin?.url || ''); const linkedinDisplay = sample.linkedin?.display || ''; const githubUrl = sanitizeUrl(sample.github?.url || ''); const githubDisplay = sample.github?.display || ''; const substitutions = { NAME: escapeLatex(sample.name), CONTACT_LINE: escapeLatex(sample.contact_line), EMAIL_URL: emailUrl, EMAIL_DISPLAY: escapeLatex(emailDisplay), LINKEDIN_URL: linkedinUrl, LINKEDIN_DISPLAY: escapeLatex(linkedinDisplay), GITHUB_URL: githubUrl, GITHUB_DISPLAY: escapeLatex(githubDisplay), EDUCATION: buildEducation(sample.education), EXPERIENCE: buildExperience(sample.experience), PROJECTS: buildProjects(sample.projects), AWARDS: buildAwards(sample.awards), SKILLS: buildSkills(sample.skills), }; // Replacer function, same reason as the render path above. for (const [key, value] of Object.entries(substitutions)) { template = template.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), () => value); } const unresolved = template.match(PLACEHOLDER_RE); if (unresolved) { console.error(`Self-test failed: unresolved placeholders: ${[...new Set(unresolved)].join(', ')}`); process.exit(1); } const outDir = dirname(absOutput); if (!existsSync(outDir)) { const { mkdirSync } = await import('fs'); mkdirSync(outDir, { recursive: true }); } await writeFile(absOutput, template, 'utf-8'); const fileInfo = await stat(absOutput); const sizeKB = (fileInfo.size / 1024).toFixed(1); const report = { status: 'self-test-passed', file: basename(absOutput), path: absOutput, sizeKB: parseFloat(sizeKB), counts: { educationEntries: sample.education.length, experienceEntries: sample.experience.length, projectEntries: sample.projects.length, awardEntries: sample.awards.length, skillCategories: sample.skills.length, totalBullets: (() => { const ex = Array.isArray(sample.experience) ? sample.experience.flatMap(e => Array.isArray(e?.bullets) ? e.bullets : []) : []; const pr = Array.isArray(sample.projects) ? sample.projects.flatMap(p => Array.isArray(p?.bullets) ? p.bullets : []) : []; return ex.length + pr.length; })(), }, }; console.log(JSON.stringify(report, null, 2)); await import('fs/promises').then(fs => Promise.all([ fs.rm(tmpInput).catch(() => {}), fs.rm(testOutput).catch(() => {}), ]) ); process.exit(0); } main();