diff --git a/.env.example b/.env.example index 2a78482..5b8821b 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,5 @@ # Arete currently has no required secrets. # Future AI/provider integrations must document required variables here. + +# Optional: write LaTeX only (removes an older PDF at the same output path). +# ARETE_SKIP_PDF_COMPILE=1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 18eb92d..e00921c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,12 +15,12 @@ jobs: node-version-file: .nvmrc - run: corepack enable - run: pnpm install --frozen-lockfile + - run: pnpm audit - run: pnpm format:check - run: pnpm lint - run: pnpm typecheck - run: pnpm test:coverage - run: pnpm build - run: pnpm arete validate --source examples/candidate.example.md - - run: pnpm arete build --source examples/candidate.example.md --out dist/example - env: - ARETE_SKIP_PDF_COMPILE: "1" + - run: sudo apt-get update && sudo apt-get install -y poppler-utils + - run: pnpm test:pdf diff --git a/.gitignore b/.gitignore index 804e95a..3e03388 100644 --- a/.gitignore +++ b/.gitignore @@ -6,10 +6,11 @@ coverage/ !.env.example private/ *.private.md +/candidate.md +*.tex *.aux *.fls *.fdb_latexmk *.log *.out *.pdf -!examples/*.pdf diff --git a/README.md b/README.md index f252858..3c859cb 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The project is CLI-first and deliberately small: no web app, account system, res ## Status -Greenfield bootstrap in progress. The current implementation provides the initial CLI, Markdown candidate parsing, validation, deterministic resume composition, LaTeX rendering, and npm-managed PDF compilation. +The CLI supports guided Markdown input, validation, deterministic resume composition, and one-page A4 LaTeX/PDF generation. See [the resume audit](docs/RESUME_AUDIT.md) for recruiting-source evidence, confirmed repairs, and limitations. No employer endorsement or universal ATS compatibility is claimed. ## Goals @@ -24,12 +24,17 @@ corepack enable pnpm install pnpm build -pnpm arete init --output candidate.md -pnpm arete validate --source candidate.md -pnpm arete build --source candidate.md --out dist/resume +pnpm arete init --output private/candidate.md +# Fill in your facts and remove unused placeholder sections before validation. +pnpm arete validate --source private/candidate.md +pnpm arete build --source private/candidate.md --out dist/resume ``` -`arete build` writes `resume.tex` and compiles `resume.pdf` through the npm-managed LaTeX compiler installed by `pnpm install`. System `latexmk` or `pdflatex` are used only as fallbacks. English is the default locale; use `--locale pt-BR` for localized section labels and dates currently supported by the MVP. +`arete build` writes `resume.tex` and compiles `resume.pdf` through the npm-managed LaTeX compiler installed by `pnpm install`. The binary runs without a shell in an isolated temporary directory. System `tectonic`, `latexmk`, or `pdflatex` are used when the bundled binary is unavailable. English is the default locale; use `--locale pt-BR` for localized section labels and dates currently supported by the MVP. + +`init` refuses to overwrite an existing file. The supplied example is explicitly fictional; private sources and generated output should remain in ignored paths. This repository does not provide an automatic anonymous-resume exporter. + +Use the generated template's exact section names and field positions. Experience dates accept `YYYY` or `YYYY-MM`, with `Present` for an ongoing role. Put experience in reverse chronological order and list your most relevant bullets first: source order is preserved, and the CLI warns when fixed list budgets omit items. Content that still exceeds one page fails compilation. Locale changes affect labels and experience dates; they do not translate prose. ## Architecture @@ -54,6 +59,9 @@ pnpm lint pnpm typecheck pnpm test:coverage pnpm build +pnpm audit +# Requires pdftotext (Poppler); builds real PDFs and tests text extraction. +pnpm test:pdf ``` See `docs/` for the product spec, architecture, testing strategy, security and privacy model, content policy, LaTeX constraints, and ExecPlan workflow. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0139fdb..998ee46 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,12 +4,13 @@ Arete is a Node.js TypeScript CLI with a linear compiler pipeline. ## Pipeline -1. Candidate Markdown is read from disk. +1. Candidate Markdown is read from disk; a YAML-only front-matter parser validates metadata without executable engines. 2. The parser extracts sections and repeated entities with source references. 3. Runtime schemas validate parsed data into a canonical candidate model. 4. The composer creates a resume model from canonical facts and build options. -5. The LaTeX renderer escapes all candidate text, applies deterministic one-page source-order budgeting and the A4 reference style, and writes `resume.tex`. -6. The compiler invokes the npm-managed LaTeX compiler first, then falls back to `latexmk` or `pdflatex`, to produce `resume.pdf`. +5. The LaTeX renderer escapes all candidate text, applies deterministic one-page source-order budgeting and the single-column A4 style, and writes `resume.tex`. +6. The compiler resolves the npm-managed Tectonic runtime and invokes it with an argument array in a unique temporary directory. If unavailable, discovery falls back to system `tectonic`, `latexmk`, or `pdflatex`. The npm shell wrapper is never invoked. +7. `pdf-lib` validates the resulting page tree and requires exactly one page before publishing `resume.pdf`. Compilation/skip removes stale PDF output; intermediate files are cleaned on success or failure. ## Boundaries @@ -22,7 +23,7 @@ Arete is a Node.js TypeScript CLI with a linear compiler pipeline. ## Provenance -Canonical facts include `SourceRef` values with file, section, and line information where practical. Resume items carry provenance references to the canonical fields they came from. This keeps generated claims inspectable and testable without a heavyweight claim database. +Canonical facts include `SourceRef` values with file, section, and original-file line information (including front matter). Resume items carry provenance references to the canonical fields they came from. This keeps generated claims inspectable and testable without a heavyweight claim database. ## STAR Evidence @@ -34,7 +35,7 @@ Candidate source files should capture STAR evidence for important work: - Result; - Evidence status. -The current MVP renders only bullet lines and supported structured fields. STAR evidence notes are still valuable source material for future composition and AI-assisted editing, but they must never create a factual claim unless the underlying candidate source supports it. +The current MVP renders only bullet lines before the STAR evidence marker and supported structured fields. Unknown/duplicate sections and malformed positional records are rejected. STAR evidence notes are still valuable source material for future composition and AI-assisted editing, but they must never create a factual claim unless the underlying candidate source supports it. ## Localization diff --git a/docs/LATEX.md b/docs/LATEX.md index b81c0ed..7f3cf8c 100644 --- a/docs/LATEX.md +++ b/docs/LATEX.md @@ -10,19 +10,24 @@ The resume template prioritizes ATS readability, selectable text, restrained typ - Escape all candidate-provided text. - Keep generated artifacts outside source control by default. - Fit the generated resume on one A4 page. If rendered content exceeds one page, compilation must fail rather than silently producing a multi-page resume. -- Follow the William Augusto reference resume style: centered name/contact header, compact black-and-white typography, section headings with thin rules, right-aligned location/date metadata, and dense ATS-readable bullets. +- Follow the single-column resume style: centered name/contact header, compact black-and-white typography, section headings with thin rules, right-aligned location/date metadata, and dense ATS-readable bullets. - Apply deterministic one-page budgeting for long lists. Skills, bullets, and technology lists preserve candidate source order and may omit lower-priority overflow items, but rendered claims must never be rewritten into unsupported facts. ## Toolchain -`arete build` first writes `resume.tex`, then tries: +`arete build` writes `resume.tex`, then discovers a compiler in this order: -1. the npm-managed `node-latex-compiler` dependency, which provides Tectonic through npm optional runtime packages; -2. `latexmk -pdf -interaction=nonstopmode -halt-on-error resume.tex`; -3. `pdflatex -interaction=nonstopmode -halt-on-error resume.tex`. +1. Tectonic runtime binary supplied by `node-latex-compiler` optional npm packages; +2. system `tectonic`; +3. system `latexmk`; +4. system `pdflatex` (two passes to resolve page references). -The primary path is intentionally installed by `pnpm install` and does not require Linux distribution packages such as `texlive`, `latexmk`, or `pdflatex`. If no compiler path is available, the command reports the missing environment requirement and leaves the `.tex` artifact intact. +Arete resolves the npm binary without invoking the dependency's shell-based compilation wrapper. All processes use argument arrays and unique temporary directories. Tectonic uses `--untrusted`; system engines disable shell escape, and `latexmk` ignores RC files. TeX file access is restricted where supported. Each process has a 120-second timeout and bounded captured diagnostics. Compilation errors remain errors; fallback discovery applies when binaries are absent, not to conceal invalid LaTeX. + +`pdf-lib` reads the actual page tree, including compressed objects, and requires exactly one page. Only validated output is copied to the destination. Stale PDFs are removed when compilation starts or is explicitly skipped. The `.tex` file remains for diagnosis. First-use Tectonic may download TeX support files and therefore require network access. + +Body text uses the declared 10pt size. List budgets preserve the first 24 skills, 4 bullets/14 technologies per experience, and 2 bullets/8 technologies per project. The CLI warns about each omitted group. These are item limits, not a measurement-based layout algorithm; long text can still exceed one page and must be edited by the candidate. ## Smoke Testing -CI should run the npm-managed compiler against the example resume. When practical, use `pdftotext` as a pragmatic signal that the PDF contains extractable text. +After `pnpm build`, run `pnpm test:pdf` with `pdftotext` from Poppler on PATH. This compiles the synthetic example concurrently in English and Portuguese, asserts one-page A4 dimensions, checks extracted contact/facts and section order, and verifies actual oversized content is rejected. CI installs Poppler and runs this command without the skip flag. diff --git a/docs/PROJECT_SPEC.md b/docs/PROJECT_SPEC.md index f1aef8a..ecc393a 100644 --- a/docs/PROJECT_SPEC.md +++ b/docs/PROJECT_SPEC.md @@ -54,7 +54,7 @@ The MVP is done when the CLI can create a template, validate a sanitized example ## STAR Method -Arete uses STAR because recruiters and interviewers respond better to evidence-backed achievements than generic responsibility lists. +Arete uses STAR as a structured evidence-gathering aid for writing concrete contributions and accomplishments. This does not imply endorsement of a specific template by an employer; see `docs/RESUME_AUDIT.md` for primary recruiting sources. - Situation: the context, problem, or opportunity. - Task: what the candidate was responsible for. @@ -62,3 +62,13 @@ Arete uses STAR because recruiters and interviewers respond better to evidence-b - Result: what changed afterward. The generated resume should usually compress STAR into one concise bullet. If the Result is unknown, the bullet must still be truthful and should avoid fake metrics or inflated impact. + +## Source Contract And Output Limits + +- Front matter is YAML with `schemaVersion: 1` and optional supported locale (`en`, `pt-BR`, or the `pt` alias). +- Section names follow the generated template exactly; unknown or duplicate sections are errors. +- Identity requires a name and valid email. `init` never overwrites an existing file. +- Pipe-separated fields retain empty positions. Experience uses four positions, with an optional empty location; start/end dates accept `YYYY` or `YYYY-MM`, and end also accepts `Present`. +- Bullets after the `STAR evidence notes:` marker are notes, not resume claims. +- Source order is preserved. Fixed list budgets may omit later items with CLI warnings; the final PDF must be one page. +- `pt-BR` changes template labels and experience-date display; candidate prose is not translated. diff --git a/docs/RESUME_AUDIT.md b/docs/RESUME_AUDIT.md new file mode 100644 index 0000000..595de5f --- /dev/null +++ b/docs/RESUME_AUDIT.md @@ -0,0 +1,49 @@ +# Resume And Repository Audit + +Reviewed on 2026-09-16. Scope: tracked repository content, compiler boundaries, tests, real PDF output, and published employer guidance. This is not an evaluation inside an employer's private ATS. + +## Assessment + +Arete is a useful foundation for concise professional resumes: candidate facts remain separate from presentation, the compiler is deterministic, and the layout is a simple single-column document with conventional headings and selectable text. After the repairs below, the synthetic example compiles to one A4 page in both supported locales and its text can be extracted in section order. + +It is not a general-purpose long-form or academic CV generator. One page is a product constraint, not a universal recruiting rule. Candidates must choose relevant content, put experience in reverse chronological order, and review the finished PDF and any application-system parsing. The compiler preserves source order rather than ranking job relevance or sorting dates. + +## What Major Employers Actually Say + +| Primary source | Published guidance | Implication for Arete | +| ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| [Amazon recruiter advice](https://www.aboutamazon.com/news/workplace/amazon-job-application-resume-writing-tips) | Favors simple, readable, text-focused black-and-white resumes, relevant accomplishments, and supported measurements. | The layout and evidence-driven writing approach align with this advice. Metrics must come from the candidate. | +| [Google-hosted resume overview](https://services.google.com/fh/files/misc/resumetipshandout2016.pdf) | Recommends consistent headings, reverse chronology, readable 10–12pt text, and concise accomplishment bullets. This is an older, student-oriented handout. | A useful design reference, not proof of a current company-wide rule or endorsement of this template. | +| [Microsoft hiring FAQ](https://careers.microsoft.com/v2/global/en/hiringfaqs.html) | Explains that uploaded resumes populate profile fields through parsing and asks candidates to review the extracted information. | Extraction testing is worthwhile, but cannot establish compatibility with every proprietary parser. | + +These sources support the general presentation choices. They do not establish that recruiters at these companies use Arete, prefer LaTeX, require this exact template, or guarantee an interview. STAR is an intake/writing aid; Arete renders candidate-written bullets and does not independently verify their real-world truth. + +## Confirmed Defects And Repairs + +| Finding | Repair and regression coverage | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| JavaScript front matter could execute through `gray-matter` before validation. Its YAML dependency also had two high-severity audit advisories. | Replace engine dispatch with explicit YAML-only parsing, a restricted YAML schema, metadata validation, and a patched direct dependency. Compatible Vitest, TypeScript ESLint, and transitive brace-expansion updates also clear the full development-dependency audit. Tests reject executable headers and malformed metadata. | +| The npm compiler wrapper interpolated paths into a shell and reused a shared intermediate directory. Its normal path never reached the advertised fallbacks when the runtime was missing. | Resolve the existing npm runtime binary without invoking the wrapper; use argument-array process execution, unique temporary directories, a timeout, and actual binary discovery for fallback selection. Tests cover shell metacharacters, fallback arguments, and failure diagnostics. | +| PDF page validation used a regex that missed compressed page objects and accepted corrupt/empty output. | Parse the PDF page tree with `pdf-lib`; require exactly one page. Reject zero/two-page compressed PDFs, corrupt output, and missing files. Publish only validated output and remove old PDFs on compilation/skip. | +| `init` overwrote existing candidate files. The documented default source path was not ignored. | Use exclusive file creation, regression-test preservation, and ignore `candidate.md`, generated TeX, and all PDFs by default. | +| Empty pipe-separated fields shifted facts into other fields; malformed records and duplicate sections lost data silently. | Preserve empty positions and reject duplicate sections/identity fields, unknown sections, surplus positional fields, and malformed language entries. | +| Provenance line numbers ignored the front matter. Wrapped summaries were truncated. STAR note bullets could be rendered as claims. | Count original source lines, retain the whole first paragraph, and stop claim bullets at the STAR notes marker. Test LF/CRLF and evidence-note exclusion. | +| Invalid numeric months rolled into different dates; reversed experience dates were accepted. | Validate experience dates and chronology before composition; preserve year-only precision. Invalid direct date formatting no longer rolls months. | +| Unsupported locales silently became English; Portuguese headings lacked accents and technology labels remained English. | Reject unsupported locales and localize all template labels, retaining candidate-authored prose unchanged. | +| Body text was effectively 9pt; multiple education/language/certification records could run together. Budget omissions were silent. | Use the declared 10pt body size, explicit paragraph boundaries, and CLI warnings for omitted list items. | +| Documentation named a private reference resume; sample links pointed at plausible third-party profiles. | Use neutral template descriptions, explicitly fictional sample identifiers, and reserved example-domain links. | +| CI and the test suite skipped real PDF compilation despite documentation implying coverage. | Add `pnpm test:pdf` to compile English/Portuguese simultaneously, verify A4/page count and extracted facts/order with Poppler, and reject real overflow. CI runs this command. | + +## Privacy Scope + +Repository examples and documentation are sanitized. No automatic anonymous export is implemented: removing a name alone would leave identifying employers, institutions, dates, projects, prose, and PDF metadata. Private candidates belong in `private/` or `*.private.md`; the default `candidate.md` is also ignored. Ignore rules do not remove previously tracked data or anonymize Git author metadata/history. This change does not rewrite history. + +## Remaining Limits + +- Real employer ATS systems were not accessed. Text extraction and visual review are useful checks, not employer certification. +- Source order and fixed list budgets require editorial review. Warnings expose omissions; excessively long content fails the one-page constraint. +- English and Brazilian Portuguese localize labels and experience dates, not prose translation or arbitrary date text in other sections. The bundled TeX font stack is not a universal writing-system implementation. +- Input follows Arete's guided Markdown contract, not arbitrary Markdown syntax. The compiler preserves supplied facts but cannot establish that a candidate's claims are true. +- Initial Tectonic compilation may download TeX support files. Missing runtime binaries or network access can prevent compilation; `.tex` remains available. System fallback commands are boundary-tested with fixture executables; local TeX Live distributions were not installed for this audit. + +See the completed audit ExecPlan for exact executed checks and results. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 6c2ea9b..6dc0e79 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -12,8 +12,16 @@ Resume data is personal data. The public repository should contain sanitized exa ## Ignored Local Data -Private candidate files should live under ignored paths such as `private/` or use ignored extensions such as `*.private.md`. Generated `dist/` output is ignored. +Private candidate files should live under ignored paths such as `private/` or use ignored extensions such as `*.private.md`. The default `candidate.md`, generated TeX/PDF files, and `dist/` output are ignored. `init` uses exclusive creation and cannot overwrite an existing source. Ignore rules do not remove already tracked files. ## Future AI Use If an LLM is added, document provider, data sent, retention/privacy tradeoffs, required environment variables, and validation gates before enabling it. + +## Untrusted Input Boundaries + +Front matter is YAML-only with a restricted schema and runtime-validated metadata; executable language markers are rejected before parsing. Candidate text is escaped for LaTeX. Compiler paths are passed as arguments without a shell. TeX uses restricted execution flags and private per-invocation working directories, which are removed afterward. PDF output is parsed and validated before publication. + +The bundled compiler can download TeX support files; candidate content is not sent to an editorial service. This is a local CLI, not a sandbox service for accepting arbitrary hostile TeX files. Only compile TeX produced by Arete from validated candidate input. + +Examples use fictional identities and reserved example-domain links. Sanitizing repository examples is not automatic anonymization of personal resumes, free text, Git metadata, or history. diff --git a/docs/TESTING.md b/docs/TESTING.md index 2a5edf9..44ffb65 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -6,7 +6,8 @@ Tests verify behavior and invariants, not implementation trivia. - Unit tests: parser helpers, schemas, date formatting, LaTeX escaping. - Integration tests: candidate Markdown to resume model, validation errors. -- End-to-end tests: CLI build from sanitized fixture to `.tex`, and PDF through the npm-managed compiler. +- End-to-end tests: CLI build from a synthetic fixture to `.tex` and refusal to overwrite a source. +- Real PDF smoke: `pnpm test:pdf` uses the built CLI and actual npm-managed compiler, then checks extraction with Poppler. It is separate from the fast test suite. ## Critical Coverage @@ -28,6 +29,10 @@ pnpm typecheck pnpm test pnpm test:coverage pnpm build +pnpm test:pdf +pnpm audit ``` Coverage thresholds are enforced globally at a modest level for the bootstrap and should increase around parser, validation, provenance, composition, and LaTeX modules as the project grows. + +The PDF smoke requires `pdftotext` on PATH (`poppler-utils` on Ubuntu/Debian). First-use Tectonic may require network access for support files. Unit compiler tests use fixture executables and real compressed PDF structures; they do not replace the real compilation smoke. diff --git a/docs/exec-plans/active/0001-greenfield-bootstrap.md b/docs/exec-plans/completed/0001-greenfield-bootstrap.md similarity index 88% rename from docs/exec-plans/active/0001-greenfield-bootstrap.md rename to docs/exec-plans/completed/0001-greenfield-bootstrap.md index b8bf71a..cf0fa10 100644 --- a/docs/exec-plans/active/0001-greenfield-bootstrap.md +++ b/docs/exec-plans/completed/0001-greenfield-bootstrap.md @@ -19,7 +19,7 @@ Create the initial Arete documentation, TypeScript toolchain, CLI, candidate par - `arete init`, `arete validate`, and `arete build` work against the example candidate. - Generated resume facts are derived from candidate source data. - LaTeX output escapes candidate text. -- LaTeX output follows the one-page A4 William Augusto reference style. +- LaTeX output follows the one-page A4 single-column style. - PDF compilation works through npm-installed tooling, with a useful diagnostic if no compiler path is available. - Formatting, linting, type checking, tests, coverage, and build pass. @@ -36,7 +36,7 @@ Create the initial Arete documentation, TypeScript toolchain, CLI, candidate par ## Decisions - PDF compilation should not require Linux distribution packages by default. Arete uses the npm dependency `node-latex-compiler`, which supplies Tectonic through npm optional runtime packages. System `latexmk` and `pdflatex` remain fallback paths for environments that already provide them. -- Resume output should be constrained to one A4 page and follow the William Augusto reference PDF's compact black-and-white LaTeX style. +- Resume output should be constrained to one A4 page and follow the template's compact black-and-white LaTeX style. - One-page budgeting preserves candidate source order for long skills, bullets, and technology lists. Overflow items may be omitted, but rendered claims must remain directly grounded in candidate data. ## Validation @@ -50,3 +50,7 @@ Create the initial Arete documentation, TypeScript toolchain, CLI, candidate par - `ARETE_SKIP_PDF_COMPILE=1 pnpm arete build --source examples/candidate.example.md --out dist/example` Local distribution TeX tooling is not installed in the current environment. Full PDF compilation is validated through the npm-managed compiler path installed by `pnpm install`. + +## Audit Follow-up + +Archived during the resume audit after the implemented MVP was revalidated. The audit supersedes the original compiler-wrapper implementation and PDF validation claims; see `0002-resume-audit.md` for current results. diff --git a/docs/exec-plans/completed/0002-resume-audit.md b/docs/exec-plans/completed/0002-resume-audit.md new file mode 100644 index 0000000..c86688e --- /dev/null +++ b/docs/exec-plans/completed/0002-resume-audit.md @@ -0,0 +1,55 @@ +# 0002 Resume, Privacy, And Reliability Audit + +## Objective + +Assess resume suitability against primary recruiting guidance, anonymize public examples and documentation, fix confirmed defects, validate the actual PDF pipeline, and publish a branch and pull request using `gh`. + +## Scope And Milestones + +1. Inspect repository, baseline tests, dependencies, and published employer guidance. +2. Repair unsafe parsing/compilation, factual-data corruption, privacy defaults, and localization/rendering defects with regression tests. +3. Exercise real PDF creation and extraction; make CI enforce this smoke test. +4. Record findings, evidence, limitations, and exact validation results; commit, push, and open a PR. + +## Acceptance Criteria + +- Untrusted Markdown cannot execute code; paths never enter a shell command. +- Missing fields cannot shift candidate facts, and provenance uses original file lines. +- Existing candidate files cannot be overwritten by `init`. +- Public candidate examples and template descriptions contain neutral sample identifiers. +- Real one-page PDFs are validated structurally; text extraction is checked in CI. +- All documented quality gates pass without lowering coverage thresholds. +- Recruiting claims distinguish general guidance from endorsement of this exact template. + +## Decisions + +- Interpret anonymization as sanitizing repository content; no inference that automatic anonymization of arbitrary free text is reliable. Git history is not rewritten. +- Keep the documented one-page product constraint. Document its limits for long careers and academic CVs. +- Replace executable front-matter engine dispatch with YAML-only parsing and runtime metadata validation. +- Use the existing npm runtime binaries through shell-free process execution, avoiding the wrapper's shared intermediate directory and shell interpolation. +- Add a PDF parser because regex counting cannot validate compressed PDF page objects. + +## Progress + +- Baseline: all 16 tests pass; no real PDF test exists. CI skips PDF compilation. +- Confirmed: executable JavaScript front matter, two high YAML dependency advisories, shell interpolation of compiler paths, destructive init, shifted empty pipe fields, body-relative provenance, summary truncation, invalid month rollover, incomplete localization, and personal reference-style naming. +- Implemented YAML-only metadata parsing, lossless field positions, original-file provenance, summary preservation, strict date/locale validation, and STAR-note exclusion. +- Implemented shell-free isolated compilation, real PDF page-tree validation, stale-output cleanup, and safe init creation. +- Sanitized public examples/template references; restored 10pt body text, localized labels, separated records, and exposed budget omissions. +- Added 50 passing tests plus a real concurrent English/Portuguese PDF and overflow smoke. Production and development dependency audits are clean after compatible updates. +- Documented employer evidence and practical limits in `docs/RESUME_AUDIT.md`. +- Implementation committed as `a092d3e` on `fix/resume-audit-privacy-and-validation`. GitHub CLI authorization was refreshed to include workflow access, the complete branch was pushed, and [PR #1](https://github.com/willstodev/arete/pull/1) was created using `gh`. + +## Validation + +- `pnpm lint`: passes, with the two complexity warnings in the existing parser/renderer functions (no errors). +- `pnpm typecheck`: passes. +- `pnpm test:coverage`: 50 tests pass; statements 95.95%, branches 85.65%, functions 97.72%, lines 96.38%. Thresholds unchanged. +- `pnpm build`: passes. +- `pnpm audit`: no known vulnerabilities (including development dependencies). +- `pnpm test:pdf`: actual concurrent English/Portuguese one-page A4 PDFs pass extracted contact/fact/section-order checks; actual oversized content fails and publishes no PDF. +- English PDF visually reviewed at 10pt body size. English/Portuguese PDFs also independently inspected with PyMuPDF. +- Local Poppler was unpacked under `/tmp/arete-poppler` because system installation requires an unavailable sudo password. Smoke command uses that directory on PATH and its library directory in LD_LIBRARY_PATH; CI installs `poppler-utils` normally. +- `pnpm install --frozen-lockfile`, `pnpm format:check`, `pnpm arete validate --source examples/candidate.example.md`, and `git diff --check`: pass. +- Repository diff reviewed; no private sources or generated resumes are included. +- Publication completed with the CI repairs included. The authentication blocker is resolved. diff --git a/examples/candidate.example.md b/examples/candidate.example.md index 9452702..64cc2dd 100644 --- a/examples/candidate.example.md +++ b/examples/candidate.example.md @@ -5,15 +5,17 @@ locale: en # Candidate +Synthetic demonstration only. All candidate details below are fictional. + ## Identity -Name: Jordan Avery +Name: Example Candidate Headline: Backend TypeScript Engineer -Location: Austin, TX -Email: jordan.avery@example.com -Phone: +1 555 010 0142 -LinkedIn: https://www.linkedin.com/in/jordan-avery-example -GitHub: https://github.com/javery-example +Location: Example City +Email: candidate@example.com +Phone: +1 202 555 0142 +LinkedIn: https://example.com/linkedin +GitHub: https://example.com/github ## Summary @@ -35,7 +37,7 @@ Backend engineer focused on TypeScript services, API design, data validation, an ## Experience -### Platform Engineer | Northstar Labs | 2021-03 - Present | Austin, TX +### Platform Engineer | Example Employer A | 2021-03 - Present | Example City - Migrated a legacy Node.js API to TypeScript, replacing loosely typed request handlers with typed service boundaries. - Built runtime validation for customer-facing API payloads, producing clearer client errors for invalid requests. @@ -52,7 +54,7 @@ Evidence status: No exact defect-reduction metric is known, so the resume avoids Technologies: TypeScript, Node.js, PostgreSQL, Zod, Vitest, GitHub Actions -### Software Engineer | Meadow Systems | 2018-07 - 2021-02 | Remote +### Software Engineer | Example Employer B | 2018-07 - 2021-02 | Remote - Maintained internal REST APIs used by operations teams to manage customer onboarding workflows. - Reworked data import jobs to surface row-level validation failures instead of generic batch errors. @@ -87,7 +89,7 @@ Technologies: TypeScript, GitHub Actions ## Education -### B.S. Computer Science | State University | 2018 +### B.S. Computer Science | Example University | 2018 ## Certifications diff --git a/package.json b/package.json index 8cb95cd..31df9f4 100644 --- a/package.json +++ b/package.json @@ -16,25 +16,28 @@ "lint": "eslint .", "test": "vitest run", "test:coverage": "vitest run --coverage", - "typecheck": "tsc -p tsconfig.json --noEmit" + "typecheck": "tsc -p tsconfig.json --noEmit", + "test:pdf": "tsx tests/pdf-smoke.ts" }, "dependencies": { "commander": "^14.0.1", - "gray-matter": "^4.0.3", + "js-yaml": "^4.3.2", "node-latex-compiler": "1.0.5", + "pdf-lib": "^1.17.1", "zod": "^4.1.12" }, "devDependencies": { "@eslint/js": "^9.38.0", + "@types/js-yaml": "^4.0.9", "@types/node": "^24.9.1", - "@vitest/coverage-v8": "^4.0.0", - "eslint": "^9.38.0", + "@vitest/coverage-v8": "^4.1.11", + "eslint": "^9.39.5", "eslint-config-prettier": "^10.1.8", "prettier": "^3.6.2", "tsx": "^4.20.6", "typescript": "^5.9.3", - "typescript-eslint": "^8.46.2", - "vitest": "^4.0.0" + "typescript-eslint": "^8.70.0", + "vitest": "^4.1.11" }, "engines": { "node": ">=24 <25" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6731a9..279e4da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,12 +10,15 @@ importers: commander: specifier: ^14.0.1 version: 14.0.3 - gray-matter: - specifier: ^4.0.3 - version: 4.0.3 + js-yaml: + specifier: ^4.3.2 + version: 4.3.2 node-latex-compiler: specifier: 1.0.5 version: 1.0.5 + pdf-lib: + specifier: ^1.17.1 + version: 1.17.1 zod: specifier: ^4.1.12 version: 4.4.3 @@ -23,14 +26,17 @@ importers: "@eslint/js": specifier: ^9.38.0 version: 9.39.5 + "@types/js-yaml": + specifier: ^4.0.9 + version: 4.0.9 "@types/node": specifier: ^24.9.1 version: 24.13.3 "@vitest/coverage-v8": - specifier: ^4.0.0 - version: 4.1.10(vitest@4.1.10) + specifier: ^4.1.11 + version: 4.1.11(vitest@4.1.11) eslint: - specifier: ^9.38.0 + specifier: ^9.39.5 version: 9.39.5 eslint-config-prettier: specifier: ^10.1.8 @@ -45,11 +51,11 @@ importers: specifier: ^5.9.3 version: 5.9.3 typescript-eslint: - specifier: ^8.46.2 - version: 8.64.0(eslint@9.39.5)(typescript@5.9.3) + specifier: ^8.70.0 + version: 8.70.0(eslint@9.39.5)(typescript@5.9.3) vitest: - specifier: ^4.0.0 - version: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)) + specifier: ^4.1.11 + version: 4.1.11(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)) packages: "@babel/helper-string-parser@7.29.7": @@ -340,6 +346,15 @@ packages: cpu: [x64] os: [win32] + "@eslint-community/eslint-utils@4.10.1": + resolution: + { + integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg== + } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + "@eslint-community/eslint-utils@4.9.1": resolution: { @@ -459,14 +474,15 @@ packages: integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== } - "@napi-rs/wasm-runtime@1.1.6": + "@napi-rs/wasm-runtime@1.2.4": resolution: { - integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg== + integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g== } + engines: { node: ^20.19.0 || ^22.13.0 || >=23.5.0 } peerDependencies: - "@emnapi/core": ^1.7.1 - "@emnapi/runtime": ^1.7.1 + "@emnapi/core": ^1.7.1 || ^2.0.0-alpha.4 + "@emnapi/runtime": ^1.7.1 || ^2.0.0-alpha.4 "@node-latex-compiler/bin-darwin-arm64@1.0.5": resolution: @@ -506,6 +522,18 @@ packages: integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw== } + "@pdf-lib/standard-fonts@1.0.0": + resolution: + { + integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA== + } + + "@pdf-lib/upng@1.0.1": + resolution: + { + integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ== + } + "@rolldown/binding-android-arm64@1.1.5": resolution: { @@ -652,10 +680,10 @@ packages: integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== } - "@tybys/wasm-util@0.10.3": + "@tybys/wasm-util@0.10.4": resolution: { - integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg== + integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A== } "@types/chai@5.2.3": @@ -676,6 +704,12 @@ packages: integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg== } + "@types/js-yaml@4.0.9": + resolution: + { + integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg== + } + "@types/json-schema@7.0.15": resolution: { @@ -688,117 +722,117 @@ packages: integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q== } - "@typescript-eslint/eslint-plugin@8.64.0": + "@typescript-eslint/eslint-plugin@8.70.0": resolution: { - integrity: sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q== + integrity: sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: - "@typescript-eslint/parser": ^8.64.0 + "@typescript-eslint/parser": ^8.70.0 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.1.0" - "@typescript-eslint/parser@8.64.0": + "@typescript-eslint/parser@8.70.0": resolution: { - integrity: sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw== + integrity: sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.1.0" - "@typescript-eslint/project-service@8.64.0": + "@typescript-eslint/project-service@8.70.0": resolution: { - integrity: sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg== + integrity: sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: typescript: ">=4.8.4 <6.1.0" - "@typescript-eslint/scope-manager@8.64.0": + "@typescript-eslint/scope-manager@8.70.0": resolution: { - integrity: sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w== + integrity: sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } - "@typescript-eslint/tsconfig-utils@8.64.0": + "@typescript-eslint/tsconfig-utils@8.70.0": resolution: { - integrity: sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw== + integrity: sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: typescript: ">=4.8.4 <6.1.0" - "@typescript-eslint/type-utils@8.64.0": + "@typescript-eslint/type-utils@8.70.0": resolution: { - integrity: sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg== + integrity: sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.1.0" - "@typescript-eslint/types@8.64.0": + "@typescript-eslint/types@8.70.0": resolution: { - integrity: sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA== + integrity: sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } - "@typescript-eslint/typescript-estree@8.64.0": + "@typescript-eslint/typescript-estree@8.70.0": resolution: { - integrity: sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA== + integrity: sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: typescript: ">=4.8.4 <6.1.0" - "@typescript-eslint/utils@8.64.0": + "@typescript-eslint/utils@8.70.0": resolution: { - integrity: sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ== + integrity: sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ">=4.8.4 <6.1.0" - "@typescript-eslint/visitor-keys@8.64.0": + "@typescript-eslint/visitor-keys@8.70.0": resolution: { - integrity: sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw== + integrity: sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } - "@vitest/coverage-v8@4.1.10": + "@vitest/coverage-v8@4.1.11": resolution: { - integrity: sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g== + integrity: sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw== } peerDependencies: - "@vitest/browser": 4.1.10 - vitest: 4.1.10 + "@vitest/browser": 4.1.11 + vitest: 4.1.11 peerDependenciesMeta: "@vitest/browser": optional: true - "@vitest/expect@4.1.10": + "@vitest/expect@4.1.11": resolution: { - integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA== + integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw== } - "@vitest/mocker@4.1.10": + "@vitest/mocker@4.1.11": resolution: { - integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow== + integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ== } peerDependencies: msw: ^2.4.9 @@ -809,34 +843,34 @@ packages: vite: optional: true - "@vitest/pretty-format@4.1.10": + "@vitest/pretty-format@4.1.11": resolution: { - integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q== + integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw== } - "@vitest/runner@4.1.10": + "@vitest/runner@4.1.11": resolution: { - integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg== + integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw== } - "@vitest/snapshot@4.1.10": + "@vitest/snapshot@4.1.11": resolution: { - integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw== + integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog== } - "@vitest/spy@4.1.10": + "@vitest/spy@4.1.11": resolution: { - integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw== + integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA== } - "@vitest/utils@4.1.10": + "@vitest/utils@4.1.11": resolution: { - integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA== + integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ== } acorn-jsx@5.3.2: @@ -868,12 +902,6 @@ packages: } engines: { node: ">=8" } - argparse@1.0.10: - resolution: - { - integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== - } - argparse@2.0.1: resolution: { @@ -906,18 +934,18 @@ packages: } engines: { node: 18 || 20 || >=22 } - brace-expansion@1.1.16: + brace-expansion@1.1.21: resolution: { - integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw== + integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw== } - brace-expansion@5.0.7: + brace-expansion@5.0.12: resolution: { - integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA== + integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ== } - engines: { node: 18 || 20 || >=22 } + engines: { node: 20 || >=22 } callsites@3.1.0: resolution: @@ -1068,6 +1096,7 @@ packages: integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: "*" @@ -1082,14 +1111,6 @@ packages: } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } - esprima@4.0.1: - resolution: - { - integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== - } - engines: { node: ">=4" } - hasBin: true - esquery@1.7.0: resolution: { @@ -1131,13 +1152,6 @@ packages: } engines: { node: ">=12.0.0" } - extend-shallow@2.0.1: - resolution: - { - integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug== - } - engines: { node: ">=0.10.0" } - fast-deep-equal@3.1.3: resolution: { @@ -1217,13 +1231,6 @@ packages: } engines: { node: ">=18" } - gray-matter@4.0.3: - resolution: - { - integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q== - } - engines: { node: ">=6.0" } - has-flag@4.0.0: resolution: { @@ -1244,10 +1251,10 @@ packages: } engines: { node: ">= 4" } - ignore@7.0.6: + ignore@7.0.9: resolution: { - integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw== + integrity: sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw== } engines: { node: ">= 4" } @@ -1265,13 +1272,6 @@ packages: } engines: { node: ">=0.8.19" } - is-extendable@0.1.1: - resolution: - { - integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw== - } - engines: { node: ">=0.10.0" } - is-extglob@2.1.1: resolution: { @@ -1319,17 +1319,10 @@ packages: integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q== } - js-yaml@3.15.0: + js-yaml@4.3.2: resolution: { - integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog== - } - hasBin: true - - js-yaml@4.3.0: - resolution: - { - integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q== + integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA== } hasBin: true @@ -1357,13 +1350,6 @@ packages: integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== } - kind-of@6.0.3: - resolution: - { - integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== - } - engines: { node: ">=0.10.0" } - levn@0.4.1: resolution: { @@ -1371,109 +1357,109 @@ packages: } engines: { node: ">= 0.8.0" } - lightningcss-android-arm64@1.32.0: + lightningcss-android-arm64@1.33.0: resolution: { - integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg== + integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg== } engines: { node: ">= 12.0.0" } cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.32.0: + lightningcss-darwin-arm64@1.33.0: resolution: { - integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ== + integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg== } engines: { node: ">= 12.0.0" } cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.32.0: + lightningcss-darwin-x64@1.33.0: resolution: { - integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w== + integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ== } engines: { node: ">= 12.0.0" } cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.32.0: + lightningcss-freebsd-x64@1.33.0: resolution: { - integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig== + integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg== } engines: { node: ">= 12.0.0" } cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.32.0: + lightningcss-linux-arm-gnueabihf@1.33.0: resolution: { - integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw== + integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ== } engines: { node: ">= 12.0.0" } cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.32.0: + lightningcss-linux-arm64-gnu@1.33.0: resolution: { - integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ== + integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg== } engines: { node: ">= 12.0.0" } cpu: [arm64] os: [linux] - lightningcss-linux-arm64-musl@1.32.0: + lightningcss-linux-arm64-musl@1.33.0: resolution: { - integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg== + integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ== } engines: { node: ">= 12.0.0" } cpu: [arm64] os: [linux] - lightningcss-linux-x64-gnu@1.32.0: + lightningcss-linux-x64-gnu@1.33.0: resolution: { - integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA== + integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg== } engines: { node: ">= 12.0.0" } cpu: [x64] os: [linux] - lightningcss-linux-x64-musl@1.32.0: + lightningcss-linux-x64-musl@1.33.0: resolution: { - integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg== + integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw== } engines: { node: ">= 12.0.0" } cpu: [x64] os: [linux] - lightningcss-win32-arm64-msvc@1.32.0: + lightningcss-win32-arm64-msvc@1.33.0: resolution: { - integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw== + integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA== } engines: { node: ">= 12.0.0" } cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.32.0: + lightningcss-win32-x64-msvc@1.33.0: resolution: { - integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q== + integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA== } engines: { node: ">= 12.0.0" } cpu: [x64] os: [win32] - lightningcss@1.32.0: + lightningcss@1.33.0: resolution: { - integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ== + integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA== } engines: { node: ">= 12.0.0" } @@ -1509,10 +1495,10 @@ packages: } engines: { node: ">=10" } - minimatch@10.2.5: + minimatch@10.2.6: resolution: { - integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg== + integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A== } engines: { node: 18 || 20 || >=22 } @@ -1528,10 +1514,10 @@ packages: integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== } - nanoid@3.3.16: + nanoid@3.3.19: resolution: { - integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q== + integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug== } engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 } hasBin: true @@ -1577,6 +1563,12 @@ packages: } engines: { node: ">=10" } + pako@1.0.11: + resolution: + { + integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== + } + parent-module@1.0.1: resolution: { @@ -1604,6 +1596,12 @@ packages: integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== } + pdf-lib@1.17.1: + resolution: + { + integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw== + } + picocolors@1.1.1: resolution: { @@ -1617,10 +1615,17 @@ packages: } engines: { node: ">=12" } - postcss@8.5.20: + picomatch@4.0.7: resolution: { - integrity: sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug== + integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA== + } + engines: { node: ">=12" } + + postcss@8.5.28: + resolution: + { + integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A== } engines: { node: ^10 || ^12 || >=14 } @@ -1661,13 +1666,6 @@ packages: engines: { node: ^20.19.0 || >=22.12.0 } hasBin: true - section-matter@1.0.0: - resolution: - { - integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA== - } - engines: { node: ">=4" } - semver@7.8.5: resolution: { @@ -1703,12 +1701,6 @@ packages: } engines: { node: ">=0.10.0" } - sprintf-js@1.0.3: - resolution: - { - integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== - } - stackback@0.0.2: resolution: { @@ -1721,13 +1713,6 @@ packages: integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw== } - strip-bom-string@1.0.0: - resolution: - { - integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g== - } - engines: { node: ">=0.10.0" } - strip-json-comments@3.1.1: resolution: { @@ -1778,6 +1763,12 @@ packages: peerDependencies: typescript: ">=4.8.4" + tslib@1.14.1: + resolution: + { + integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== + } + tslib@2.8.1: resolution: { @@ -1799,10 +1790,10 @@ packages: } engines: { node: ">= 0.8.0" } - typescript-eslint@8.64.0: + typescript-eslint@8.70.0: resolution: { - integrity: sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ== + integrity: sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw== } engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } peerDependencies: @@ -1875,10 +1866,10 @@ packages: yaml: optional: true - vitest@4.1.10: + vitest@4.1.11: resolution: { - integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw== + integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw== } engines: { node: ^20.0.0 || ^22.0.0 || >=24.0.0 } hasBin: true @@ -1886,12 +1877,12 @@ packages: "@edge-runtime/vm": "*" "@opentelemetry/api": ^1.9.0 "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 - "@vitest/browser-playwright": 4.1.10 - "@vitest/browser-preview": 4.1.10 - "@vitest/browser-webdriverio": 4.1.10 - "@vitest/coverage-istanbul": 4.1.10 - "@vitest/coverage-v8": 4.1.10 - "@vitest/ui": 4.1.10 + "@vitest/browser-playwright": 4.1.11 + "@vitest/browser-preview": 4.1.11 + "@vitest/browser-webdriverio": 4.1.11 + "@vitest/coverage-istanbul": 4.1.11 + "@vitest/coverage-v8": 4.1.11 + "@vitest/ui": 4.1.11 happy-dom: "*" jsdom: "*" vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -2065,6 +2056,11 @@ snapshots: "@esbuild/win32-x64@0.28.1": optional: true + "@eslint-community/eslint-utils@4.10.1(eslint@9.39.5)": + dependencies: + eslint: 9.39.5 + eslint-visitor-keys: 3.4.3 + "@eslint-community/eslint-utils@4.9.1(eslint@9.39.5)": dependencies: eslint: 9.39.5 @@ -2096,7 +2092,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.3.0 + js-yaml: 4.3.2 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -2136,11 +2132,11 @@ snapshots: "@jridgewell/resolve-uri": 3.1.2 "@jridgewell/sourcemap-codec": 1.5.5 - "@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)": + "@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)": dependencies: "@emnapi/core": 1.11.1 "@emnapi/runtime": 1.11.1 - "@tybys/wasm-util": 0.10.3 + "@tybys/wasm-util": 0.10.4 optional: true "@node-latex-compiler/bin-darwin-arm64@1.0.5": @@ -2157,6 +2153,14 @@ snapshots: "@oxc-project/types@0.139.0": {} + "@pdf-lib/standard-fonts@1.0.0": + dependencies: + pako: 1.0.11 + + "@pdf-lib/upng@1.0.1": + dependencies: + pako: 1.0.11 + "@rolldown/binding-android-arm64@1.1.5": optional: true @@ -2197,7 +2201,7 @@ snapshots: dependencies: "@emnapi/core": 1.11.1 "@emnapi/runtime": 1.11.1 - "@napi-rs/wasm-runtime": 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + "@napi-rs/wasm-runtime": 1.2.4(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true "@rolldown/binding-win32-arm64-msvc@1.1.5": @@ -2210,7 +2214,7 @@ snapshots: "@standard-schema/spec@1.1.0": {} - "@tybys/wasm-util@0.10.3": + "@tybys/wasm-util@0.10.4": dependencies: tslib: 2.8.1 optional: true @@ -2224,63 +2228,65 @@ snapshots: "@types/estree@1.0.9": {} + "@types/js-yaml@4.0.9": {} + "@types/json-schema@7.0.15": {} "@types/node@24.13.3": dependencies: undici-types: 7.18.2 - "@typescript-eslint/eslint-plugin@8.64.0(@typescript-eslint/parser@8.64.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3)": + "@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3)": dependencies: "@eslint-community/regexpp": 4.12.2 - "@typescript-eslint/parser": 8.64.0(eslint@9.39.5)(typescript@5.9.3) - "@typescript-eslint/scope-manager": 8.64.0 - "@typescript-eslint/type-utils": 8.64.0(eslint@9.39.5)(typescript@5.9.3) - "@typescript-eslint/utils": 8.64.0(eslint@9.39.5)(typescript@5.9.3) - "@typescript-eslint/visitor-keys": 8.64.0 + "@typescript-eslint/parser": 8.70.0(eslint@9.39.5)(typescript@5.9.3) + "@typescript-eslint/scope-manager": 8.70.0 + "@typescript-eslint/type-utils": 8.70.0(eslint@9.39.5)(typescript@5.9.3) + "@typescript-eslint/utils": 8.70.0(eslint@9.39.5)(typescript@5.9.3) + "@typescript-eslint/visitor-keys": 8.70.0 eslint: 9.39.5 - ignore: 7.0.6 + ignore: 7.0.9 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - "@typescript-eslint/parser@8.64.0(eslint@9.39.5)(typescript@5.9.3)": + "@typescript-eslint/parser@8.70.0(eslint@9.39.5)(typescript@5.9.3)": dependencies: - "@typescript-eslint/scope-manager": 8.64.0 - "@typescript-eslint/types": 8.64.0 - "@typescript-eslint/typescript-estree": 8.64.0(typescript@5.9.3) - "@typescript-eslint/visitor-keys": 8.64.0 + "@typescript-eslint/scope-manager": 8.70.0 + "@typescript-eslint/types": 8.70.0 + "@typescript-eslint/typescript-estree": 8.70.0(typescript@5.9.3) + "@typescript-eslint/visitor-keys": 8.70.0 debug: 4.4.3 eslint: 9.39.5 typescript: 5.9.3 transitivePeerDependencies: - supports-color - "@typescript-eslint/project-service@8.64.0(typescript@5.9.3)": + "@typescript-eslint/project-service@8.70.0(typescript@5.9.3)": dependencies: - "@typescript-eslint/tsconfig-utils": 8.64.0(typescript@5.9.3) - "@typescript-eslint/types": 8.64.0 + "@typescript-eslint/tsconfig-utils": 8.70.0(typescript@5.9.3) + "@typescript-eslint/types": 8.70.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - "@typescript-eslint/scope-manager@8.64.0": + "@typescript-eslint/scope-manager@8.70.0": dependencies: - "@typescript-eslint/types": 8.64.0 - "@typescript-eslint/visitor-keys": 8.64.0 + "@typescript-eslint/types": 8.70.0 + "@typescript-eslint/visitor-keys": 8.70.0 - "@typescript-eslint/tsconfig-utils@8.64.0(typescript@5.9.3)": + "@typescript-eslint/tsconfig-utils@8.70.0(typescript@5.9.3)": dependencies: typescript: 5.9.3 - "@typescript-eslint/type-utils@8.64.0(eslint@9.39.5)(typescript@5.9.3)": + "@typescript-eslint/type-utils@8.70.0(eslint@9.39.5)(typescript@5.9.3)": dependencies: - "@typescript-eslint/types": 8.64.0 - "@typescript-eslint/typescript-estree": 8.64.0(typescript@5.9.3) - "@typescript-eslint/utils": 8.64.0(eslint@9.39.5)(typescript@5.9.3) + "@typescript-eslint/types": 8.70.0 + "@typescript-eslint/typescript-estree": 8.70.0(typescript@5.9.3) + "@typescript-eslint/utils": 8.70.0(eslint@9.39.5)(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.5 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -2288,16 +2294,16 @@ snapshots: transitivePeerDependencies: - supports-color - "@typescript-eslint/types@8.64.0": {} + "@typescript-eslint/types@8.70.0": {} - "@typescript-eslint/typescript-estree@8.64.0(typescript@5.9.3)": + "@typescript-eslint/typescript-estree@8.70.0(typescript@5.9.3)": dependencies: - "@typescript-eslint/project-service": 8.64.0(typescript@5.9.3) - "@typescript-eslint/tsconfig-utils": 8.64.0(typescript@5.9.3) - "@typescript-eslint/types": 8.64.0 - "@typescript-eslint/visitor-keys": 8.64.0 + "@typescript-eslint/project-service": 8.70.0(typescript@5.9.3) + "@typescript-eslint/tsconfig-utils": 8.70.0(typescript@5.9.3) + "@typescript-eslint/types": 8.70.0 + "@typescript-eslint/visitor-keys": 8.70.0 debug: 4.4.3 - minimatch: 10.2.5 + minimatch: 10.2.6 semver: 7.8.5 tinyglobby: 0.2.17 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -2305,26 +2311,26 @@ snapshots: transitivePeerDependencies: - supports-color - "@typescript-eslint/utils@8.64.0(eslint@9.39.5)(typescript@5.9.3)": + "@typescript-eslint/utils@8.70.0(eslint@9.39.5)(typescript@5.9.3)": dependencies: - "@eslint-community/eslint-utils": 4.9.1(eslint@9.39.5) - "@typescript-eslint/scope-manager": 8.64.0 - "@typescript-eslint/types": 8.64.0 - "@typescript-eslint/typescript-estree": 8.64.0(typescript@5.9.3) + "@eslint-community/eslint-utils": 4.10.1(eslint@9.39.5) + "@typescript-eslint/scope-manager": 8.70.0 + "@typescript-eslint/types": 8.70.0 + "@typescript-eslint/typescript-estree": 8.70.0(typescript@5.9.3) eslint: 9.39.5 typescript: 5.9.3 transitivePeerDependencies: - supports-color - "@typescript-eslint/visitor-keys@8.64.0": + "@typescript-eslint/visitor-keys@8.70.0": dependencies: - "@typescript-eslint/types": 8.64.0 + "@typescript-eslint/types": 8.70.0 eslint-visitor-keys: 5.0.1 - "@vitest/coverage-v8@4.1.10(vitest@4.1.10)": + "@vitest/coverage-v8@4.1.11(vitest@4.1.11)": dependencies: "@bcoe/v8-coverage": 1.0.2 - "@vitest/utils": 4.1.10 + "@vitest/utils": 4.1.11 ast-v8-to-istanbul: 1.0.5 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 @@ -2333,46 +2339,46 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.0 - vitest: 4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)) + vitest: 4.1.11(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)) - "@vitest/expect@4.1.10": + "@vitest/expect@4.1.11": dependencies: "@standard-schema/spec": 1.1.0 "@types/chai": 5.2.3 - "@vitest/spy": 4.1.10 - "@vitest/utils": 4.1.10 + "@vitest/spy": 4.1.11 + "@vitest/utils": 4.1.11 chai: 6.2.2 tinyrainbow: 3.1.0 - "@vitest/mocker@4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1))": + "@vitest/mocker@4.1.11(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1))": dependencies: - "@vitest/spy": 4.1.10 + "@vitest/spy": 4.1.11 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1) - "@vitest/pretty-format@4.1.10": + "@vitest/pretty-format@4.1.11": dependencies: tinyrainbow: 3.1.0 - "@vitest/runner@4.1.10": + "@vitest/runner@4.1.11": dependencies: - "@vitest/utils": 4.1.10 + "@vitest/utils": 4.1.11 pathe: 2.0.3 - "@vitest/snapshot@4.1.10": + "@vitest/snapshot@4.1.11": dependencies: - "@vitest/pretty-format": 4.1.10 - "@vitest/utils": 4.1.10 + "@vitest/pretty-format": 4.1.11 + "@vitest/utils": 4.1.11 magic-string: 0.30.21 pathe: 2.0.3 - "@vitest/spy@4.1.10": {} + "@vitest/spy@4.1.11": {} - "@vitest/utils@4.1.10": + "@vitest/utils@4.1.11": dependencies: - "@vitest/pretty-format": 4.1.10 + "@vitest/pretty-format": 4.1.11 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 @@ -2393,10 +2399,6 @@ snapshots: dependencies: color-convert: 2.0.1 - argparse@1.0.10: - dependencies: - sprintf-js: 1.0.3 - argparse@2.0.1: {} assertion-error@2.0.1: {} @@ -2411,12 +2413,12 @@ snapshots: balanced-match@4.0.4: {} - brace-expansion@1.1.16: + brace-expansion@1.1.21: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@5.0.7: + brace-expansion@5.0.12: dependencies: balanced-match: 4.0.4 @@ -2548,8 +2550,6 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.17.0) eslint-visitor-keys: 4.2.1 - esprima@4.0.1: {} - esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -2568,10 +2568,6 @@ snapshots: expect-type@1.4.0: {} - extend-shallow@2.0.1: - dependencies: - is-extendable: 0.1.1 - fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -2607,20 +2603,13 @@ snapshots: globals@14.0.0: {} - gray-matter@4.0.3: - dependencies: - js-yaml: 3.15.0 - kind-of: 6.0.3 - section-matter: 1.0.0 - strip-bom-string: 1.0.0 - has-flag@4.0.0: {} html-escaper@2.0.2: {} ignore@5.3.2: {} - ignore@7.0.6: {} + ignore@7.0.9: {} import-fresh@3.3.1: dependencies: @@ -2629,8 +2618,6 @@ snapshots: imurmurhash@0.1.4: {} - is-extendable@0.1.1: {} - is-extglob@2.1.1: {} is-glob@4.0.3: @@ -2654,12 +2641,7 @@ snapshots: js-tokens@10.0.0: {} - js-yaml@3.15.0: - dependencies: - argparse: 1.0.10 - esprima: 4.0.1 - - js-yaml@4.3.0: + js-yaml@4.3.2: dependencies: argparse: 2.0.1 @@ -2673,61 +2655,59 @@ snapshots: dependencies: json-buffer: 3.0.1 - kind-of@6.0.3: {} - levn@0.4.1: dependencies: prelude-ls: 1.2.1 type-check: 0.4.0 - lightningcss-android-arm64@1.32.0: + lightningcss-android-arm64@1.33.0: optional: true - lightningcss-darwin-arm64@1.32.0: + lightningcss-darwin-arm64@1.33.0: optional: true - lightningcss-darwin-x64@1.32.0: + lightningcss-darwin-x64@1.33.0: optional: true - lightningcss-freebsd-x64@1.32.0: + lightningcss-freebsd-x64@1.33.0: optional: true - lightningcss-linux-arm-gnueabihf@1.32.0: + lightningcss-linux-arm-gnueabihf@1.33.0: optional: true - lightningcss-linux-arm64-gnu@1.32.0: + lightningcss-linux-arm64-gnu@1.33.0: optional: true - lightningcss-linux-arm64-musl@1.32.0: + lightningcss-linux-arm64-musl@1.33.0: optional: true - lightningcss-linux-x64-gnu@1.32.0: + lightningcss-linux-x64-gnu@1.33.0: optional: true - lightningcss-linux-x64-musl@1.32.0: + lightningcss-linux-x64-musl@1.33.0: optional: true - lightningcss-win32-arm64-msvc@1.32.0: + lightningcss-win32-arm64-msvc@1.33.0: optional: true - lightningcss-win32-x64-msvc@1.32.0: + lightningcss-win32-x64-msvc@1.33.0: optional: true - lightningcss@1.32.0: + lightningcss@1.33.0: dependencies: detect-libc: 2.1.2 optionalDependencies: - lightningcss-android-arm64: 1.32.0 - lightningcss-darwin-arm64: 1.32.0 - lightningcss-darwin-x64: 1.32.0 - lightningcss-freebsd-x64: 1.32.0 - lightningcss-linux-arm-gnueabihf: 1.32.0 - lightningcss-linux-arm64-gnu: 1.32.0 - lightningcss-linux-arm64-musl: 1.32.0 - lightningcss-linux-x64-gnu: 1.32.0 - lightningcss-linux-x64-musl: 1.32.0 - lightningcss-win32-arm64-msvc: 1.32.0 - lightningcss-win32-x64-msvc: 1.32.0 + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 locate-path@6.0.0: dependencies: @@ -2749,17 +2729,17 @@ snapshots: dependencies: semver: 7.8.5 - minimatch@10.2.5: + minimatch@10.2.6: dependencies: - brace-expansion: 5.0.7 + brace-expansion: 5.0.12 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.16 + brace-expansion: 1.1.21 ms@2.1.3: {} - nanoid@3.3.16: {} + nanoid@3.3.19: {} natural-compare@1.4.0: {} @@ -2789,6 +2769,8 @@ snapshots: dependencies: p-limit: 3.1.0 + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -2799,13 +2781,22 @@ snapshots: pathe@2.0.3: {} + pdf-lib@1.17.1: + dependencies: + "@pdf-lib/standard-fonts": 1.0.0 + "@pdf-lib/upng": 1.0.1 + pako: 1.0.11 + tslib: 1.14.1 + picocolors@1.1.1: {} picomatch@4.0.5: {} - postcss@8.5.20: + picomatch@4.0.7: {} + + postcss@8.5.28: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.19 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -2838,11 +2829,6 @@ snapshots: "@rolldown/binding-win32-arm64-msvc": 1.1.5 "@rolldown/binding-win32-x64-msvc": 1.1.5 - section-matter@1.0.0: - dependencies: - extend-shallow: 2.0.1 - kind-of: 6.0.3 - semver@7.8.5: {} shebang-command@2.0.0: @@ -2855,14 +2841,10 @@ snapshots: source-map-js@1.2.1: {} - sprintf-js@1.0.3: {} - stackback@0.0.2: {} std-env@4.2.0: {} - strip-bom-string@1.0.0: {} - strip-json-comments@3.1.1: {} supports-color@7.2.0: @@ -2884,6 +2866,8 @@ snapshots: dependencies: typescript: 5.9.3 + tslib@1.14.1: {} + tslib@2.8.1: optional: true @@ -2897,12 +2881,12 @@ snapshots: dependencies: prelude-ls: 1.2.1 - typescript-eslint@8.64.0(eslint@9.39.5)(typescript@5.9.3): + typescript-eslint@8.70.0(eslint@9.39.5)(typescript@5.9.3): dependencies: - "@typescript-eslint/eslint-plugin": 8.64.0(@typescript-eslint/parser@8.64.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3) - "@typescript-eslint/parser": 8.64.0(eslint@9.39.5)(typescript@5.9.3) - "@typescript-eslint/typescript-estree": 8.64.0(typescript@5.9.3) - "@typescript-eslint/utils": 8.64.0(eslint@9.39.5)(typescript@5.9.3) + "@typescript-eslint/eslint-plugin": 8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3) + "@typescript-eslint/parser": 8.70.0(eslint@9.39.5)(typescript@5.9.3) + "@typescript-eslint/typescript-estree": 8.70.0(typescript@5.9.3) + "@typescript-eslint/utils": 8.70.0(eslint@9.39.5)(typescript@5.9.3) eslint: 9.39.5 typescript: 5.9.3 transitivePeerDependencies: @@ -2918,9 +2902,9 @@ snapshots: vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1): dependencies: - lightningcss: 1.32.0 - picomatch: 4.0.5 - postcss: 8.5.20 + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 rolldown: 1.1.5 tinyglobby: 0.2.17 optionalDependencies: @@ -2929,15 +2913,15 @@ snapshots: fsevents: 2.3.3 tsx: 4.23.1 - vitest@4.1.10(@types/node@24.13.3)(@vitest/coverage-v8@4.1.10)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)): + vitest@4.1.11(@types/node@24.13.3)(@vitest/coverage-v8@4.1.11)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)): dependencies: - "@vitest/expect": 4.1.10 - "@vitest/mocker": 4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)) - "@vitest/pretty-format": 4.1.10 - "@vitest/runner": 4.1.10 - "@vitest/snapshot": 4.1.10 - "@vitest/spy": 4.1.10 - "@vitest/utils": 4.1.10 + "@vitest/expect": 4.1.11 + "@vitest/mocker": 4.1.11(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)) + "@vitest/pretty-format": 4.1.11 + "@vitest/runner": 4.1.11 + "@vitest/snapshot": 4.1.11 + "@vitest/spy": 4.1.11 + "@vitest/utils": 4.1.11 es-module-lexer: 2.3.1 expect-type: 1.4.0 magic-string: 0.30.21 @@ -2953,7 +2937,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: "@types/node": 24.13.3 - "@vitest/coverage-v8": 4.1.10(vitest@4.1.10) + "@vitest/coverage-v8": 4.1.11(vitest@4.1.11) transitivePeerDependencies: - msw diff --git a/src/candidate/front-matter.ts b/src/candidate/front-matter.ts new file mode 100644 index 0000000..0481e31 --- /dev/null +++ b/src/candidate/front-matter.ts @@ -0,0 +1,30 @@ +import { JSON_SCHEMA, load } from "js-yaml"; +import { z } from "zod"; +import { ValidationError } from "../diagnostics/errors.js"; + +const metadataSchema = z.strictObject({ + schemaVersion: z.literal(1), + locale: z.string().min(2).optional() +}); + +export function parseFrontMatter(markdown: string, file: string) { + const lines = markdown.replace(/^\uFEFF/u, "").split(/\r?\n/u); + if (lines[0]?.trim() !== "---") { + throw new ValidationError( + `Invalid candidate source ${file}: expected YAML front matter (---).` + ); + } + const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---"); + if (end < 0) { + throw new ValidationError(`Invalid candidate source ${file}: unclosed YAML front matter.`); + } + try { + const data: unknown = load(lines.slice(1, end).join("\n"), { schema: JSON_SCHEMA }); + const metadata = metadataSchema.parse(data); + return { metadata, body: lines.slice(end + 1).join("\n"), lineOffset: end + 1 }; + } catch (error) { + throw new ValidationError( + `Invalid candidate metadata in ${file}: ${error instanceof Error ? error.message : String(error)}` + ); + } +} diff --git a/src/candidate/parser.ts b/src/candidate/parser.ts index 8f55b8f..022e374 100644 --- a/src/candidate/parser.ts +++ b/src/candidate/parser.ts @@ -1,4 +1,4 @@ -import matter from "gray-matter"; +import { parseFrontMatter } from "./front-matter.js"; import { candidateSchema } from "./schema.js"; import type { Candidate, @@ -23,14 +23,22 @@ type Entry = { lines: { text: string; line: number }[]; }; +const supportedSections = new Set([ + "Identity", + "Summary", + "Skills", + "Experience", + "Projects", + "Education", + "Certifications", + "Languages", + "Targeting Notes" +]); + export function parseCandidateMarkdown(markdown: string, file: string): Candidate { - const parsed = matter(markdown); - if (parsed.data.schemaVersion !== 1) { - throw new ValidationError(`Invalid candidate source ${file}: schemaVersion must be 1`); - } - const locale = typeof parsed.data.locale === "string" ? parsed.data.locale : undefined; - const body = parsed.content; - const sections = splitSections(body); + const { metadata, body, lineOffset } = parseFrontMatter(markdown, file); + const locale = metadata.locale; + const sections = splitSections(body, lineOffset, file); const identity = parseIdentity(requiredSection(sections, "Identity", file), file); const summarySection = sections.get("Summary"); @@ -64,15 +72,21 @@ export function parseCandidateMarkdown(markdown: string, file: string): Candidat return candidate; } -function splitSections(markdown: string): Map { +function splitSections(markdown: string, lineOffset: number, file: string): Map { const sections = new Map(); let current: Section | undefined; const lines = markdown.split(/\r?\n/); lines.forEach((text, index) => { - const line = index + 1; + const line = index + 1 + lineOffset; const match = /^##\s+(.+?)\s*$/.exec(text); if (match?.[1]) { + if (!supportedSections.has(match[1])) { + throw new ValidationError(`Unknown section "${match[1]}" at ${file}:${line}`); + } + if (sections.has(match[1])) { + throw new ValidationError(`Duplicate section "${match[1]}" at ${file}:${line}`); + } current = { title: match[1], line, lines: [] }; sections.set(current.title, current); return; @@ -108,7 +122,11 @@ function parseIdentity(section: Section, file: string): Candidate["identity"] { for (const row of section.lines) { const match = /^([A-Za-z][A-Za-z ]+):\s*(.*)$/.exec(row.text); if (match?.[1] && match[2]?.trim()) { - fields.set(match[1].trim().toLowerCase(), sourced(match[2], file, section.title, row.line)); + const key = match[1].trim().toLowerCase(); + if (fields.has(key)) { + throw new ValidationError(`Duplicate identity field "${key}" at ${file}:${row.line}`); + } + fields.set(key, sourced(match[2], file, section.title, row.line)); } } @@ -139,8 +157,17 @@ function parseIdentity(section: Section, file: string): Candidate["identity"] { } function firstParagraph(section: Section, file: string): SourcedText | undefined { - const row = section.lines.find((line) => line.text.trim() && !line.text.trim().startsWith("#")); - return row ? sourced(row.text, file, section.title, row.line) : undefined; + const start = section.lines.findIndex( + (line) => line.text.trim() && !line.text.trim().startsWith("#") + ); + const first = section.lines[start]; + if (!first) return undefined; + const paragraph: string[] = []; + for (const row of section.lines.slice(start)) { + if (!row.text.trim() || row.text.startsWith("#")) break; + paragraph.push(row.text.trim()); + } + return sourced(paragraph.join(" "), file, section.title, first.line); } function parseListSection(section: Section | undefined, file: string): SourcedText[] { @@ -181,7 +208,7 @@ function splitEntries(section: Section | undefined, file: string): Entry[] { function parseExperience(section: Section | undefined, file: string): Experience[] { return splitEntries(section, file).map((entry) => { const parts = splitPipe(entry.heading.value); - if (parts.length < 4) { + if (parts.length !== 4) { throw new ValidationError( `Experience heading at ${file}:${entry.heading.source.line} must be "Title | Employer | Start - End | Location"` ); @@ -206,7 +233,13 @@ function parseExperience(section: Section | undefined, file: string): Experience function parseProjects(section: Section | undefined, file: string): Project[] { return splitEntries(section, file).map((entry) => { - const [name, date] = splitPipe(entry.heading.value); + const parts = splitPipe(entry.heading.value); + if (parts.length > 2) { + throw new ValidationError( + `Project heading at ${file}:${entry.heading.source.line} must be "Name | Date"` + ); + } + const [name, date] = parts; if (!name) { throw new ValidationError(`Project heading at ${file}:${entry.heading.source.line} is empty`); } @@ -222,8 +255,9 @@ function parseProjects(section: Section | undefined, file: string): Project[] { function parseEducation(section: Section | undefined, file: string): Education[] { return splitEntries(section, file).map((entry) => { - const [credential, institution, date] = splitPipe(entry.heading.value); - if (!credential || !institution) { + const parts = splitPipe(entry.heading.value); + const [credential, institution, date] = parts; + if (!credential || !institution || parts.length > 3) { throw new ValidationError( `Education heading at ${file}:${entry.heading.source.line} must be "Credential | Institution | Date"` ); @@ -248,9 +282,12 @@ function parseCertifications(section: Section | undefined, file: string): Certif return []; } - const [name, issuer, date] = splitPipe(match[1]); - if (!name) { - return []; + const parts = splitPipe(match[1]); + const [name, issuer, date] = parts; + if (!name || parts.length > 3) { + throw new ValidationError( + `Certification at ${file}:${row.line} must be "Name | Issuer | Date"` + ); } return [ @@ -271,6 +308,11 @@ function parseLanguages(section: Section | undefined, file: string): Language[] return section.lines.flatMap((row) => { const match = /^-\s+(.+?):\s*(.+)$/.exec(row.text); if (!match?.[1] || !match[2]) { + if (row.text.startsWith("-")) { + throw new ValidationError( + `Language at ${file}:${row.line} must be "Language: Proficiency"` + ); + } return []; } @@ -284,7 +326,9 @@ function parseLanguages(section: Section | undefined, file: string): Language[] } function parseBullets(entry: Entry, file: string, section: string): SourcedText[] { - return entry.lines.flatMap((row) => { + const notesStart = entry.lines.findIndex((row) => row.text.trim() === "STAR evidence notes:"); + const bulletLines = notesStart < 0 ? entry.lines : entry.lines.slice(0, notesStart); + return bulletLines.flatMap((row) => { const match = /^-\s+(.+)$/.exec(row.text); return match?.[1] ? [sourced(match[1], file, section, row.line)] : []; }); @@ -305,10 +349,7 @@ function parseTechnologies(entry: Entry, file: string, section: string): Sourced } function splitPipe(value: string): string[] { - return value - .split("|") - .map((part) => part.trim()) - .filter(Boolean); + return value.split("|").map((part) => part.trim()); } function splitDateRange(value: string, file: string, line: number): { start: string; end: string } { diff --git a/src/candidate/schema.ts b/src/candidate/schema.ts index 84db09d..d140aff 100644 --- a/src/candidate/schema.ts +++ b/src/candidate/schema.ts @@ -11,8 +11,18 @@ const sourcedTextSchema = z.object({ source: sourceRefSchema }); +const datePattern = /^(?!0000)\d{4}(?:-(?:0[1-9]|1[0-2]))?$/u; +const dateSchema = sourcedTextSchema.extend({ + value: z.string().regex(datePattern, "Use YYYY or YYYY-MM with a valid month") +}); +const endDateSchema = sourcedTextSchema.extend({ + value: z.string().refine((value) => datePattern.test(value) || /^present$/iu.test(value), { + message: "Use YYYY, YYYY-MM, or Present" + }) +}); + const contactInfoSchema = z.object({ - email: sourcedTextSchema, + email: sourcedTextSchema.extend({ value: z.email() }), phone: sourcedTextSchema.optional(), linkedIn: sourcedTextSchema.optional(), github: sourcedTextSchema.optional(), @@ -21,7 +31,10 @@ const contactInfoSchema = z.object({ export const candidateSchema = z.object({ schemaVersion: z.literal(1), - locale: z.string().min(2).optional(), + locale: z + .string() + .refine((value) => /^(en|pt|pt-br)$/iu.test(value), "Supported locales: en, pt-BR") + .optional(), identity: z.object({ name: sourcedTextSchema, headline: sourcedTextSchema.optional(), @@ -32,15 +45,27 @@ export const candidateSchema = z.object({ skills: z.array(sourcedTextSchema).default([]), experiences: z .array( - z.object({ - title: sourcedTextSchema, - employer: sourcedTextSchema, - start: sourcedTextSchema, - end: sourcedTextSchema, - location: sourcedTextSchema.optional(), - bullets: z.array(sourcedTextSchema).min(1), - technologies: z.array(sourcedTextSchema).default([]) - }) + z + .object({ + title: sourcedTextSchema, + employer: sourcedTextSchema, + start: dateSchema, + end: endDateSchema, + location: sourcedTextSchema.optional(), + bullets: z.array(sourcedTextSchema).min(1), + technologies: z.array(sourcedTextSchema).default([]) + }) + .refine( + (entry) => { + if (/^present$/iu.test(entry.end.value)) return true; + // Year-only values express uncertainty: compare the latest possible end month. + const start = + entry.start.value.length === 4 ? `${entry.start.value}-01` : entry.start.value; + const end = entry.end.value.length === 4 ? `${entry.end.value}-12` : entry.end.value; + return start <= end; + }, + { message: "Experience end date must not precede its start date", path: ["end"] } + ) ) .default([]), projects: z diff --git a/src/cli/main.ts b/src/cli/main.ts index ad763bd..1bdadfb 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -7,7 +7,7 @@ import { candidateTemplate } from "../candidate/template.js"; import { validateEnv } from "../config/env.js"; import { formatError, EnvironmentError } from "../diagnostics/errors.js"; import { compilePdf } from "../latex/compile.js"; -import { renderLatex } from "../latex/render.js"; +import { renderLatex, renderWarnings } from "../latex/render.js"; import { composeResume } from "../resume/compose.js"; type BuildOptions = { @@ -39,7 +39,7 @@ program await runCli(async () => { const output = options.output ?? "candidate.md"; await mkdir(dirname(output), { recursive: true }); - await writeFile(output, candidateTemplate, "utf8"); + await writeFile(output, candidateTemplate, { encoding: "utf8", flag: "wx" }); console.log(`Wrote ${output}`); }); }); @@ -69,6 +69,7 @@ program const candidate = parseCandidateMarkdown(markdown, options.source); const resume = composeResume(candidate, options.locale ? { locale: options.locale } : {}); const latex = renderLatex(resume); + for (const warning of renderWarnings(resume)) console.warn(`Warning: ${warning}`); await mkdir(options.out, { recursive: true }); const texPath = join(options.out, "resume.tex"); await writeFile(texPath, latex, "utf8"); diff --git a/src/latex/compile.ts b/src/latex/compile.ts index b4adb4f..5d4158c 100644 --- a/src/latex/compile.ts +++ b/src/latex/compile.ts @@ -1,9 +1,10 @@ -import { access, readFile } from "node:fs/promises"; +import { access, copyFile, mkdtemp, readFile, rm } from "node:fs/promises"; import { constants } from "node:fs"; import { spawn } from "node:child_process"; -import { basename, dirname } from "node:path"; -import { compile as compileWithNodeLatex } from "node-latex-compiler"; -import type { CompileResult as NodeLatexCompileResult } from "node-latex-compiler"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { delimiter, dirname, join, resolve } from "node:path"; +import { PDFDocument } from "pdf-lib"; import { EnvironmentError } from "../diagnostics/errors.js"; export type CompileResult = { @@ -12,22 +13,21 @@ export type CompileResult = { message: string; }; -type NodeLatexCompiler = (config: { - texFile: string; - outputDir: string; - outputFile: string; -}) => Promise; - type CompilePdfOptions = { - nodeCompiler?: NodeLatexCompiler | null; + /** Override binary discovery for isolated compiler boundary tests. */ + tectonicPath?: string | null; }; export async function compilePdf( texPath: string, options: CompilePdfOptions = {} ): Promise { + if (!texPath.endsWith(".tex")) { + throw new EnvironmentError("PDF input must have a .tex extension."); + } const pdfPath = texPath.replace(/\.tex$/u, ".pdf"); - + // Never leave an old resume looking like the result of a skipped or failed build. + await rm(pdfPath, { force: true }); if (process.env.ARETE_SKIP_PDF_COMPILE === "1") { return { skipped: true, @@ -36,118 +36,127 @@ export async function compilePdf( }; } - const nodeCompiler = Object.hasOwn(options, "nodeCompiler") - ? options.nodeCompiler - : compileWithNodeLatex; - if (nodeCompiler) { - return compilePdfWithNodeLatex(nodeCompiler, texPath, pdfPath); - } + const { command, kind } = await findCompiler(options, texPath); - const latexmk = await commandExists("latexmk"); - if (latexmk) { - await run( - "latexmk", - ["-pdf", "-interaction=nonstopmode", "-halt-on-error", basename(texPath)], - dirname(texPath) - ); - await assertSinglePagePdf(pdfPath); + // Each invocation gets private intermediates, including concurrent builds named resume.tex. + const work = await mkdtemp(join(tmpdir(), "arete-tex-")); + try { + await copyFile(texPath, join(work, "resume.tex")); + const args = + kind === "tectonic" + ? ["--untrusted", "resume.tex"] + : kind === "latexmk" + ? [ + "-norc", + "-pdf", + "-no-shell-escape", + "-interaction=nonstopmode", + "-halt-on-error", + "resume.tex" + ] + : ["-no-shell-escape", "-interaction=nonstopmode", "-halt-on-error", "resume.tex"]; + await run(command, args, work); + // Resolve LastPage references on system pdflatex, which does not rerun automatically. + if (kind === "pdflatex") await run(command, args, work); + const compiledPdf = join(work, "resume.pdf"); + await assertSinglePagePdf(compiledPdf); + await copyFile(compiledPdf, pdfPath); return { skipped: false, pdfPath, message: `Wrote ${pdfPath}` }; + } finally { + await rm(work, { recursive: true, force: true }); } +} - const pdflatex = await commandExists("pdflatex"); - if (pdflatex) { - await run( - "pdflatex", - ["-interaction=nonstopmode", "-halt-on-error", basename(texPath)], - dirname(texPath) - ); - await assertSinglePagePdf(pdfPath); - return { skipped: false, pdfPath, message: `Wrote ${pdfPath}` }; +async function findCompiler(options: CompilePdfOptions, texPath: string) { + const tectonic = Object.hasOwn(options, "tectonicPath") + ? options.tectonicPath + : ((await bundledTectonic()) ?? (await findCommand("tectonic"))); + if (tectonic) return { command: tectonic, kind: "tectonic" }; + for (const kind of ["latexmk", "pdflatex"]) { + const command = await findCommand(kind); + if (command) return { command, kind }; } - throw new EnvironmentError( - `Wrote ${texPath}, but no PDF compiler was found. Run pnpm install to install the npm-managed compiler, or install latexmk/pdflatex to produce ${pdfPath}.` + `Wrote ${texPath}, but no PDF compiler was found. Run pnpm install, or install tectonic/latexmk/pdflatex.` ); } -async function compilePdfWithNodeLatex( - nodeCompiler: NodeLatexCompiler, - texPath: string, - pdfPath: string -): Promise { - let result: NodeLatexCompileResult; +async function bundledTectonic(): Promise { + // Resolve only runtime package files; do not execute the dependency's shell-based wrapper. try { - result = await nodeCompiler({ - texFile: texPath, - outputDir: dirname(texPath), - outputFile: pdfPath - }); - } catch (error) { - throw new EnvironmentError(`npm-managed LaTeX compiler failed: ${formatCompilerError(error)}`); - } - - if (result.status !== "success") { - throw new EnvironmentError( - `npm-managed LaTeX compiler failed: ${ - result.stderr ?? result.error ?? `exit code ${result.exitCode ?? "unknown"}` - }` + const runtimeRequire = createRequire(import.meta.resolve("node-latex-compiler")); + const runtime = runtimeRequire.resolve( + `@node-latex-compiler/bin-${process.platform}-${process.arch}/package.json` + ); + const binary = join( + dirname(runtime), + "bin", + process.platform === "win32" ? "tectonic.exe" : "tectonic" ); + await access(binary, constants.X_OK); + return binary; + } catch { + return undefined; } - - await assertSinglePagePdf(pdfPath); - return { skipped: false, pdfPath, message: `Wrote ${pdfPath}` }; } async function assertSinglePagePdf(pdfPath: string): Promise { + let document: PDFDocument; try { - await access(pdfPath, constants.F_OK); - } catch { - throw new EnvironmentError(`LaTeX compiler completed, but ${pdfPath} was not created.`); - } - - const pdf = await readFile(pdfPath, "latin1"); - const pageCount = pdf.match(/\/Type\s*\/Page\b/gu)?.length ?? 0; - if (pageCount > 1) { + document = await PDFDocument.load(await readFile(pdfPath)); + } catch (error) { throw new EnvironmentError( - `PDF must be exactly one page, but ${pdfPath} contains ${pageCount} pages.` + `LaTeX compiler did not produce a readable PDF: ${error instanceof Error ? error.message : String(error)}` ); } + const pageCount = document.getPageCount(); + if (pageCount !== 1) { + throw new EnvironmentError(`PDF must be exactly one page, but contains ${pageCount} pages.`); + } } -async function commandExists(command: string): Promise { - const paths = (process.env.PATH ?? "").split(":").filter(Boolean); - for (const path of paths) { - try { - await access(`${path}/${command}`, constants.X_OK); - return true; - } catch { - // Continue searching PATH. +async function findCommand(command: string): Promise { + const names = process.platform === "win32" ? [`${command}.exe`, command] : [command]; + for (const path of (process.env.PATH ?? "").split(delimiter).filter(Boolean)) { + for (const name of names) { + const candidate = resolve(path, name); + try { + await access(candidate, constants.X_OK); + return candidate; + } catch { + // Continue searching PATH. + } } } - return false; -} - -function formatCompilerError(error: unknown): string { - return error instanceof Error ? error.message : String(error); + return undefined; } async function run(command: string, args: string[], cwd: string): Promise { - await new Promise((resolve, reject) => { - const child = spawn(command, args, { cwd, stdio: "pipe" }); - let stderr = ""; - child.stderr.on("data", (chunk: Buffer) => { - stderr += chunk.toString("utf8"); + await new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, openin_any: "p", openout_any: "p" }, + timeout: 120_000, + killSignal: "SIGKILL" }); - child.on("error", reject); - child.on("close", (code) => { + let output = ""; + const capture = (chunk: Buffer) => { + output = (output + chunk.toString("utf8")).slice(-16_000); + }; + child.stdout.on("data", capture); + child.stderr.on("data", capture); + child.on("error", (error) => { + reject(new EnvironmentError(`Could not run ${command}: ${error.message}`)); + }); + child.on("close", (code, signal) => { if (code === 0) { - resolve(); - return; + resolvePromise(); + } else { + reject( + new EnvironmentError(`${command} failed (${signal ?? code ?? "unknown"}): ${output}`) + ); } - - reject( - new EnvironmentError(`${command} failed with exit code ${code ?? "unknown"}: ${stderr}`) - ); }); }); } diff --git a/src/latex/render.ts b/src/latex/render.ts index aabe57c..1b8b76a 100644 --- a/src/latex/render.ts +++ b/src/latex/render.ts @@ -27,7 +27,6 @@ export function renderLatex(resume: ResumeModel): string { "\\newcommand{\\areteEntry}[4]{\\textbf{#1}\\hfill\\textbf{#2}\\\\\\emph{#3}\\hfill\\emph{#4}\\\\}", "\\AtEndDocument{\\ifnum\\getpagerefnumber{LastPage}>1\\errmessage{Arete one-page rule failed: generated resume exceeds one page}\\fi}", "\\begin{document}", - "\\small", `\\begin{center}{\\huge ${fact(resume.name)}}\\\\`, resume.headline ? `${fact(resume.headline)}\\\\` : "", contactLine(resume), @@ -53,7 +52,11 @@ export function renderLatex(resume: ResumeModel): string { lines.push( itemize([ ...limitFacts(experience.bullets, MAX_EXPERIENCE_BULLETS).map(fact), - technologiesLine(experience.technologies, MAX_EXPERIENCE_TECHNOLOGIES) + technologiesLine( + experience.technologies, + MAX_EXPERIENCE_TECHNOLOGIES, + resume.labels.technologies + ) ]) ); } @@ -67,7 +70,11 @@ export function renderLatex(resume: ResumeModel): string { lines.push( itemize([ ...limitFacts(project.bullets, MAX_PROJECT_BULLETS).map(fact), - technologiesLine(project.technologies, MAX_PROJECT_TECHNOLOGIES) + technologiesLine( + project.technologies, + MAX_PROJECT_TECHNOLOGIES, + resume.labels.technologies + ) ]) ); } @@ -78,7 +85,7 @@ export function renderLatex(resume: ResumeModel): string { for (const education of resume.education) { const date = education.date ? ` \\hfill ${fact(education.date)}` : ""; lines.push( - `\\textbf{${fact(education.credential)}} -- ${fact(education.institution)}${date}` + `\\textbf{${fact(education.credential)}} -- ${fact(education.institution)}${date}\\par` ); } } @@ -127,14 +134,20 @@ function entryHeader( } function section(title: string, bodyLines: string[]): string { - return [`\\section*{${escapeLatex(title)}}`, ...bodyLines].join("\n"); + return [`\\section*{${escapeLatex(title)}}`, ...bodyLines.map((line) => `${line}\\par`)].join( + "\n" + ); } -function technologiesLine(technologies: ResumeFact[], limit: number): string | undefined { +function technologiesLine( + technologies: ResumeFact[], + limit: number, + label: string +): string | undefined { if (technologies.length === 0) { return undefined; } - return `\\textbf{Key Technologies:} ${limitFacts(technologies, limit).map(fact).join(", ")}.`; + return `\\textbf{${escapeLatex(label)}:} ${limitFacts(technologies, limit).map(fact).join(", ")}.`; } function itemize(items: (string | undefined)[]): string { @@ -148,3 +161,28 @@ function itemize(items: (string | undefined)[]): string { function limitFacts(facts: ResumeFact[], limit: number): ResumeFact[] { return facts.slice(0, limit); } + +export function renderWarnings(resume: ResumeModel): string[] { + const warnings: string[] = []; + const check = (facts: ResumeFact[], limit: number, label: string) => { + if (facts.length > limit) { + warnings.push( + `${label}: omitted ${facts.length - limit} item(s) for the one-page budget; review source order.` + ); + } + }; + check(resume.skills, MAX_SKILLS, "Skills"); + resume.experiences.forEach((experience, index) => { + check(experience.bullets, MAX_EXPERIENCE_BULLETS, `Experience ${index + 1} bullets`); + check( + experience.technologies, + MAX_EXPERIENCE_TECHNOLOGIES, + `Experience ${index + 1} technologies` + ); + }); + resume.projects.forEach((project, index) => { + check(project.bullets, MAX_PROJECT_BULLETS, `Project ${index + 1} bullets`); + check(project.technologies, MAX_PROJECT_TECHNOLOGIES, `Project ${index + 1} technologies`); + }); + return warnings; +} diff --git a/src/resume/localization.ts b/src/resume/localization.ts index 95ea343..1675bbb 100644 --- a/src/resume/localization.ts +++ b/src/resume/localization.ts @@ -1,4 +1,5 @@ import type { ResumeLabels } from "./types.js"; +import { ValidationError } from "../diagnostics/errors.js"; const englishLabels: ResumeLabels = { summary: "Summary", @@ -7,17 +8,19 @@ const englishLabels: ResumeLabels = { projects: "Projects", education: "Education", certifications: "Certifications", - languages: "Languages" + languages: "Languages", + technologies: "Key Technologies" }; const portugueseBrazilLabels: ResumeLabels = { summary: "Resumo", - skills: "Competencias", - experience: "Experiencia", + skills: "Competências", + experience: "Experiência", projects: "Projetos", - education: "Formacao", - certifications: "Certificacoes", - languages: "Idiomas" + education: "Formação", + certifications: "Certificações", + languages: "Idiomas", + technologies: "Tecnologias" }; export function normalizeLocale(locale: string | undefined): string { @@ -25,11 +28,12 @@ export function normalizeLocale(locale: string | undefined): string { return "en"; } - if (locale === "pt" || locale.toLowerCase() === "pt-br") { + if (locale.toLowerCase() === "pt" || locale.toLowerCase() === "pt-br") { return "pt-BR"; } - return "en"; + if (locale.toLowerCase() === "en") return "en"; + throw new ValidationError(`Unsupported locale "${locale}". Supported locales: en, pt-BR.`); } export function labelsForLocale(locale: string): ResumeLabels { @@ -50,12 +54,13 @@ function formatDate(value: string, locale: string): string { return normalizeLocale(locale) === "pt-BR" ? "Atual" : "Present"; } - const match = /^(\d{4})-(\d{2})$/.exec(value); + const match = /^(\d{4})-(0[1-9]|1[0-2])$/.exec(value); if (!match?.[1] || !match[2]) { return value; } - const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, 1)); + const date = new Date(0); + date.setUTCFullYear(Number(match[1]), Number(match[2]) - 1, 1); return new Intl.DateTimeFormat(normalizeLocale(locale), { year: "numeric", month: "short", diff --git a/src/resume/types.ts b/src/resume/types.ts index 42dc74e..ce7acf1 100644 --- a/src/resume/types.ts +++ b/src/resume/types.ts @@ -36,6 +36,7 @@ export type ResumeLanguage = { }; export type ResumeLabels = { + technologies: string; summary: string; skills: string; experience: string; diff --git a/tests/e2e/cli-build.test.ts b/tests/e2e/cli-build.test.ts index bba49bc..5b30a6b 100644 --- a/tests/e2e/cli-build.test.ts +++ b/tests/e2e/cli-build.test.ts @@ -1,10 +1,24 @@ -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawn } from "node:child_process"; import { describe, expect, it } from "vitest"; describe("CLI build", () => { + it("does not overwrite an existing candidate file on init", async () => { + const out = await mkdtemp(join(tmpdir(), "arete-init-")); + const source = join(out, "candidate.md"); + try { + await writeFile(source, "private candidate facts"); + await expect( + run("node", ["--import", "tsx", "src/cli/main.ts", "init", "--output", source]) + ).rejects.toThrow("EEXIST"); + expect(await readFile(source, "utf8")).toBe("private candidate facts"); + } finally { + await rm(out, { recursive: true, force: true }); + } + }); + it("writes resume.tex and can skip PDF compilation", async () => { const out = await mkdtemp(join(tmpdir(), "arete-")); try { @@ -20,7 +34,7 @@ describe("CLI build", () => { ]); const tex = await readFile(join(out, "resume.tex"), "utf8"); - expect(tex).toContain("Jordan Avery"); + expect(tex).toContain("Example Candidate"); expect(tex).toContain("\\section*{Experience}"); } finally { await rm(out, { recursive: true, force: true }); diff --git a/tests/integration/candidate-pipeline.test.ts b/tests/integration/candidate-pipeline.test.ts index ea35461..27b698a 100644 --- a/tests/integration/candidate-pipeline.test.ts +++ b/tests/integration/candidate-pipeline.test.ts @@ -10,7 +10,7 @@ describe("candidate pipeline", () => { const candidate = parseCandidateMarkdown(markdown, "examples/candidate.example.md"); const resume = composeResume(candidate); - expect(resume.name.text).toBe("Jordan Avery"); + expect(resume.name.text).toBe("Example Candidate"); expect(resume.experiences).toHaveLength(2); expect(resume.experiences[0]!.bullets[0]!.sources[0]!.section).toBe("Experience"); }); diff --git a/tests/integration/parser-regressions.test.ts b/tests/integration/parser-regressions.test.ts new file mode 100644 index 0000000..96f3f9a --- /dev/null +++ b/tests/integration/parser-regressions.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import { parseCandidateMarkdown } from "../../src/candidate/parser.js"; +import { composeResume } from "../../src/resume/compose.js"; +import { renderLatex, renderWarnings } from "../../src/latex/render.js"; + +const minimal = + "---\nschemaVersion: 1\n---\n\n## Identity\nName: Example Candidate\nEmail: candidate@example.com\n"; +const experience = + "\n## Experience\n### Engineer | Example Employer | 2021-03 - Present | Remote\n- Built APIs.\n"; + +describe("candidate parsing regressions", () => { + it("does not turn bullet-shaped STAR notes into resume claims", () => { + const source = + minimal + + experience + + "STAR evidence notes:\n- Unconfirmed: reduced errors by 99%.\nTechnologies: TypeScript\n"; + const latex = renderLatex(composeResume(parseCandidateMarkdown(source, "candidate.md"))); + expect(latex).not.toContain("99"); + expect(latex).toContain("TypeScript"); + }); + + it("reports budget omissions and preserves selected source order", () => { + const source = minimal + experience + "- Second.\n- Third.\n- Fourth.\n- Fifth.\n"; + const resume = composeResume(parseCandidateMarkdown(source, "candidate.md")); + expect(renderWarnings(resume)).toEqual([ + "Experience 1 bullets: omitted 1 item(s) for the one-page budget; review source order." + ]); + expect(renderLatex(resume)).toContain("Fourth."); + expect(renderLatex(resume)).not.toContain("Fifth."); + }); + + it.each([ + "\n## Experiance\n", + "\n## Projects\n### Project | 2022 | Unexpected\n- Built it.\n", + "\n## Education\n### Degree | | 2022\n", + "\n## Certifications\n- | Issuer | 2022\n", + "\n## Languages\n- English\n" + ])("rejects malformed records instead of silently dropping or shifting facts", (section) => { + expect(() => parseCandidateMarkdown(minimal + section, "bad.md")).toThrow(); + }); + it("rejects executable front matter before running it", () => { + const payload = '---javascript\n(() => { throw new Error("EXECUTED"); })()\n---\n'; + expect(() => parseCandidateMarkdown(payload, "unsafe.md")).toThrow( + "expected YAML front matter" + ); + }); + + it.each([ + minimal.replace("schemaVersion: 1", "schemaVersion: 2"), + minimal.replace("schemaVersion: 1", "schemaVersion: ["), + minimal.replace("schemaVersion: 1", '!!js/function "function() {}"'), + minimal.replace("schemaVersion: 1", "schemaVersion: 1\nschemaVersion: 1"), + "---\nschemaVersion: 1", + minimal.replace("candidate@example.com", "not-an-email"), + minimal.replace("schemaVersion: 1", "schemaVersion: 1\nlocale: fr-FR") + ])("rejects invalid metadata and identity", (source) => { + expect(() => parseCandidateMarkdown(source, "bad.md")).toThrow(); + }); + + it.each(["\n", "\r\n"])("preserves original file line numbers with %j newlines", (newline) => { + const input = (minimal + experience).replaceAll("\n", newline); + const candidate = parseCandidateMarkdown(input, "candidate.md"); + const lines = input.split(/\r?\n/); + expect(lines[candidate.identity.name.source.line - 1]).toBe("Name: Example Candidate"); + const bullet = composeResume(candidate).experiences[0]!.bullets[0]!; + expect(lines[bullet.sources[0]!.line - 1]).toBe("- Built APIs."); + }); + + it("preserves optional blank pipe fields instead of shifting dates into issuers", () => { + const candidate = parseCandidateMarkdown( + minimal + "\n## Certifications\n- Example Certification | | 2022\n", + "candidate.md" + ); + expect(candidate.certifications[0]!.issuer).toBeUndefined(); + expect(candidate.certifications[0]!.date!.value).toBe("2022"); + }); + + it("allows missing optional experience location", () => { + const candidate = parseCandidateMarkdown( + minimal + experience.replace(" | Remote", " | "), + "candidate.md" + ); + expect(candidate.experiences[0]!.location).toBeUndefined(); + }); + + it.each([ + experience.replace("Engineer | Example Employer", " | Example Employer"), + experience.replace("Engineer | Example Employer", "Engineer | "), + experience.replace("2021-03", "2021-13"), + experience.replace("2021-03", "2021-00"), + experience.replace("Present", "2020-12"), + experience.replace("Remote", "Remote | Unexpected") + ])("rejects shifted fields and invalid dates", (section) => { + expect(() => parseCandidateMarkdown(minimal + section, "bad.md")).toThrow(); + }); + + it("rejects duplicate sections and identity keys rather than dropping facts", () => { + expect(() => + parseCandidateMarkdown(minimal + "\n## Identity\nName: Duplicate\n", "bad.md") + ).toThrow("Duplicate section"); + expect(() => parseCandidateMarkdown(minimal + "Name: Duplicate\n", "bad.md")).toThrow( + "Duplicate identity" + ); + }); + + it("preserves wrapped summary text", () => { + const candidate = parseCandidateMarkdown( + minimal + "\n## Summary\nBuilt APIs\nand maintained tests.\n", + "candidate.md" + ); + expect(candidate.summary!.value).toBe("Built APIs and maintained tests."); + }); + + it("localizes all headings and preserves separate list paragraphs", () => { + const source = + minimal + + experience + + "Technologies: TypeScript\n\n## Languages\n- English: Fluent\n- Portuguese: Fluent\n"; + const latex = renderLatex( + composeResume(parseCandidateMarkdown(source, "candidate.md"), { locale: "pt-BR" }) + ); + expect(latex).toContain("Experiência"); + expect(latex).toContain("\\textbf{Tecnologias:}"); + expect(latex).toContain("English: Fluent\\par\nPortuguese: Fluent\\par"); + }); +}); diff --git a/tests/pdf-smoke.ts b/tests/pdf-smoke.ts new file mode 100644 index 0000000..6372093 --- /dev/null +++ b/tests/pdf-smoke.ts @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { PDFDocument } from "pdf-lib"; + +const exec = promisify(execFile); +const root = await mkdtemp(join(tmpdir(), "arete-pdf-smoke-")); +const env = { ...process.env }; +delete env.ARETE_SKIP_PDF_COMPILE; + +try { + // Real concurrent builds also guard against shared compiler intermediates. + await Promise.all( + ["en", "pt-BR"].map(async (locale) => { + const out = join(root, `${locale} output $() quoted`); + await exec( + process.execPath, + [ + "dist/src/cli/main.js", + "build", + "--source", + "examples/candidate.example.md", + "--out", + out, + "--locale", + locale + ], + { env, timeout: 150_000 } + ); + const path = join(out, "resume.pdf"); + const pdf = await PDFDocument.load(await readFile(path)); + assert.equal(pdf.getPageCount(), 1); + assert.ok(Math.abs(pdf.getPage(0).getWidth() - 595.28) < 1); + assert.ok(Math.abs(pdf.getPage(0).getHeight() - 841.89) < 1); + const { stdout } = await exec("pdftotext", [path, "-"], { encoding: "utf8" }); + const text = stdout.normalize("NFKC").replace(/\s+/gu, " "); + const headings = + locale === "en" + ? [ + "Summary", + "Skills", + "Experience", + "Projects", + "Education", + "Certifications", + "Languages" + ] + : [ + "Resumo", + "Competências", + "Experiência", + "Projetos", + "Formação", + "Certificações", + "Idiomas" + ]; + let position = -1; + for (const heading of ["Example Candidate", "candidate@example.com", ...headings]) { + const next = text.indexOf(heading, position + 1); + assert.ok(next > position, `Missing or out-of-order text: ${heading}`); + position = next; + } + for (const expected of [ + "Example Employer A", + "Example Employer B", + "TypeScript", + "AWS Certified Cloud Practitioner", + "English: Native", + "Portuguese: Professional working proficiency" + ]) { + assert.ok(text.includes(expected), `Missing extracted fact: ${expected}`); + } + assert.ok(!text.includes("STAR evidence notes")); + console.log( + `${locale}: one-page A4 PDF; contact, section order, and fact extraction passed.` + ); + }) + ); + + const oversized = join(root, "oversized.md"); + await writeFile( + oversized, + [ + "---", + "schemaVersion: 1", + "---", + "## Identity", + "Name: Example Candidate", + "Email: candidate@example.com", + "## Experience", + ...Array.from( + { length: 40 }, + (_, i) => + `### Engineer ${i + 1} | Example Employer | 2020 - 2021 | Remote\n- Built an example service.` + ) + ].join("\n") + ); + const out = join(root, "overflow"); + await assert.rejects( + exec(process.execPath, ["dist/src/cli/main.js", "build", "--source", oversized, "--out", out], { + env, + timeout: 150_000 + }), + /one.page/iu + ); + await assert.rejects(readFile(join(out, "resume.pdf"))); + console.log("Overflow: real multi-page compilation rejected without publishing a PDF."); +} finally { + await rm(root, { recursive: true, force: true }); +} diff --git a/tests/unit/compile.test.ts b/tests/unit/compile.test.ts index 673d707..194de55 100644 --- a/tests/unit/compile.test.ts +++ b/tests/unit/compile.test.ts @@ -1,142 +1,121 @@ -import { chmod, mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join, relative } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { join } from "node:path"; +import { PDFDocument } from "pdf-lib"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { compilePdf } from "../../src/latex/compile.js"; -const originalPath = process.env.PATH; -const originalSkipPdfCompile = process.env.ARETE_SKIP_PDF_COMPILE; +let root: string; +let texPath: string; -describe("compilePdf", () => { - afterEach(() => { - process.env.PATH = originalPath; - if (originalSkipPdfCompile === undefined) { - delete process.env.ARETE_SKIP_PDF_COMPILE; - } else { - process.env.ARETE_SKIP_PDF_COMPILE = originalSkipPdfCompile; - } - }); - - it("can skip PDF compilation by environment flag", async () => { - process.env.ARETE_SKIP_PDF_COMPILE = "1"; - - await expect(compilePdf("out/resume.tex")).resolves.toEqual({ - skipped: true, - pdfPath: "out/resume.pdf", - message: "PDF compilation skipped by ARETE_SKIP_PDF_COMPILE=1." - }); - }); +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "arete-compile-")); + texPath = join(root, "resume.tex"); + await writeFile(texPath, "example latex"); + vi.stubEnv("ARETE_SKIP_PDF_COMPILE", ""); +}); +afterEach(async () => { + vi.unstubAllEnvs(); + await rm(root, { recursive: true, force: true }); +}); - it("prefers the npm-managed LaTeX compiler", async () => { - const root = await mkdtemp(join(tmpdir(), "arete-compile-")); - try { - const out = join(root, "out"); - await mkdir(out); +async function fakeCompiler(name: string, body: string): Promise { + const bin = join(root, "bin"); + await mkdir(bin, { recursive: true }); + const file = join(bin, name); + await writeFile(file, `#!${process.execPath}\n${body}\n`); + await chmod(file, 0o755); + return file; +} - const texPath = join(out, "resume.tex"); - await writeFile(texPath, "\\documentclass{article}\\begin{document}Test\\end{document}\n"); +async function pdfBytes(pages = 1): Promise { + const pdf = await PDFDocument.create(); + for (let i = 0; i < pages; i++) pdf.addPage(); + return pdf.save({ addDefaultPage: false }); +} - const result = await compilePdf(relative(process.cwd(), texPath), { - nodeCompiler: async (config) => { - expect(config.texFile).toBe(relative(process.cwd(), texPath)); - expect(config.outputDir).toBe(relative(process.cwd(), out)); - expect(config.outputFile).toBe(relative(process.cwd(), join(out, "resume.pdf"))); - await writeFile(config.outputFile, onePagePdf()); - return { status: "success", pdfPath: config.outputFile }; - } - }); +function writePdfScript(bytes: Uint8Array): string { + return `require('node:fs').writeFileSync('resume.pdf', Buffer.from('${Buffer.from(bytes).toString("base64")}', 'base64'));`; +} - expect(result.skipped).toBe(false); - expect(result.pdfPath).toBe(relative(process.cwd(), join(out, "resume.pdf"))); - } finally { - await rm(root, { recursive: true, force: true }); - } +describe("compilePdf", () => { + it("skips compilation and removes a stale PDF", async () => { + vi.stubEnv("ARETE_SKIP_PDF_COMPILE", "1"); + await writeFile(join(root, "resume.pdf"), "stale private resume"); + expect((await compilePdf(texPath)).skipped).toBe(true); + await expect(readFile(join(root, "resume.pdf"))).rejects.toThrow(); }); - it("reports npm-managed compiler failures", async () => { - await expect( - compilePdf("out/resume.tex", { - nodeCompiler: () => - Promise.resolve({ - status: "failed", - stderr: "bad latex" - }) - }) - ).rejects.toThrow("npm-managed LaTeX compiler failed: bad latex"); + it("runs Tectonic in a private directory without interpreting shell metacharacters", async () => { + const out = join(root, 'output $(touch injected) `touch injected` " quoted'); + await mkdir(out); + const input = join(out, "resume.tex"); + await writeFile(input, "example latex"); + const binary = await fakeCompiler( + "tectonic", + ` + const assert = require('node:assert/strict'); + assert.deepEqual(process.argv.slice(2), ['--untrusted', 'resume.tex']); + assert.equal(require('node:fs').readFileSync('resume.tex', 'utf8'), 'example latex'); + ${writePdfScript(await pdfBytes())} + ` + ); + const result = await compilePdf(input, { tectonicPath: binary }); + expect(result.skipped).toBe(false); + expect((await PDFDocument.load(await readFile(result.pdfPath))).getPageCount()).toBe(1); + await expect(readFile(join(out, "injected"))).rejects.toThrow(); }); - it("reports npm-managed compiler exceptions", async () => { - await expect( - compilePdf("out/resume.tex", { - nodeCompiler: () => Promise.reject(new Error("compiler unavailable")) - }) - ).rejects.toThrow("npm-managed LaTeX compiler failed: compiler unavailable"); + it.each([0, 2])("rejects a compressed PDF with %i pages", async (pages) => { + const binary = await fakeCompiler("tectonic", writePdfScript(await pdfBytes(pages))); + await expect(compilePdf(texPath, { tectonicPath: binary })).rejects.toThrow("exactly one page"); + await expect(readFile(join(root, "resume.pdf"))).rejects.toThrow(); }); - it("passes only the tex filename to system fallbacks from a relative output directory", async () => { - const root = await mkdtemp(join(tmpdir(), "arete-compile-")); - try { - const bin = join(root, "bin"); - const out = join(root, "relative-out"); - await mkdir(bin); - await mkdir(out); - - const latexmk = join(bin, "latexmk"); - await writeFile( - latexmk, - [ - "#!/usr/bin/env sh", - "for arg do tex_file=$arg; done", - 'test "$tex_file" = "resume.tex" || exit 2', - 'printf "%s" "/Type /Page" > "${tex_file%.tex}.pdf"' - ].join("\n"), - "utf8" - ); - await chmod(latexmk, 0o755); - - const texPath = join(out, "resume.tex"); - await writeFile(texPath, "\\documentclass{article}\\begin{document}Test\\end{document}\n"); - process.env.PATH = `${bin}:${originalPath ?? ""}`; + it.each(["require('node:fs').writeFileSync('resume.pdf', 'not a PDF');", ""])( + "rejects corrupt or missing PDF output", + async (body) => { + const binary = await fakeCompiler("tectonic", body); + await expect(compilePdf(texPath, { tectonicPath: binary })).rejects.toThrow("readable PDF"); + } + ); - const result = await compilePdf(relative(process.cwd(), texPath), { nodeCompiler: null }); + it("reports compiler stdout diagnostics and clears stale output", async () => { + await writeFile(join(root, "resume.pdf"), "old resume"); + const binary = await fakeCompiler("tectonic", "console.log('bad latex'); process.exit(1);"); + await expect(compilePdf(texPath, { tectonicPath: binary })).rejects.toThrow("bad latex"); + await expect(readFile(join(root, "resume.pdf"))).rejects.toThrow(); + }); - expect(result.skipped).toBe(false); - expect(result.pdfPath).toBe(relative(process.cwd(), join(out, "resume.pdf"))); - } finally { - await rm(root, { recursive: true, force: true }); - } + it("reports process startup errors", async () => { + await expect(compilePdf(texPath, { tectonicPath: join(root, "missing") })).rejects.toThrow( + "Could not run" + ); }); - it("reports when neither npm nor system compilers are available", async () => { - process.env.PATH = ""; + it.each(["latexmk", "pdflatex"])("uses the %s fallback when Tectonic is absent", async (name) => { + await fakeCompiler( + name, + ` + const assert = require('node:assert/strict'); + assert.ok(process.argv.includes('-no-shell-escape')); + assert.equal(process.argv.at(-1), 'resume.tex'); + ${writePdfScript(await pdfBytes())} + ` + ); + vi.stubEnv("PATH", join(root, "bin")); + expect((await compilePdf(texPath, { tectonicPath: null })).skipped).toBe(false); + }); - await expect(compilePdf("out/resume.tex", { nodeCompiler: null })).rejects.toThrow( + it("reports when no compilers are available", async () => { + vi.stubEnv("PATH", ""); + await expect(compilePdf(texPath, { tectonicPath: null })).rejects.toThrow( "no PDF compiler was found" ); }); - it("rejects PDFs that exceed one page", async () => { - const root = await mkdtemp(join(tmpdir(), "arete-compile-")); - try { - const out = join(root, "out"); - await mkdir(out); - const texPath = join(out, "resume.tex"); - await writeFile(texPath, "\\documentclass{article}\\begin{document}Test\\end{document}\n"); - - await expect( - compilePdf(relative(process.cwd(), texPath), { - nodeCompiler: async (config) => { - await writeFile(config.outputFile, "/Type /Page\n/Type /Page\n"); - return { status: "success", pdfPath: config.outputFile }; - } - }) - ).rejects.toThrow("PDF must be exactly one page"); - } finally { - await rm(root, { recursive: true, force: true }); - } + it("rejects non-TeX input without deleting the input", async () => { + await expect(compilePdf(texPath.replace(".tex", ".md"))).rejects.toThrow(".tex extension"); + expect(await readFile(texPath, "utf8")).toBe("example latex"); }); }); - -function onePagePdf(): string { - return "/Type /Page\n"; -} diff --git a/tests/unit/localization.test.ts b/tests/unit/localization.test.ts index 878a47b..51387d3 100644 --- a/tests/unit/localization.test.ts +++ b/tests/unit/localization.test.ts @@ -9,11 +9,11 @@ describe("localization", () => { it("normalizes supported locales", () => { expect(normalizeLocale(undefined)).toBe("en"); expect(normalizeLocale("pt-br")).toBe("pt-BR"); - expect(normalizeLocale("fr-FR")).toBe("en"); + expect(() => normalizeLocale("fr-FR")).toThrow("Unsupported locale"); }); it("provides localized labels and date ranges", () => { - expect(labelsForLocale("pt-BR").experience).toBe("Experiencia"); + expect(labelsForLocale("pt-BR").experience).toBe("Experiência"); expect(formatDateRange("2021-03", "Present", "en")).toContain("Present"); expect(formatDateRange("2021-03", "Present", "pt-BR")).toContain("Atual"); expect(formatDateRange("Spring 2021", "Present", "en")).toBe("Spring 2021 - Present");