diff --git a/.agents/skills/debug-linkedin-sample-pdfs/SKILL.md b/.agents/skills/debug-linkedin-sample-pdfs/SKILL.md new file mode 100644 index 0000000..19aa1f4 --- /dev/null +++ b/.agents/skills/debug-linkedin-sample-pdfs/SKILL.md @@ -0,0 +1,91 @@ +--- +name: debug-linkedin-sample-pdfs +description: Use automatically when working in this repo and the user or developer discusses a specific PDF file/path, sample PDF, parser JSON, generated JSON output, baseline JSON, or whether JSON accurately reflects the original PDF. Also use for LinkedIn PDF extraction bugs, parser misses, section or column errors, unpdf/pdfplumber/Poppler comparisons, source evidence bundles, and sample completeness audits. +--- + +# Debug LinkedIn Sample PDFs + +Use source-derived artifacts as the authority. Parser JSON and sample baselines are useful regression outputs, but they are not proof of what the PDF contains. + +Default to the repo-local `samples/` directory when the user does not provide a PDF path, sample directory, or specific parser symptom. Do not ask which PDF to inspect before running the default repo-wide sample pass. + +## Workflow + +1. If `samples/` contains PDFs but no JSON files yet, generate initial JSON before checking: + + ```bash + pnpm run samples:verify + ``` + + The generated JSON is not golden output. Treat it as suspect parser output that exists only to make coverage, diffing, and review workflows possible. Debug questionable values against the original PDFs with CLI PDF tools and the scripts in `scripts/`. + +2. Generate evidence before diagnosing. When no PDF path or sample directory is provided, inspect the repo-local `samples/` directory by default: + + ```bash + pnpm run source:inspect + ``` + + For a specific PDF: + + ```bash + pnpm run source:inspect -- + ``` + + For a custom output folder: + + ```bash + pnpm run source:inspect -- --output .debug/ + ``` + +3. Inspect source artifacts first: + - `poppler.layout.txt` for readable columns and visible line order. + - `overlay.html` for visual page geometry and text box placement. + - `unpdf.items.json` for the extractor input the parser actually receives. + - `pdfplumber.words.json` for independent word geometry. + - `parser-lines.json` and `parser.structural.json` for parser reconstruction. + - `parser-output.json` for current parser output, including `warnings` and `diagnostics`. + - `parser-source-coverage.json` or `baseline-source-coverage.json` for section-aware coverage prompts. + - `fieldMismatchOutputMatches` in coverage reports for high-confidence field-role mistakes inside a section, such as experience location or duration lines captured as descriptions. + +4. Decide whether the failure is source extraction, layout reconstruction, section assignment, field parsing, or fixture expectation drift. Cite artifact filenames and source lines/items when explaining the diagnosis. + +5. If changing parser behavior, add focused unit tests for the failing shape. Use a small synthetic text item or structural-line fixture unless the bug requires an end-to-end PDF fixture. + +6. Run the repo-required verification after changes: + + ```bash + pnpm run check + pnpm run samples:verify + ``` + + `samples/` is local and gitignored, so `samples:verify` is intentionally separate from the default check. If no JSON files are present, `samples:verify` writes initial suspect JSON baselines before checking. After `samples:verify`, report its result and make no further changes from that output unless the user explicitly asks. + +## Required Final Report + +After using this skill, clearly document: + +- Which PDF files produced incorrect or incomplete parser output, with the source evidence used to identify each problem. +- What code changes specifically address each failure case. Tie each fix to the PDF symptom it resolves rather than describing changes only by file name. +- How the generated JSON should appear different after the changes, including the fields or sections expected to be added, removed, moved, or normalized. +- Any `warnings` and `diagnostics` present in generated output JSON, including warnings that remain after the fix. +- Any generated JSON that remains suspect and still needs source-level review. Generated JSON is never golden output just because it was written by the CLI. + +## Batch Audit + +Use the section-aware audit to scan all samples or compare a candidate fix: + +```bash +pnpm run samples:audit-coverage -- --samples samples/ +``` + +Use strict mode when validating the local sample corpus: + +```bash +pnpm run samples:audit-coverage -- --samples samples/ --strict +``` + +Strict mode fails on unmatched source, loose source matches, untraced output, high-confidence field mismatches, and section warnings. Treat `crossSectionOutputMatches` as informational review prompts: the output was traced to source text, but not in the section inferred from its JSON path. Treat `fieldMismatchOutputMatches` as stronger evidence: the output traces to the right broad section but the source line has an inferred field role that conflicts with the JSON path. Verify suspicious rows against `poppler.layout.txt`, `overlay.html`, and source geometry before changing parser code. + +## Artifact Reference + +Read [references/source-evidence.md](references/source-evidence.md) when you need artifact meanings, coverage-report interpretation, or a triage checklist. diff --git a/.agents/skills/debug-linkedin-sample-pdfs/agents/openai.yaml b/.agents/skills/debug-linkedin-sample-pdfs/agents/openai.yaml new file mode 100644 index 0000000..6f0eeaf --- /dev/null +++ b/.agents/skills/debug-linkedin-sample-pdfs/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: 'Debug LinkedIn Sample PDFs' + short_description: 'Debug PDF and parser JSON output' + default_prompt: 'Use $debug-linkedin-sample-pdfs when I mention a specific PDF file, sample directory, parser symptom, or generated JSON output.' + +policy: + allow_implicit_invocation: true diff --git a/.agents/skills/debug-linkedin-sample-pdfs/references/source-evidence.md b/.agents/skills/debug-linkedin-sample-pdfs/references/source-evidence.md new file mode 100644 index 0000000..3e12cc5 --- /dev/null +++ b/.agents/skills/debug-linkedin-sample-pdfs/references/source-evidence.md @@ -0,0 +1,42 @@ +# Source Evidence Reference + +## Artifact Inventory + +- `manifest.json`: Bundle index and tool failures. Check this first. +- `pdfinfo.txt`: Page count, producer, metadata, encryption, and page size. +- `pdffonts.txt`: Embedded font data; useful for odd glyph or spacing behavior. +- `pdfimages.txt`: Confirms whether visible content is text or image-backed. +- `poppler.layout.txt`: Best first read for visible line order and column breaks. +- `poppler.raw.txt`: Poppler extraction without layout preservation. +- `poppler.bbox.xhtml`: Poppler word and block coordinates. +- `pdfplumber.words.json`: Independent word-level geometry with font and size. +- `pdfplumber.chars.json`: Character-level geometry for split glyphs, ligatures, or wrapped tokens. +- `unpdf.items.json`: Raw unpdf/PDF.js text items before parser normalization. +- `parser.structural.json`: Parser debug export with detected layout, raw text, text items, and structural lines. +- `parser-lines.json`: Reconstructed structural lines consumed by section parsers. +- `parser-output.json`: Current parser output with `rawText`, `warnings`, and `diagnostics`. +- `source-segments.json`: Poppler layout text split into inferred source sections. +- `parser-source-coverage.json`: Source coverage of the current parser output. +- `baseline-source-coverage.json`: Source coverage of the adjacent sample JSON baseline, when present. +- `page-*.png`: Rendered pages used by the overlay. +- `overlay.html`: Rendered pages with unpdf text item boxes overlaid. + +## Coverage Signals + +- `unmatchedSourceSegments`: PDF text in an inferred source section that did not appear in same-section JSON. Verify before changing code; common causes are section inference mistakes, parser omissions, or intentionally unmodeled fields. +- `looseSourceMatches`: Source matched only by token containment, not exact normalized text. Use these to find punctuation, spacing, URL wrapping, or normalization issues. +- `crossSectionOutputMatches`: JSON values traced to PDF text in a different inferred section. Treat these as review prompts for section inference or intentional duplicated content, not as untraced output failures. +- `fieldMismatchOutputMatches`: JSON values traced to the same inferred section but to a source line with a conflicting field role. These are high-confidence prompts for values like standalone experience locations or dates being captured as descriptions. +- `untracedOutputValues`: JSON values not traceable to same-section PDF text. These can reveal hallucinated/misassigned fields, normalized URLs, derived date fields, or text assigned to the wrong section. +- `sectionWarnings`: Parser warnings from generated or baseline JSON. Treat `section_parse_warning` as higher priority than heuristic coverage noise. +- `warnings` and `diagnostics`: Parser self-reporting in output JSON. Include these in the investigation notes even when the visible source text looks correct. + +## Triage Checklist + +1. Confirm visible truth in `poppler.layout.txt` and `overlay.html`. +2. Compare Poppler, pdfplumber, and unpdf geometry if text is missing or split unexpectedly. +3. Compare `unpdf.items.json` to `parser-lines.json` when columns, page transitions, or wrapped lines are wrong. +4. Check `fieldMismatchOutputMatches` before accepting section coverage as sufficient; a same-section match can still be a field-level parse error. +5. Compare `parser-lines.json` to `parser-output.json` when parser input is correct but fields are wrong. +6. Use `baseline-source-coverage.json` only to audit fixture completeness; do not treat the baseline as source truth. +7. Keep generated artifacts in `.debug/` for ad hoc investigation and `.debug-dist/` for reproducible script output. diff --git a/.attw.json b/.attw.json new file mode 100644 index 0000000..dba6755 --- /dev/null +++ b/.attw.json @@ -0,0 +1,3 @@ +{ + "profile": "node16" +} diff --git a/.claude/CLI_IMPLEMENTATION_SUMMARY.md b/.claude/CLI_IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index 3329a5f..0000000 --- a/.claude/CLI_IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -1,214 +0,0 @@ -# 🚀 CLI Implementation Summary - -## ✅ **Implementação Completa da CLI** - -Criada uma interface de linha de comando completa para o LinkedIn PDF Parser que permite usar a biblioteca diretamente via npm package. - ---- - -## 📁 **Arquivos Criados** - -### 1. **CLI Principal** -- `bin/cli.js` - Executable CLI script com shebang -- Configuração no `package.json`: - ```json - { - "bin": { - "linkedin-pdf-parser": "./bin/cli.js" - }, - "files": ["dist", "bin"] - } - ``` - -### 2. **Documentação** -- `CLI_USAGE.md` - Guia completo de uso da CLI -- `INSTALLATION_GUIDE.md` - Guia de instalação e troubleshooting -- `demo-cli.sh` - Script de demonstração -- Atualização do `README.md` com seção CLI - ---- - -## 🛠️ **Funcionalidades Implementadas** - -### **Uso Básico** -```bash -linkedin-pdf-parser /path/to/resume.pdf -``` - -### **Opções Disponíveis** -- `--compact` - JSON compacto (sem formatação) -- `--raw-text` - Incluir texto bruto extraído -- `--help, -h` - Ajuda -- `--pretty` - JSON formatado (padrão) - -### **Instalação e Uso** -```bash -# Instalação global -npm install -g @zalko/linkedin-parser - -# Uso direto (sem instalação) -npx @zalko/linkedin-parser resume.pdf - -# Instalação local em projeto -npm install @zalko/linkedin-parser -npx linkedin-pdf-parser resume.pdf -``` - ---- - -## 🎯 **Casos de Uso Implementados** - -### 1. **Parsing Simples** -```bash -linkedin-pdf-parser resume.pdf > profile.json -``` - -### 2. **Processamento em Lote** -```bash -for pdf in *.pdf; do - linkedin-pdf-parser "$pdf" > "${pdf%.pdf}.json" -done -``` - -### 3. **Pipeline com jq** -```bash -linkedin-pdf-parser resume.pdf | jq '.profile.name' -linkedin-pdf-parser resume.pdf | jq '.profile.contact.email' -linkedin-pdf-parser resume.pdf | jq '.profile.experience[].company' -``` - -### 4. **Tratamento de Erros** -- Arquivo não encontrado -- Formato não-PDF -- Erro de parsing -- Códigos de saída apropriados (0 = sucesso, 1 = erro) - ---- - -## 🧪 **Testes Realizados** - -### **✅ Casos de Sucesso** -```bash -# Profile.pdf -node bin/cli.js "/Users/arkady/Downloads/Profile.pdf" --compact -# Output: {"profile":{"name":"Arkady Zalkowitsch",...}} - -# Profile (1).pdf -node bin/cli.js "/Users/arkady/Downloads/Profile (1).pdf" -# Output: Formatted JSON with Thamiris Zalkowitsch data - -# Profile (2).pdf -node bin/cli.js "/Users/arkady/Downloads/Profile (2).pdf" --compact -# Output: {"profile":{"name":"Daniel Braga",...}} -``` - -### **✅ Casos de Erro** -```bash -# Arquivo não encontrado -node bin/cli.js non-existent.pdf -# Output: Error: File not found: /path/to/non-existent.pdf - -# Arquivo não-PDF -node bin/cli.js package.json -# Output: Error: File must be a PDF: /path/to/package.json -``` - -### **✅ Help e Opções** -```bash -node bin/cli.js --help -# Output: Usage instructions and examples -``` - ---- - -## 📊 **Resultados dos 3 PDFs** - -| PDF | Nome | Email | Status | -|-----|------|-------|--------| -| **Profile.pdf** | Arkady Zalkowitsch | arkadyzalko@gmail.com | ✅ Sucesso | -| **Profile (1).pdf** | Thamiris Zalkowitsch | thamizalko@gmail.com | ✅ Sucesso | -| **Profile (2).pdf** | Daniel Braga | daniel.hba@gmail.com | ✅ Sucesso | - ---- - -## 🔧 **Características Técnicas** - -### **Robustez** -- ✅ Validação de entrada (arquivo existe, é PDF) -- ✅ Tratamento de erros com mensagens claras -- ✅ Códigos de saída padronizados -- ✅ Output para stdout, erros para stderr - -### **Flexibilidade** -- ✅ Múltiplas opções de formatação -- ✅ Suporte a pipes e redirecionamento -- ✅ Compatível com ferramentas Unix (jq, grep, etc.) - -### **Usabilidade** -- ✅ Help integrado com exemplos -- ✅ Documentação completa -- ✅ Exemplos práticos de uso - -### **Compatibilidade** -- ✅ Node.js 18+ -- ✅ npm/npx/yarn/pnpm -- ✅ Unix/Linux/macOS/Windows -- ✅ ES Modules - ---- - -## 💡 **Exemplos de Uso Real** - -### **Análise de Candidatos** -```bash -# Processar currículos de candidatos -for resume in candidate-resumes/*.pdf; do - echo "Processing: $resume" - linkedin-pdf-parser "$resume" | jq '{ - name: .profile.name, - email: .profile.contact.email, - skills: .profile.top_skills, - experience_count: (.profile.experience | length) - }' > "processed/$(basename "$resume" .pdf).json" -done -``` - -### **Extração de Dados Específicos** -```bash -# Extrair lista de empresas -linkedin-pdf-parser resume.pdf | jq -r '.profile.experience[].company' | sort -u - -# Extrair skills técnicas -linkedin-pdf-parser resume.pdf | jq -r '.profile.top_skills[]' | grep -i "javascript\|python\|react" - -# Verificar experiência mínima -exp_count=$(linkedin-pdf-parser resume.pdf | jq '.profile.experience | length') -if [ "$exp_count" -ge 3 ]; then - echo "Candidate has sufficient experience" -fi -``` - -### **Integração com Sistemas** -```bash -# Upload para banco de dados -profile_json=$(linkedin-pdf-parser resume.pdf --compact) -curl -X POST -H "Content-Type: application/json" \ - -d "$profile_json" \ - https://api.hr-system.com/candidates -``` - ---- - -## 🎉 **Conclusão** - -A CLI foi **implementada com sucesso** e oferece: - -1. ✅ **Interface simples e intuitiva** -2. ✅ **Compatibilidade total com a biblioteca** -3. ✅ **Tratamento robusto de erros** -4. ✅ **Documentação completa** -5. ✅ **Exemplos práticos de uso** -6. ✅ **Testado com todos os 3 formatos de PDF** -7. ✅ **Pronto para publicação no npm** - -A implementação permite que usuários utilizem o LinkedIn PDF Parser diretamente da linha de comando, facilitando a integração em workflows automatizados, scripts de batch processing, e pipelines de dados. \ No newline at end of file diff --git a/.claude/CLI_USAGE.md b/.claude/CLI_USAGE.md deleted file mode 100644 index c598d7a..0000000 --- a/.claude/CLI_USAGE.md +++ /dev/null @@ -1,180 +0,0 @@ -# 🚀 LinkedIn PDF Parser CLI - -Uma ferramenta de linha de comando para extrair dados estruturados de PDFs de currículos do LinkedIn. - -## 📦 Instalação - -### Instalação Global -```bash -npm install -g @zalko/linkedin-parser -``` - -### Uso Temporário (npx) -```bash -npx @zalko/linkedin-parser path/to/resume.pdf -``` - -## 💻 Uso da CLI - -### Sintaxe Básica -```bash -linkedin-pdf-parser [options] -``` - -### Argumentos -- `` - Caminho para o arquivo PDF do LinkedIn a ser analisado - -### Opções -- `--raw-text` - Inclui o texto bruto extraído na saída -- `--pretty` - Saída JSON formatada (padrão: true) -- `--compact` - Saída JSON compacta (sem formatação) -- `--help, -h` - Mostra a mensagem de ajuda - -## 📋 Exemplos de Uso - -### 1. Parsing Básico -```bash -linkedin-pdf-parser ./meu-curriculo.pdf -``` - -### 2. Saída Compacta -```bash -linkedin-pdf-parser /path/to/linkedin-resume.pdf --compact -``` - -### 3. Incluindo Texto Bruto -```bash -linkedin-pdf-parser resume.pdf --raw-text -``` - -### 4. Salvando em Arquivo -```bash -linkedin-pdf-parser resume.pdf > profile-data.json -``` - -### 5. Usando com jq para Filtrar Dados -```bash -# Extrair apenas o nome e email -linkedin-pdf-parser resume.pdf | jq '{name: .profile.name, email: .profile.contact.email}' - -# Listar apenas as experiências -linkedin-pdf-parser resume.pdf | jq '.profile.experience[]' - -# Contar número de skills -linkedin-pdf-parser resume.pdf | jq '.profile.top_skills | length' -``` - -## 📊 Estrutura de Saída JSON - -```json -{ - "profile": { - "name": "Nome da Pessoa", - "headline": "Título/Headline profissional", - "location": "Cidade, Estado, País", - "contact": { - "email": "email@exemplo.com", - "phone": "+55 (11) 99999-9999", - "linkedin_url": "https://linkedin.com/in/usuario" - }, - "top_skills": ["Skill 1", "Skill 2", "Skill 3"], - "languages": [ - { - "language": "Português", - "proficiency": "Native or Bilingual" - } - ], - "summary": "Resumo profissional...", - "experience": [ - { - "title": "Cargo/Posição", - "company": "Nome da Empresa", - "duration": "Jan 2020 - Present", - "location": "Cidade, Estado", - "description": "Descrição das responsabilidades..." - } - ], - "education": [ - { - "degree": "Grau Acadêmico", - "institution": "Nome da Instituição", - "year": "2020", - "location": "Cidade, Estado" - } - ] - } -} -``` - -## 🔧 Casos de Uso Comuns - -### 1. Integração com Scripts -```bash -#!/bin/bash -# Script para processar múltiplos PDFs - -for pdf in *.pdf; do - echo "Processing: $pdf" - linkedin-pdf-parser "$pdf" --compact > "${pdf%.pdf}.json" -done -``` - -### 2. Extração de Dados Específicos -```bash -# Extrair todas as empresas onde a pessoa trabalhou -linkedin-pdf-parser resume.pdf | jq -r '.profile.experience[].company' | sort -u - -# Extrair skills em formato de lista -linkedin-pdf-parser resume.pdf | jq -r '.profile.top_skills[]' - -# Verificar se tem experiência em determinada empresa -linkedin-pdf-parser resume.pdf | jq '.profile.experience[] | select(.company == "Google")' -``` - -### 3. Validação de Dados -```bash -# Verificar se o PDF foi processado com sucesso -if linkedin-pdf-parser resume.pdf >/dev/null 2>&1; then - echo "PDF processed successfully" -else - echo "Error processing PDF" -fi -``` - -## 🚨 Tratamento de Erros - -A CLI retorna códigos de saída apropriados: - -- `0` - Sucesso -- `1` - Erro (arquivo não encontrado, formato inválido, erro de parsing, etc.) - -Mensagens de erro são enviadas para `stderr`, enquanto o JSON é enviado para `stdout`. - -### Exemplo de Tratamento de Erro -```bash -linkedin-pdf-parser non-existent.pdf 2>/dev/null || echo "Arquivo não encontrado" -``` - -## 🔍 Debugging - -Para debug, você pode usar a opção `--raw-text` para ver o texto bruto extraído: - -```bash -linkedin-pdf-parser resume.pdf --raw-text | jq '.rawText' -``` - -## 📝 Notas Importantes - -1. **Formatos Suportados**: Apenas arquivos PDF são aceitos -2. **Compatibilidade**: Funciona com PDFs de currículos do LinkedIn -3. **Tamanho de Arquivo**: Não há limite específico, mas PDFs muito grandes podem demorar mais para processar -4. **Encoding**: A saída JSON usa encoding UTF-8 - -## 🔗 Links Úteis - -- [jq Manual](https://stedolan.github.io/jq/manual/) - Para filtrar e manipular JSON -- [Repositório do Projeto](https://github.com/zalkowitsch/linkedin-parser) - -## 🐛 Reportando Bugs - -Se encontrar problemas, por favor reporte em: https://github.com/zalkowitsch/linkedin-parser/issues \ No newline at end of file diff --git a/.claude/IMPLEMENTATION_SUMMARY.md b/.claude/IMPLEMENTATION_SUMMARY.md deleted file mode 100644 index 1da25f8..0000000 --- a/.claude/IMPLEMENTATION_SUMMARY.md +++ /dev/null @@ -1,127 +0,0 @@ -# LinkedIn PDF Parser - pdfjs-dist Implementation Summary - -## Overview - -Successfully implemented pdfjs-dist integration to replace the previous parsing logic and create a robust structural parsing system for LinkedIn PDF resumes. - -## ✅ Complete Implementation - -### 1. **pdfjs-dist Integration** -- **Replaced unpdf dependency** with pdfjs-dist for better PDF handling -- **Solved Node.js compatibility** issues with proper worker configuration -- **Added legacy build support** for stable Node.js execution - -### 2. **Structural Text Extraction** -- **Coordinate-based parsing**: Extract text with X/Y positions and font sizes -- **Multi-page support**: Process all pages in LinkedIn PDF documents -- **Font analysis**: Use typography information for content classification - -### 3. **Layout Detection System** -- **Two-column detection**: Automatically identify sidebar vs main content -- **Boundary analysis**: X-position clustering to separate columns -- **Adaptive grouping**: Handle both single-column and two-column layouts - -### 4. **Work Experience Hierarchy** -- **Organization identification**: Detect company names using font size and patterns -- **Position parsing**: Extract job titles with keyword analysis -- **Duration extraction**: Parse employment dates and periods -- **Location detection**: Identify geographic information -- **Description parsing**: Capture bullet points and role details - -## 📊 Results Achieved - -### Performance Improvements -| PDF File | Before | After | Improvement | -|----------|--------|-------|-------------| -| Profile.pdf | 1 position | 6 positions | 500% more experience data | -| Profile (1).pdf | 0 positions | 3 positions | Complete recovery | -| Profile (2).pdf | 3 positions | 8 positions | 167% more experience data | - -### Data Quality -- **Work Experience**: Now properly extracts hierarchical organization → position structure -- **Basic Info**: Improved name, email, location extraction -- **Skills**: Enhanced skills detection from structured content -- **Languages**: Better language proficiency parsing -- **Education**: More complete educational background extraction - -## 🔍 Structural Analysis Insights - -### Work Experience Hierarchy -The parser now understands the distinction between: -- **Work Experience**: Period of employment at an organization -- **Organization**: The company (e.g., "Carta", "Boba Joy", "Guild") -- **Position**: Job title within that work experience (e.g., "Engineering Manager", "Co-founder") - -### PDF Format Variations -Successfully handles three different LinkedIn PDF formats: -1. **Standard two-column layout**: Contact info sidebar + main content -2. **Condensed format**: Tighter spacing with compressed information -3. **Extended format**: Multi-page documents with detailed descriptions - -## 🛠️ Technical Architecture - -### Core Components -``` -src/ -├── parsers/ -│ ├── structural-parser.ts # Core PDF text extraction -│ ├── experience-structural.ts # Hierarchical experience parsing -│ ├── basic-info.ts # Name, contact, location extraction -│ ├── lists.ts # Skills and languages parsing -│ └── education.ts # Education background parsing -├── types/ -│ └── structural.ts # Type definitions for structural data -└── index.ts # Main parser interface -``` - -### Key Features -- **TextItem interface**: Captures text, position, font size, and formatting -- **LayoutInfo detection**: Identifies column structure automatically -- **Classification system**: Categorizes text as organization, position, duration, etc. -- **Proximity grouping**: Groups text items by Y-coordinate distance -- **Pattern matching**: Uses regex and keyword analysis for content identification - -## 📋 Implementation Status - -### ✅ Completed Tasks -1. Install pdfjs-dist and remove unpdf dependency -2. Create structural text extraction with coordinates -3. Implement layout detection (sidebar vs main content) -4. Create experience parser with work experience/organization/position logic -5. Update main parser to use new structural system -6. Fix pdfjs-dist Node.js compatibility issues -7. Test with Profile.pdf and validate accuracy -8. Test with Profile (1).pdf and validate accuracy -9. Test with Profile (2).pdf and validate accuracy -10. Fix experience parsing - improve Y-distance grouping and organization detection - -### 🎯 System Benefits -- **Robust PDF handling**: Better error handling and format support -- **Coordinate-aware parsing**: Uses spatial information for accurate extraction -- **Hierarchical understanding**: Properly maps organization-position relationships -- **Multi-format support**: Handles various LinkedIn PDF layouts -- **Improved data quality**: Significantly more complete and accurate extraction - -## 🔧 Configuration - -### Worker Setup -```typescript -// Set worker source from node_modules for Node.js compatibility -(pdfjs.GlobalWorkerOptions as any).workerSrc = - process.cwd() + '/node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs'; -``` - -### Column Detection -```typescript -// Two-column layout detection using X-coordinate analysis -const leftItems = textItems.filter(item => item.x < 150); -const rightItems = textItems.filter(item => item.x >= 150); -``` - -### Text Grouping -```typescript -// Proximity-based text grouping with smaller Y-distance for better separation -const groups = StructuralParser.groupTextByProximity(relevantItems, 3); -``` - -This implementation provides a solid foundation for parsing LinkedIn PDF resumes with significantly improved accuracy and completeness across different PDF formats. \ No newline at end of file diff --git a/.claude/INSTALLATION_GUIDE.md b/.claude/INSTALLATION_GUIDE.md deleted file mode 100644 index e6b18e3..0000000 --- a/.claude/INSTALLATION_GUIDE.md +++ /dev/null @@ -1,235 +0,0 @@ -# 📦 Guia de Instalação - LinkedIn PDF Parser CLI - -## 🚀 Instalação via npm - -### Instalação Global (Recomendada) -```bash -# Instalar globalmente para usar de qualquer lugar -npm install -g @zalko/linkedin-parser -``` - -Após a instalação global, você pode usar o comando em qualquer diretório: -```bash -linkedin-pdf-parser /path/to/resume.pdf -``` - -### Uso com npx (Sem Instalação) -```bash -# Usar diretamente sem instalar -npx @zalko/linkedin-parser /path/to/resume.pdf -``` - -### Instalação Local em Projeto -```bash -# Adicionar como dependência do projeto -npm install @zalko/linkedin-parser - -# Usar via npm scripts ou npx local -npx linkedin-pdf-parser /path/to/resume.pdf -``` - -## 🔧 Verificação da Instalação - -Após instalar, verifique se está funcionando: - -```bash -# Verificar se o comando está disponível -linkedin-pdf-parser --help - -# Verificar versão -npm list -g @zalko/linkedin-parser -``` - -## 📋 Exemplo de Uso Completo - -### 1. Instalar e Usar -```bash -# Passo 1: Instalar globalmente -npm install -g @zalko/linkedin-parser - -# Passo 2: Usar com seu PDF -linkedin-pdf-parser ./meu-curriculo-linkedin.pdf > perfil.json - -# Passo 3: Visualizar resultado -cat perfil.json -``` - -### 2. Pipeline de Dados -```bash -# Extrair dados específicos e salvar -linkedin-pdf-parser resume.pdf > full-profile.json - -# Se você tiver jq instalado, pode filtrar dados: -linkedin-pdf-parser resume.pdf | jq '.profile.name' > name.txt -linkedin-pdf-parser resume.pdf | jq '.profile.contact.email' > email.txt -linkedin-pdf-parser resume.pdf | jq '.profile.experience' > experience.json -``` - -### 3. Processamento em Lote -```bash -# Script para processar múltiplos PDFs -#!/bin/bash - -echo "Processing LinkedIn PDFs..." - -for pdf_file in linkedin-pdfs/*.pdf; do - echo "Processing: $pdf_file" - - # Gerar nome do arquivo JSON - json_file="${pdf_file%.pdf}.json" - - # Processar PDF - if linkedin-pdf-parser "$pdf_file" > "$json_file"; then - echo "✅ Success: $json_file" - else - echo "❌ Failed: $pdf_file" - fi -done - -echo "Batch processing complete!" -``` - -## 🐛 Resolução de Problemas - -### Erro: Command not found -Se você receber `linkedin-pdf-parser: command not found`: - -```bash -# Verificar se npm global bin está no PATH -npm config get prefix -echo $PATH - -# Reinstalar globalmente -npm uninstall -g @zalko/linkedin-parser -npm install -g @zalko/linkedin-parser -``` - -### Erro: Permission denied -Se houver problemas de permissão: - -```bash -# No Linux/Mac, usar sudo -sudo npm install -g @zalko/linkedin-parser - -# Ou configurar npm para não precisar de sudo -npm config set prefix ~/.npm-global -echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.bashrc -source ~/.bashrc -``` - -### Erro: PDF parsing failed -Se o parsing falhar: - -```bash -# Verificar se é um PDF válido -file meu-arquivo.pdf - -# Tentar com --raw-text para debug -linkedin-pdf-parser meu-arquivo.pdf --raw-text - -# Verificar tamanho do arquivo -ls -lh meu-arquivo.pdf -``` - -## 🔗 Integração com Outras Ferramentas - -### Com jq (JSON processor) -```bash -# Instalar jq -# Ubuntu/Debian: sudo apt-get install jq -# macOS: brew install jq -# Windows: choco install jq - -# Exemplos de uso -linkedin-pdf-parser resume.pdf | jq '.profile.name' -linkedin-pdf-parser resume.pdf | jq '.profile.experience[].company' | sort | uniq -linkedin-pdf-parser resume.pdf | jq '.profile.top_skills | length' -``` - -### Com Python -```python -import subprocess -import json - -# Processar PDF via CLI -result = subprocess.run([ - 'linkedin-pdf-parser', - 'resume.pdf', - '--compact' -], capture_output=True, text=True) - -if result.returncode == 0: - profile_data = json.loads(result.stdout) - print(f"Nome: {profile_data['profile']['name']}") - print(f"Email: {profile_data['profile']['contact']['email']}") -else: - print(f"Erro: {result.stderr}") -``` - -### Com Node.js -```javascript -const { execSync } = require('child_process'); - -try { - const jsonOutput = execSync('linkedin-pdf-parser resume.pdf --compact', { - encoding: 'utf8' - }); - - const profileData = JSON.parse(jsonOutput); - console.log('Nome:', profileData.profile.name); - console.log('Email:', profileData.profile.contact.email); -} catch (error) { - console.error('Erro ao processar PDF:', error.message); -} -``` - -## 📊 Monitoramento e Logs - -### Logging de Uso -```bash -# Criar log de processamento -echo "$(date): Processing $1" >> pdf-processing.log -linkedin-pdf-parser "$1" > "${1%.pdf}.json" 2>> pdf-processing.log -``` - -### Validação de Saída -```bash -#!/bin/bash - -pdf_file="$1" -json_file="${pdf_file%.pdf}.json" - -# Processar PDF -if linkedin-pdf-parser "$pdf_file" > "$json_file"; then - # Validar JSON - if jq empty "$json_file" 2>/dev/null; then - # Verificar se tem dados essenciais - name=$(jq -r '.profile.name' "$json_file") - email=$(jq -r '.profile.contact.email' "$json_file") - - if [[ "$name" != "null" && "$email" != "null" ]]; then - echo "✅ PDF processado com sucesso: $pdf_file" - echo " Nome: $name" - echo " Email: $email" - else - echo "⚠️ PDF processado mas dados incompletos: $pdf_file" - fi - else - echo "❌ JSON inválido gerado: $json_file" - fi -else - echo "❌ Falha ao processar PDF: $pdf_file" -fi -``` - -## 🎯 Dicas de Performance - -1. **Processamento em lote**: Use scripts para processar múltiplos arquivos -2. **Cache de resultados**: Evite reprocessar PDFs já processados -3. **Validação prévia**: Verifique se é PDF antes de processar -4. **Timeout**: Para PDFs muito grandes, considere timeout - -```bash -# Exemplo com timeout -timeout 30 linkedin-pdf-parser large-resume.pdf > output.json || echo "Timeout!" -``` \ No newline at end of file diff --git a/.github/workflows/bundlephobia.yml b/.github/workflows/bundlephobia.yml new file mode 100644 index 0000000..0693dc9 --- /dev/null +++ b/.github/workflows/bundlephobia.yml @@ -0,0 +1,49 @@ +name: Bundlephobia + +on: + pull_request: + branches: [main] + +permissions: + contents: read + issues: write + pull-requests: write + +concurrency: + group: bundlephobia-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + bundlephobia: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup pnpm + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + with: + version: 11.1.3 + + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '22' + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build package + run: pnpm run build + + - name: Verify build artifacts + run: pnpm run verify:artifacts + + - name: Report compressed size + uses: preactjs/compressed-size-action@66325aad6443cb7cf89c4bfcd414aea2367cda94 # 2.9.1 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + install-script: 'pnpm install --frozen-lockfile' + build-script: 'build' + pattern: 'dist/**/*.{js,mjs,cjs}' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a834476..2bfa817 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,67 +1,88 @@ -name: CI +name: ci on: - push: - branches: [ main, develop ] pull_request: - branches: [ main ] + branches: [main] + push: + branches: [main, develop] + +permissions: + contents: read + pull-requests: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true jobs: - test: + checks: runs-on: ubuntu-latest - strategy: - matrix: - node-version: [18, 20] - steps: - - name: Checkout code - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup pnpm + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + with: + version: 11.1.3 - - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 + - name: Setup Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: ${{ matrix.node-version }} - cache: 'npm' + node-version: '22' + cache: 'pnpm' - name: Install dependencies - run: npm ci + run: pnpm install --frozen-lockfile + + - name: Audit production dependencies + run: pnpm audit --prod - - name: Run linting - run: npm run lint + - name: Lint + run: pnpm run lint - name: Check formatting - run: npm run format:check + run: pnpm run format:check - - name: Run tests - run: npm run test:coverage + - name: Analyze TypeScript complexity + run: pnpm run fta - - name: Upload coverage to Codecov - if: matrix.node-version == 18 - uses: codecov/codecov-action@v3 - with: - file: ./coverage/lcov.info - flags: unittests - name: codecov-umbrella + - name: Check duplicate code + run: pnpm run dupes - build: - runs-on: ubuntu-latest - needs: test + - name: Check type coverage + run: pnpm run type-coverage - steps: - - name: Checkout code - uses: actions/checkout@v4 + - name: Build package + run: pnpm run build - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '18' - cache: 'npm' + - name: Verify build artifacts + run: pnpm run verify:artifacts - - name: Install dependencies - run: npm ci + - name: Verify packed package + run: pnpm run verify:package - - name: Build package - run: npm run build + - name: Check unused files and dependencies + run: pnpm run knip + + - name: Lint package exports + run: pnpm run publint + + - name: Lint package types + run: pnpm run types:lint - name: Check bundle size - run: npm run size:check \ No newline at end of file + run: pnpm run size:check + + - name: Run tests with coverage + run: pnpm run test:coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage/lcov.info + flags: unittests + name: codecov-umbrella + fail_ci_if_error: true + verbose: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 09628a5..c00b76a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,122 +1,80 @@ -name: Release and Publish +name: Release on: - push: - tags: - - 'v*' - workflow_dispatch: - inputs: - version: - description: 'Version to release (e.g., 1.0.2)' - required: true - default: '1.0.2' - -jobs: - test: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '18' - cache: 'npm' + release: + types: [published] - - name: Install dependencies - run: npm ci +permissions: + contents: read + id-token: write - - name: Run quality checks - run: npm run quality:check +concurrency: + group: release-${{ github.event.release.tag_name }} + cancel-in-progress: false - build: - needs: test +jobs: + publish: + name: Publish to npm runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - node-version: '18' - cache: 'npm' + ref: ${{ github.event.release.tag_name }} - - name: Install dependencies - run: npm ci - - - name: Build package - run: npm run build - - - name: Upload build artifacts - uses: actions/upload-artifact@v4 + - name: Setup pnpm + uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 with: - name: build-artifacts - path: dist/ - - publish: - needs: [test, build] - runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') - steps: - - name: Checkout code - uses: actions/checkout@v4 + version: 11.1.3 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '18' + node-version: '22.x' + cache: 'pnpm' + cache-dependency-path: pnpm-lock.yaml registry-url: 'https://registry.npmjs.org' - cache: 'npm' + + - name: Upgrade npm to latest + run: | + npm -v + npm config set prefix ~/.local + export PATH="$HOME/.local/bin:$PATH" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + npm install -g npm@latest + hash -r + npm -v - name: Install dependencies - run: npm ci + run: pnpm install --frozen-lockfile + + - name: Run tests + run: pnpm test - name: Build package - run: npm run build + run: pnpm build - - name: Publish to npm - run: npm publish - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Verify build artifacts + run: pnpm run verify:artifacts - release: - needs: [test, build] - runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') - steps: - - name: Checkout code - uses: actions/checkout@v4 + - name: Verify packed package + run: pnpm run verify:package - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '18' - cache: 'npm' - - - name: Install dependencies - run: npm ci + - name: Check bundle size + run: pnpm run size:check - - name: Build package - run: npm run build + - name: Lint package exports + run: pnpm run publint - - name: Download build artifacts - uses: actions/download-artifact@v4 - with: - name: build-artifacts - path: dist/ + - name: Lint package types + run: pnpm run types:lint - - name: Create GitHub Release - uses: softprops/action-gh-release@v1 - with: - files: | - dist/index.js - dist/index.cjs - dist/index.min.js - dist/index.d.ts - generate_release_notes: true - draft: false - prerelease: false - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + - name: Publish to npm + run: | + TAG="latest" + if [[ "${{ github.event.release.prerelease }}" == "true" ]]; then + TAG="next" + fi + + echo "Publishing with tag: $TAG" + npm publish --tag "$TAG" --access public --provenance diff --git a/.gitignore b/.gitignore index e22f7d9..fa4e82e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,6 @@ yarn-error.log* # Build outputs dist/ -lib/ *.tsbuildinfo # TypeScript @@ -50,6 +49,7 @@ jspm_packages/ # Optional npm cache directory .npm +.npm-cache/ # Optional REPL history .node_repl_history @@ -57,13 +57,22 @@ jspm_packages/ # Output of 'npm pack' *.tgz +# Generated analysis reports +report/ + # Yarn Integrity file .yarn-integrity -# parcel-bundler cache (https://parceljs.org/) -.cache +# Generated cache/debug directories +.cache/ +.debug/ +.debug-dist/ .parcel-cache # temporary files *.tmp -*.temp \ No newline at end of file +*.temp + +triage_decisions.db +reviews_triage/ +samples/ diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..4215d8b --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,5 @@ +{// Folder-specific settings +// +// For a full list of overridable settings, and general information on folder-specific settings,//zed.dev/docs/configuring-zed#settings-files + "format_on_save": "off" +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..aba1c7e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,35 @@ +# Working Guide + +- After adding any code or functionality, write thorough unit tests and check coverage. +- After making any changes always execute `pnpm run check` to verify +- After completing a task and the check verification, run `pnpm run samples:verify`. Make no further changes based on this output (unless explicitly asked) but report any changes to the user. +- `samples/` is local and gitignored, so sample verification is intentionally separate from `pnpm run check`. +- Fix any pnpm format issues (even if they are unrelated) +- When confusion or errors reveal a reusable project workflow rule, add a concise guideline to AGENTS.md. +- When verification fails on unrelated dirty-worktree changes, report the exact failing command and failures instead of modifying unrelated code. +- When debugging sample PDF extraction, use the repo-local skill at `.agents/skills/debug-linkedin-sample-pdfs`. +- Sample coverage strictness must include field-level misclassification checks, not only section-level source traceability. +- NEVER edit, delete, overwrite, or otherwise modify anything in the samples/ directory. +- Avoid one-off parser hacks such as adding domain cue words or narrow regular expressions for a single sample. Prefer generalizable extraction strategies: canonical block parsing, visual hierarchy/layout evidence, scored confidence signals, and penalties for ambiguous cues (for example person-shaped organization names) instead of hard vetoes. + +# TypeScript + +- **Type everything**: params, returns, config objects, and external integrations; avoid `any` +- **Use interfaces**: for complex types and objects, including ports and DTOs +- **Make Illegal States Unrepresentable**: If something should never happen, encode that rule in the type system instead of comments or runtime checks. + - Discriminated unions instead of flags + nullable fields + - Narrowed constructors / factory functions +- **Avoid using bare string types** - prefer Branded domain types instead of primitives + - Brand types especially for strings e.g. phone, email, ID + - e.g. NormalizedEmail, UserId, ChatId +- **Avoid Type Assertions (as)**: Every as is a potential runtime crash hidden from the compiler. + - Replace with: Narrowing functions or Exhaustive pattern matching or Refined input types +- **Prefer Union Types Over Boolean Flags**: Boolean flags destroy invariants. +- **Separate Pure Logic from Side Effects**: Functions that return void hide meaning from the compiler. + - Prefer Pure functions with explicit inputs/outputs. +- **Use a single params object for a function argument when there are optional arguments or arguments of the same type**: this enables safe, name-based destructuring. +- **Add comments to tricky parts of code (no need for obvious comments)**: ensure comments on tricky code capture intent +- **Prefer undefined over null** - except at outer boundaries where it's necessary to communicate absence of a value. +- **Avoid uninformative method names** - don't use words like "handle" or "process" in names, use descriptive verbs +- **Avoid type guard functions** - prefer Zod (e.g. for cache policy `isValue`) +- **Avoid creating duplicative types** - prefer to use typescript's `Pick` or `Omit` (if using Zod use `.extend`) diff --git a/README.md b/README.md index eb90e8c..591f7a4 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,62 @@ -
+# linkedin-parser-serverless -# @zalko/linkedin-parser +[![npm version](https://badge.fury.io/js/linkedin-parser-serverless.svg)](https://www.npmjs.com/package/linkedin-parser-serverless) +[![codecov](https://codecov.io/gh/hbmartin/linkedin-parser-serverless/graph/badge.svg?token=Po1nDYEr5f)](https://codecov.io/gh/hbmartin/linkedin-parser-serverless) +[![npm bundle size](https://img.shields.io/bundlephobia/minzip/linkedin-parser-serverless)](https://bundlephobia.com/package/linkedin-parser-serverless) +[![TypeScript](https://img.shields.io/badge/TypeScript-6-blue?logo=typescript)](https://www.typescriptlang.org/) +[![Context7](https://img.shields.io/badge/[]-Context7-059669)](https://context7.com/hbmartin/linkedin-parser-serverless) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hbmartin/linkedin-parser-serverless) -

- npm version - downloads - coverage - bundle size - node version - typescript - license -

+A clean, lightweight, serverless (e.g. Vercel Edge) TypeScript library for parsing LinkedIn PDF resumes and extracting structured profile data. -**A clean, lightweight TypeScript library for parsing LinkedIn PDF resumes and extracting structured profile data.** +- [📦 Installation](#-installation) +- [🖥️ CLI Usage](#️-cli-usage) +- [🚀 Quick Start](#-quick-start) +- [📚 Examples](#-examples) +- [📖 API Reference](#-api-reference) +- [🛠️ Development](#️-development) -> ℹ️ **Note:** This is a newly published package. Download statistics may take 24-48 hours to populate. Some badges show "package not found or too new" until npm statistics are updated. - -

- tests - activity - last commit -

+--- -[Installation](#installation) • [CLI Usage](#cli-usage) • [Quick Start](#quick-start) • [API Reference](#api-reference) • [Examples](#examples) +## Features -
+- **Simple API**: Single function to parse PDF files or text +- **Serverless Friendly**: Uses `unpdf` for PDF text extraction across JavaScript runtimes +- **TypeScript First**: Full type definitions included +- **Fast**: Optimized parsing algorithms +- **Well Tested**: Comprehensive Jest test suite +- **ESM Ready**: Modern ES module support ---- +## 📦 Installation -## ✨ Features - - - - - - - - - - - - - - - - - - - - - - - - - - -
🚀Simple API
Single function to parse PDF files or text
📦Lightweight
Only 1 dependency (pdf-parse)
🔧TypeScript First
Full type definitions included
Fast
Optimized parsing algorithms
🧪Well Tested
Comprehensive Jest test suite
📱ESM Ready
Modern ES module support
+### Requirements -## 📦 Installation +- Node.js 22.0.0 or newer +- pnpm 11.1.3 for local development +- Supported runtimes: Node.js 22+, Vercel Edge, and serverless JavaScript runtimes that provide Web-standard binary types such as `ArrayBuffer` ### Library Usage + ```bash -npm install @zalko/linkedin-parser +npm install linkedin-parser-serverless ``` ### CLI Usage (Global) + ```bash # Install globally for command-line usage -npm install -g @zalko/linkedin-parser +npm install -g linkedin-parser-serverless # Or use with npx (no installation required) -npx @zalko/linkedin-parser path/to/resume.pdf +npx -p linkedin-parser-serverless linkedin-pdf-parser path/to/resume.pdf ``` ## 🖥️ CLI Usage The package includes a command-line interface for easy PDF processing: -### Basic Usage +### Command + ```bash # Parse a LinkedIn PDF and output JSON linkedin-pdf-parser ./resume.pdf @@ -90,14 +69,25 @@ linkedin-pdf-parser ./resume.pdf --compact # Include raw extracted text linkedin-pdf-parser ./resume.pdf --raw-text + +# Create JSON baselines next to PDFs in a folder +linkedin-pdf-parser write-json ./fixtures + +# Verify PDFs still generate the expected JSON baselines +linkedin-pdf-parser verify-json ./fixtures + +# Print semantic JSON keypath changes for changed baselines +linkedin-pdf-parser verify-json ./fixtures --json-paths ``` ### Real-world Examples + ```bash -# Process multiple PDFs -for pdf in *.pdf; do - linkedin-pdf-parser "$pdf" > "${pdf%.pdf}.json" -done +# Create or refresh regression baselines +linkedin-pdf-parser write-json ./customer-samples --force + +# Check parser output after dependency or parser changes +linkedin-pdf-parser verify-json ./customer-samples # Extract specific data with jq linkedin-pdf-parser resume.pdf | jq '.profile.name' @@ -107,79 +97,217 @@ linkedin-pdf-parser resume.pdf | jq '.profile.experience[].company' ### CLI Options - `--compact` - Compact JSON output (no formatting) +- `--force` - Overwrite existing JSON files in `write-json` mode +- `--json-paths` - Print semantic JSON keypath changes in `verify-json` mode - `--raw-text` - Include raw extracted text in output - `--help, -h` - Show help message **📖 See [CLI_USAGE.md](CLI_USAGE.md) for complete CLI documentation** -**Note:** Starting from v1.0.2, `pdf-parse` is a peer dependency to minimize bundle size. +**Note:** PDF extraction is powered by `unpdf`, which includes a serverless PDF.js build. ## 🚀 Quick Start ```typescript -import { parseLinkedInPDF } from '@zalko/linkedin-parser'; +import { + formatLinkedInProfile, + parseLinkedInPDF +} from 'linkedin-parser-serverless'; import fs from 'fs'; -// Parse from PDF Buffer const pdfBuffer = fs.readFileSync('resume.pdf'); -const result = await parseLinkedInPDF(pdfBuffer); - -console.log(result.profile.name); // "John Silva" -console.log(result.profile.contact.email); // "john.silva@email.com" -console.log(result.profile.experience); // [{ title: "...", company: "..." }] -``` - -## 📚 Examples - -### Basic Usage - -```typescript -import { parseLinkedInPDF } from '@zalko/linkedin-parser'; - -const pdfBuffer = fs.readFileSync('linkedin-resume.pdf'); -const { profile } = await parseLinkedInPDF(pdfBuffer); +const { diagnostics, profile } = await parseLinkedInPDF(pdfBuffer); -// Access parsed data console.log(`Name: ${profile.name}`); -console.log(`Email: ${profile.contact.email}`); +console.log(`Email: ${profile.contact.email ?? 'not found'}`); console.log(`Skills: ${profile.top_skills.join(', ')}`); console.log(`Experience: ${profile.experience.length} positions`); +console.log(`Likely LinkedIn export: ${diagnostics.isLikelyLinkedInExport}`); +console.log(formatLinkedInProfile(profile)); ``` +### Sample Output + +```json +{ + "profile": { + "name": "Orion Helios", + "headline": "Senior Backend Engineer at DataFlow Inc", + "location": "Austin, Texas, United States", + "contact": { + "email": "orion.helios@example.com", + "linkedin_url": "https://www.linkedin.com/in/orion-helios" + }, + "top_skills": ["TypeScript", "Node.js", "AWS"], + "certifications": ["AWS Certified Solutions Architect"], + "volunteer_work": [], + "projects": ["Search platform migration"], + "publications": [], + "languages": [ + { + "language": "English", + "proficiency": "Native or Bilingual" + } + ], + "summary": "Backend engineer focused on high-volume data platforms and serverless APIs.", + "experience": [ + { + "title": "Senior Backend Engineer", + "company": "DataFlow Inc", + "duration": "January 2021 - Present", + "location": "Austin, Texas, United States" + }, + { + "title": "Software Engineer", + "company": "TechFlow Systems", + "duration": "June 2018 - December 2020" + } + ], + "education": [ + { + "degree": "BS, Computer Science", + "institution": "University of Texas at Austin", + "year": "2014 - 2018" + } + ] + }, + "warnings": [], + "diagnostics": { + "sectionsFound": ["summary", "experience", "education", "top_skills"], + "confidence": 0.94, + "isLikelyLinkedInExport": true, + "isEmpty": false + } +} +``` + +## 📚 Examples + ### With Options ```typescript // Include raw extracted text in result -const result = await parseLinkedInPDF(pdfBuffer, { +const result = await parseLinkedInPDF(pdfData, { includeRawText: true }); console.log(`Raw text: ${result.rawText?.substring(0, 100)}...`); ``` +### Serverless Binary Input + +```typescript +const arrayBuffer = await request.arrayBuffer(); +const result = await parseLinkedInPDF(arrayBuffer); +``` + +### Vercel Edge Route + +Create a Next.js App Router endpoint at `app/api/parse-linkedin/route.ts`: + +```typescript +import { parseLinkedInPDF } from 'linkedin-parser-serverless'; + +export const runtime = 'edge'; + +export async function POST(request: Request): Promise { + const formData = await request.formData(); + const resume = formData.get('resume'); + + if (!(resume instanceof File)) { + return Response.json( + { error: 'Upload a PDF file in the "resume" form field.' }, + { status: 400 } + ); + } + + const parsed = await parseLinkedInPDF(await resume.arrayBuffer()); + + return Response.json(parsed); +} +``` + +Deploy it with Vercel and post a LinkedIn PDF to the Edge Function: + +```bash +vercel deploy +curl -F "resume=@linkedin-resume.pdf" https://your-app.vercel.app/api/parse-linkedin +``` + ### Parse Text Directly ```typescript // If you already have extracted text from PDF -const extractedText = "John Silva\nSoftware Engineer..."; +const extractedText = "Orion Helios\nSoftware Engineer..."; const result = await parseLinkedInPDF(extractedText); ``` -### Error Handling +### Partial Results and Warnings + +```typescript +const result = await parseLinkedInPDF(pdfData); + +if (!result.diagnostics.isLikelyLinkedInExport) { + console.warn('Input parsed, but does not look like a LinkedIn export.'); +} + +for (const warning of result.warnings) { + console.warn(`${warning.field}: ${warning.message}`); +} + +if (result.profile.contact.email) { + console.log(result.profile.contact.email); +} +``` + +The parser throws only for fatal input failures such as empty or unreadable PDFs. +Missing profile fields are returned as partial results with structured warnings. + +### Formatted Profile Summary + +```typescript +import { formatLinkedInProfile, parseLinkedInPDF } from "linkedin-parser-serverless"; + +const { profile } = await parseLinkedInPDF(pdfData); +const notes = formatLinkedInProfile(profile, { + includeContact: false, + outputFormat: "markdown" +}); +``` + +`formatLinkedInProfile` emits stable plain text by default, or Markdown when +`outputFormat: "markdown"` is set. Pass `includeContact: true` to include email, +phone, LinkedIn URL, and profile links. + +### Strict and Safe Parsing ```typescript +import { + LinkedInProfileParseError, + parseLinkedInPDFStrict, + safeParseLinkedInPDF +} from "linkedin-parser-serverless"; + try { - const result = await parseLinkedInPDF(pdfBuffer); - console.log(result.profile); + const result = await parseLinkedInPDFStrict(pdfData); + console.log(result.diagnostics.confidence); } catch (error) { - if (error.message === 'PDF appears to be empty or unreadable') { - console.error('Invalid PDF file'); - } else { - console.error('Parsing failed:', error.message); + if (error instanceof LinkedInProfileParseError) { + console.warn(error.code); } } + +const safeResult = await safeParseLinkedInPDF(pdfData); +if (!safeResult.success) { + console.warn(safeResult.error.code); +} ``` +`parseLinkedInPDF` is lenient and does not throw for readable non-LinkedIn +input; inspect `diagnostics.isLikelyLinkedInExport` instead. `parseLinkedInPDFStrict` +adds runtime `ParseResultSchema` validation and throws `schema_validation_failed` +if the successful parse result does not match the public schema. + ## 📖 API Reference ### `parseLinkedInPDF(input, options?)` @@ -190,7 +318,7 @@ Parses a LinkedIn PDF resume and extracts structured profile data. | Parameter | Type | Description | |-----------|------|-------------| -| `input` | `Buffer \| string` | PDF Buffer or extracted text string | +| `input` | `ArrayBuffer \| Uint8Array \| string` | PDF binary data or extracted text string | | `options?` | `ParseOptions` | Optional parsing configuration | #### Returns @@ -200,7 +328,37 @@ Parses a LinkedIn PDF resume and extracts structured profile data. #### Example ```typescript -const result = await parseLinkedInPDF(pdfBuffer, { includeRawText: true }); +const result = await parseLinkedInPDF(pdfData, { includeRawText: true }); +``` + +### `parseLinkedInPDFStrict(input, options?)` + +Parses the input with `parseLinkedInPDF`, then validates the successful result +with `ParseResultSchema`. Fatal parse errors and schema failures are thrown as +`LinkedInProfileParseError`. + +### `safeParseLinkedInPDF(input, options?)` + +Returns a discriminated result: + +```typescript +type SafeParseLinkedInPDFResult = + | { success: true; data: ParseResult } + | { success: false; error: LinkedInProfileParseError }; +``` + +### `formatLinkedInProfile(profile, options?)` + +Formats a parsed `LinkedInProfile` as plain text or Markdown with stable section +headings and whitespace cleanup. + +```typescript +type LinkedInProfileOutputFormat = "plainText" | "markdown"; + +interface FormatLinkedInProfileOptions { + includeContact?: boolean; + outputFormat?: LinkedInProfileOutputFormat; +} ``` ## 🏗️ TypeScript Interfaces @@ -210,13 +368,19 @@ const result = await parseLinkedInPDF(pdfBuffer, { includeRawText: true }); ```typescript interface LinkedInProfile { - name: string; - headline: string; - location: string; + name?: string; + headline?: string; + location?: string; contact: Contact; top_skills: string[]; languages: Language[]; + certifications: string[]; + volunteer_work: string[]; + projects: string[]; + publications: string[]; + honors_awards: string[]; summary?: string; + experience_groups: ExperienceGroup[]; experience: Experience[]; education: Education[]; } @@ -228,7 +392,7 @@ interface LinkedInProfile { ```typescript interface Contact { - email: string; + email?: string; phone?: string; linkedin_url?: string; location?: string; @@ -244,38 +408,16 @@ interface Experience { title: string; company: string; duration: string; + dates?: ParsedDateRange; location?: string; description?: string; } ``` -**Work Experience Structure:** -- **Work Experience**: A continuous period of employment at an organization, even if the person returns to the same company later after working elsewhere -- **Organization/Company**: The employer entity (e.g., "TechCorp", "DataSystems Inc") -- **Position/Role**: The job title/role within that work experience period (e.g., "Engineering Manager", "Senior Developer") - -**Examples:** - -*Single organization, multiple positions:* -``` -TechCorp (1 work experience, 3 positions): -- Engineering Manager -- Senior Developer -- Software Developer -``` - -*Same organization, separate work experiences:* -``` -DataSystems Inc (2 separate work experiences, 2 positions): -1st work experience: Lead Engineer (2018-2020) -2nd work experience: Technical Architect (2023-Present) -// Note: Person worked elsewhere between 2020-2023 -``` - -**Key principle:** If someone returns to the same company after working elsewhere, it counts as a separate work experience. This reflects career progression and different employment periods. - +See [Work Experience Semantics](docs/work-experience-semantics.md) for how repeated companies and multiple positions are interpreted. +
Education @@ -284,12 +426,48 @@ interface Education { degree: string; institution: string; year?: string; + dates?: ParsedDateRange; location?: string; description?: string; } ```
+
+ParsedDateRange + +```typescript +interface ParsedProfileDate { + iso: string; + precision: "year" | "month" | "day"; + text: string; +} + +type ParsedDateRange = + | { + kind: 'current'; + originalText: string; + start: ParsedProfileDate; + durationText?: string; + end?: undefined; + } + | { + kind: 'completed'; + originalText: string; + start: ParsedProfileDate; + end: ParsedProfileDate; + durationText?: string; + } + | { + kind: 'single'; + originalText: string; + start: ParsedProfileDate; + durationText?: string; + end?: undefined; + }; +``` +
+
Language @@ -311,12 +489,94 @@ interface ParseOptions { ```
+
+ParseWarning + +```typescript +interface MissingProfileFieldWarning { + code: 'missing_profile_field'; + field: 'profile.name' | 'profile.contact.email'; + message: string; +} + +interface SectionParseWarning { + code: 'section_parse_warning'; + section: + | 'profile' + | 'contact' + | 'summary' + | 'top_skills' + | 'languages' + | 'certifications' + | 'volunteer_work' + | 'projects' + | 'publications' + | 'honors_awards' + | 'experience' + | 'education'; + entry?: number; + field: string; + message: string; + rawText?: string; +} + +type ParseWarning = MissingProfileFieldWarning | SectionParseWarning; +``` +
+ +
+ParseDiagnostics + +```typescript +interface ParseDiagnostics { + sectionsFound: WarningSection[]; + confidence: number; + isLikelyLinkedInExport: boolean; + isEmpty: boolean; +} +``` +
+ +
+LinkedInProfileParseError + +```typescript +type LinkedInProfileParseErrorCode = + | 'invalid_pdf' + | 'encrypted_pdf' + | 'unsupported_pdf' + | 'not_linkedin_profile' + | 'text_extraction_failed' + | 'schema_validation_failed'; + +class LinkedInProfileParseError extends Error { + code: LinkedInProfileParseErrorCode; + cause?: unknown; +} +``` +
+ +### Zod Schemas + +The main entrypoint also exports named Zod schemas for runtime validation. +`parseLinkedInPDFStrict` validates successful results with `ParseResultSchema`; +plain `parseLinkedInPDF` returns the typed result without the extra validation step. + +```typescript +import { LinkedInProfileSchema, ParseResultSchema, parseLinkedInPDFStrict } from "linkedin-parser-serverless"; + +const result = await parseLinkedInPDFStrict(pdfData); +const profile = LinkedInProfileSchema.parse(result.profile); +``` +
ParseResult ```typescript interface ParseResult { profile: LinkedInProfile; + warnings: ParseWarning[]; + diagnostics: ParseDiagnostics; rawText?: string; } ``` @@ -326,23 +586,81 @@ interface ParseResult { ```bash # Clone repository -git clone https://github.com/zalkowitsch/linkedin-parser.git -cd linkedin-parser +git clone https://github.com/hbmartin/linkedin-parser-serverless.git +cd linkedin-parser-serverless # Install dependencies -npm install +pnpm install # Run tests -npm test +pnpm test # Build library -npm run build +pnpm run build # Run tests with coverage -npm run test:coverage +pnpm run test:coverage # Clean build artifacts -npm run clean +pnpm run clean +``` + +### Codex PDF Debugging Skill + +This repo includes a Codex skill for investigating sample PDF extraction issues: + +```text +.agents/skills/debug-linkedin-sample-pdfs +``` + +Use it when parser output may be wrong and the original PDF needs to be treated as the source of truth. + +In the Codex CLI, start from the repository root and ask Codex to use the skill explicitly: + +```text +Use $debug-linkedin-sample-pdfs to investigate why samples/Persephone Kore.pdf parses incorrectly. +``` + +In the Codex app, use the same `$debug-linkedin-sample-pdfs` mention in the chat. The skill directs Codex to generate source evidence bundles with `pnpm run source:inspect -- `, compare Poppler/pdfplumber/unpdf artifacts, and use the section-aware sample audit before changing parser code. + +For a private sample corpus, place top-level PDFs in the local gitignored +`samples/` directory, then ask Codex to use the skill against the new set: + +```text +Use $debug-linkedin-sample-pdfs to inspect my samples/ directory, identify parser gaps against the source PDFs, and improve the parser until pnpm run check and pnpm run samples:verify pass. +``` + +Codex will treat the PDFs as source truth, generate evidence bundles for +suspicious cases, add focused tests for any parser change, and use +`samples:verify` so another developer can iterate without committing private +files. If no JSON exists yet, `samples:verify` generates initial JSON first; +that generated JSON is suspect parser output for review, not golden truth. + +### Developing and Testing the CLI + +The local CLI script loads the built package from `dist/`, so build the project before running it from a checkout: + +```bash +pnpm install +pnpm run build + +# Run the local CLI directly against the included fixture +node bin/cli.js tests/fixtures/Profile.pdf + +# Check compact output and raw text output +node bin/cli.js tests/fixtures/Profile.pdf --compact +node bin/cli.js tests/fixtures/Profile.pdf --raw-text + +# Check usage output +node bin/cli.js --help +``` + +For a quick smoke test, assert a few expected fields with `jq`: + +```bash +node bin/cli.js tests/fixtures/Profile.pdf | jq '.profile.name' +node bin/cli.js tests/fixtures/Profile.pdf | jq '.profile.contact.email' +node bin/cli.js tests/fixtures/Profile.pdf | jq '.profile.experience[0]' ``` ## 📊 Performance @@ -351,56 +669,6 @@ npm run clean - **Memory usage**: Minimal memory footprint (~8MB) - **Bundle size**: Ultra-lightweight at 3.0kB gzipped -## 🛡️ Quality & Trust - - - - - - - - - - - - - - - - - - - - - - - - - - -
🧪Test Coverage
95.6% code coverage with comprehensive test suite
🔒Security
Zero known vulnerabilities, regularly audited
📈CI/CD
Automated testing and deployment pipeline
🏷️Semantic Versioning
Follows semver for predictable releases
📝Documentation
Comprehensive docs with TypeScript support
🚀Production Ready
Battle-tested in production environments
- -## 🌍 Compatibility - -
- -![Node.js](https://img.shields.io/badge/Node.js-18%2B-brightgreen?style=flat-square&logo=node.js) -![TypeScript](https://img.shields.io/badge/TypeScript-5.0%2B-blue?style=flat-square&logo=typescript) -![ES2022](https://img.shields.io/badge/ES2022-Compatible-orange?style=flat-square&logo=javascript) - -
- -**Supported Environments:** -- ✅ Node.js 18+ (ES2022 support) -- ✅ TypeScript 5.0+ -- ✅ ESM (ES Modules) -- ✅ CommonJS (via build) -- ✅ Browsers (via bundlers) - -**Package Managers:** -- ✅ npm 8+ -- ✅ yarn 1.22+ -- ✅ pnpm 7+ ## 🤝 Contributing @@ -412,10 +680,4 @@ Contributions are welcome! Please feel free to submit a Pull Request. For major --- -
- -**[⭐ Star this project](https://github.com/zalkowitsch/linkedin-parser)** if you find it helpful! - -Made with ❤️ by [Arkady Zalkowitsch](https://github.com/zalkowitsch) - -
\ No newline at end of file +Made with ❤️ by [Arkady Zalkowitsch](https://github.com/zalkowitsch) and Harold Martin diff --git a/bin/cli.js b/bin/cli.js index 37830ca..cb7f4b6 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -1,103 +1,16 @@ #!/usr/bin/env node -import fs from 'fs'; -import path from 'path'; -import { parseLinkedInPDF } from '../dist/index.js'; - -function showUsage() { - console.error(` -Usage: linkedin-pdf-parser [options] - -Arguments: - Path to the LinkedIn PDF file to parse - -Options: - --raw-text Include raw extracted text in output - --pretty Pretty-print JSON output (default: true) - --compact Compact JSON output (no formatting) - --help, -h Show this help message - -Examples: - linkedin-pdf-parser ./resume.pdf - linkedin-pdf-parser /path/to/linkedin-resume.pdf --raw-text - linkedin-pdf-parser resume.pdf --compact - -Output: - Outputs structured JSON to stdout with parsed LinkedIn profile data -`); -} - -async function main() { - const args = process.argv.slice(2); - - // Check for help flag - if (args.includes('--help') || args.includes('-h') || args.length === 0) { - showUsage(); - process.exit(0); - } - - // Extract PDF file path (first non-flag argument) - const pdfPath = args.find(arg => !arg.startsWith('--')); - if (!pdfPath) { - console.error('Error: No PDF file path provided'); - showUsage(); - process.exit(1); - } - - // Parse options - const options = { - includeRawText: args.includes('--raw-text'), - prettyPrint: !args.includes('--compact') - }; - - try { - // Resolve and validate file path - const resolvedPath = path.resolve(pdfPath); - - if (!fs.existsSync(resolvedPath)) { - console.error(`Error: File not found: ${resolvedPath}`); - process.exit(1); - } - - // Check file extension - if (!resolvedPath.toLowerCase().endsWith('.pdf')) { - console.error(`Error: File must be a PDF: ${resolvedPath}`); - process.exit(1); - } - - // Read PDF file - const pdfBuffer = fs.readFileSync(resolvedPath); - - // Parse LinkedIn PDF - const result = await parseLinkedInPDF(pdfBuffer, { - includeRawText: options.includeRawText - }); - - // Output JSON to stdout - if (options.prettyPrint) { - console.log(JSON.stringify(result, null, 2)); - } else { - console.log(JSON.stringify(result)); - } - - } catch (error) { - // Output error to stderr - console.error(`Error: ${error.message}`); - - // Exit with error code - process.exit(1); - } -} +import { main } from '../dist/cli.js'; // Handle uncaught errors -process.on('uncaughtException', (error) => { +process.on('uncaughtException', error => { console.error(`Fatal Error: ${error.message}`); process.exit(1); }); -process.on('unhandledRejection', (error) => { +process.on('unhandledRejection', error => { console.error(`Unhandled Promise Rejection: ${error.message}`); process.exit(1); }); -main(); \ No newline at end of file +process.exit(await main()); diff --git a/demo-cli.sh b/demo-cli.sh deleted file mode 100755 index ed5315b..0000000 --- a/demo-cli.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/bin/bash - -echo "🚀 LinkedIn PDF Parser CLI Demo" -echo "===============================" -echo - -# Test CLI help -echo "📋 1. Showing help:" -node bin/cli.js --help -echo - -# Test with Profile.pdf in compact mode -echo "📄 2. Parsing Profile.pdf (compact mode):" -echo "node bin/cli.js \"/Users/arkady/Downloads/Profile.pdf\" --compact" -echo -echo "Output (first 300 characters):" -node bin/cli.js "/Users/arkady/Downloads/Profile.pdf" --compact | head -c 300 -echo "..." -echo - -# Test with Profile (1).pdf showing just structure -echo "📄 3. Parsing Profile (1).pdf (structured output):" -echo "node bin/cli.js \"/Users/arkady/Downloads/Profile (1).pdf\"" -echo -echo "Key fields extracted:" -RESULT=$(node bin/cli.js "/Users/arkady/Downloads/Profile (1).pdf" --compact) -echo "Name: $(echo $RESULT | grep -o '"name":"[^"]*"' | cut -d'"' -f4)" -echo "Email: $(echo $RESULT | grep -o '"email":"[^"]*"' | cut -d'"' -f4)" -echo "Location: $(echo $RESULT | grep -o '"location":"[^"]*"' | cut -d'"' -f4)" -echo "Skills count: $(echo $RESULT | grep -o '"top_skills":\[[^\]]*\]' | grep -o ',' | wc -l | xargs expr 1 +)" -echo "Experience count: $(echo $RESULT | grep -o '"experience":\[[^\]]*\]' | grep -o '"title":' | wc -l)" -echo - -# Test error handling -echo "🚨 4. Error handling examples:" -echo - -echo " a) Non-existent file:" -echo " node bin/cli.js non-existent.pdf" -node bin/cli.js non-existent.pdf 2>&1 || true -echo - -echo " b) Non-PDF file:" -echo " node bin/cli.js package.json" -node bin/cli.js package.json 2>&1 || true -echo - -# Usage examples -echo "💡 5. Real-world usage examples:" -echo " # Save to file:" -echo " linkedin-pdf-parser resume.pdf > profile-data.json" -echo -echo " # Extract specific data (with jq):" -echo " linkedin-pdf-parser resume.pdf | jq '.profile.name'" -echo " linkedin-pdf-parser resume.pdf | jq '.profile.contact.email'" -echo " linkedin-pdf-parser resume.pdf | jq '.profile.experience[].company'" -echo -echo " # Process multiple files:" -echo " for pdf in *.pdf; do" -echo " linkedin-pdf-parser \"\$pdf\" --compact > \"\${pdf%.pdf}.json\"" -echo " done" -echo - -echo "✅ CLI Demo completed successfully!" -echo "📖 See CLI_USAGE.md for complete documentation" \ No newline at end of file diff --git a/docs/migrate-2.1.0.md b/docs/migrate-2.1.0.md new file mode 100644 index 0000000..6a01ba1 --- /dev/null +++ b/docs/migrate-2.1.0.md @@ -0,0 +1,410 @@ +# Migrating to 2.1.0 + +2.1.0 keeps the main `parseLinkedInPDF` entrypoint and import path, but it +expands the public result shape and adds helpers for confidence checks, typed +errors, plain-text formatting, grouped work experience, and PDF source +debugging. + +The most common migration work is updating TypeScript mocks, JSON fixtures, and +golden-file assertions that were written against the 2.0.0 result shape. + +## Upgrade Checklist + +1. Upgrade the package to `linkedin-parser-serverless@2.1.0`. +2. Update any stored `ParseResult` JSON to include top-level `diagnostics`. +3. Update any constructed `LinkedInProfile` values to include + `experience_groups` and `honors_awards`. +4. Decide whether your integration should use lenient parsing + (`parseLinkedInPDF`), schema-validated parsing (`parseLinkedInPDFStrict`), or + no-throw parsing (`safeParseLinkedInPDF`). +5. If you validate parser output with Zod, update expected shapes for + `ParseDiagnosticsSchema`, `ContactLinkSchema`, and `ExperienceGroupSchema`. +6. If you compare complete parser JSON, regenerate fixtures because parser + heuristics now extract more contact links, honors/awards, grouped experience, + dates, languages, education, and section warnings. + +Node.js 22+ remains required. Local development in this repository now uses +`pnpm@11.1.3`. + +## Result Shape Changes + +`parseLinkedInPDF` still resolves to a `ParseResult`, but `diagnostics` is now a +required top-level field: + +```ts +const result = await parseLinkedInPDF(pdfData); + +if (!result.diagnostics.isLikelyLinkedInExport) { + console.warn('Input parsed, but does not look like a LinkedIn export.'); +} +``` + +The diagnostics shape is: + +```ts +interface ParseDiagnostics { + sectionsFound: WarningSection[]; + confidence: number; + isLikelyLinkedInExport: boolean; + isEmpty: boolean; +} +``` + +Use diagnostics as a routing signal, not as a perfect probability: + +- `isLikelyLinkedInExport` is the best high-level accept/review flag. +- `confidence` is a bounded `0..1` parser confidence score. +- `sectionsFound` lists recognized LinkedIn-style sections such as + `summary`, `experience`, `education`, and `top_skills`. +- `isEmpty` means the parser did not find usable profile content. + +Readable PDFs or text that do not look like LinkedIn exports do not throw by +default. Fatal extraction failures still throw, including empty input, invalid +PDFs, encrypted PDFs, unsupported PDF features, and text that is too short to +parse. + +## Profile Shape Changes + +`LinkedInProfile` now has two additional required array fields: + +```ts +interface LinkedInProfile { + // Existing 2.0.0 fields remain. + honors_awards: string[]; + experience_groups: ExperienceGroup[]; +} +``` + +If your tests or application code construct profile objects manually, add empty +arrays when you do not have values: + +```ts +const profile: LinkedInProfile = { + contact: {}, + top_skills: [], + languages: [], + certifications: [], + volunteer_work: [], + projects: [], + publications: [], + honors_awards: [], + experience_groups: [], + experience: [], + education: [], +}; +``` + +`WarningSection` also includes `honors_awards`, so exhaustive switches over +warning sections must handle that value. + +## Grouped Experience + +2.0.0 exposed only `profile.experience`, a flat list where every role repeated +its company name. 2.1.0 adds `profile.experience_groups`, which preserves +LinkedIn's company grouping and organization-level total duration. + +Prefer `experience_groups` when your UI or storage model needs company tenure, +multi-role progressions, or the distinction between one continuous employment +period and a later return to the same company: + +```ts +for (const group of profile.experience_groups) { + console.log(group.company, group.totalDuration); + + for (const position of group.positions) { + console.log(position.title, position.duration); + } +} +``` + +Keep using `profile.experience` when you need the old flat role list: + +```ts +const flatRoles = profile.experience; +``` + +Both shapes describe the same parsed work history. The grouped representation is +the better long-term shape for new integrations. See +`docs/work-experience-semantics.md` for the exact continuity rules. + +## Contact Links + +`profile.contact` can now include normalized profile links: + +```ts +interface Contact { + email?: string; + phone?: string; + linkedin_url?: string; + location?: string; + links?: ContactLink[]; +} + +interface ContactLink { + label?: string; + rawText: string; + url: string; +} +``` + +Use `contact.linkedin_url` for the canonical LinkedIn profile URL. Use +`contact.links ?? []` when you want all extracted links, including portfolios, +company links, blogs, or "Other" links from the LinkedIn contact section. + +The parser now avoids treating digits inside URLs as phone numbers and removes a +phone number when it is just the numeric portion of a LinkedIn profile URL. + +## Formatter Output (Plain Text & Markdown) + +Use `formatLinkedInProfile` when callers need app-facing plain text from an +extracted profile, such as notes, search indexes, or downstream prompts: + +```ts +import { + formatLinkedInProfile, + parseLinkedInPDF, +} from 'linkedin-parser-serverless'; + +const { profile } = await parseLinkedInPDF(pdfData); +const summaryText = formatLinkedInProfile(profile, { + includeContact: false, +}); +``` + +The formatter emits stable section headings, skips empty sections, and +normalizes whitespace. It formats the parsed `profile`; it is different from +`result.rawText`, which is the raw PDF text stream and is mostly useful for +debugging or source inspection. + +Formatting options: + +```ts +type LinkedInProfileOutputFormat = 'plainText' | 'markdown'; + +interface FormatLinkedInProfileOptions { + includeContact?: boolean; + outputFormat?: LinkedInProfileOutputFormat; +} +``` + +`includeContact` defaults to `false`, so email, phone, LinkedIn URL, location, +and profile links are omitted for privacy. Pass `includeContact: true` when the +plain-text output should include the `Contact` section: + +```ts +const textWithContact = formatLinkedInProfile(profile, { + includeContact: true, +}); +``` + +Pass `outputFormat: 'markdown'` when callers need Markdown headings instead of +plain-text section labels: + +```ts +const markdownSummary = formatLinkedInProfile(profile, { + outputFormat: 'markdown', +}); +``` + +If callers need the original extracted PDF text instead of normalized profile +text, request it during parsing: + +```ts +const result = await parseLinkedInPDF(pdfData, { + includeRawText: true, +}); + +const rawPdfText = result.rawText; +``` + +## Typed Errors + +Thrown parser errors now subclass `LinkedInProfileParseError` and expose a +stable `code`: + +```ts +import { + LinkedInProfileParseError, + parseLinkedInPDF, +} from 'linkedin-parser-serverless'; + +try { + await parseLinkedInPDF(pdfData); +} catch (error) { + if (error instanceof LinkedInProfileParseError) { + console.warn(error.code); + } +} +``` + +Current error codes are: + +- `invalid_pdf` +- `encrypted_pdf` +- `unsupported_pdf` +- `not_linkedin_profile` +- `text_extraction_failed` +- `schema_validation_failed` + +`not_linkedin_profile` is exported for integrations that want to enforce their +own diagnostics gate, but the built-in `parseLinkedInPDF` API does not throw it +for readable non-LinkedIn input. For example: + +```ts +import { + createLinkedInProfileParseError, + parseLinkedInPDF, +} from 'linkedin-parser-serverless'; + +const result = await parseLinkedInPDF(input); + +if (!result.diagnostics.isLikelyLinkedInExport) { + throw createLinkedInProfileParseError({ + code: 'not_linkedin_profile', + }); +} +``` + +If you previously matched exact `Error.message` strings from 2.0.0, switch to +`error.code`. Messages are now more specific; for example, short text input +throws `text_extraction_failed` with `Input text is empty or too short`. + +## Strict and Safe Parsing + +`parseLinkedInPDF` remains the compatibility-first API. It returns partial +structured profiles when extraction is usable and does not perform an extra Zod +parse before returning. + +Use `parseLinkedInPDFStrict` when you want every successful parse result to pass +`ParseResultSchema` before it is returned: + +```ts +const result = await parseLinkedInPDFStrict(pdfData); +``` + +If the parsed result does not satisfy `ParseResultSchema`, strict parsing throws +`LinkedInProfileParseError` with code `schema_validation_failed`. + +Use `safeParseLinkedInPDF` when your application prefers no-throw control flow: + +```ts +const result = await safeParseLinkedInPDF(pdfData); + +if (result.success) { + console.log(result.data.profile.name); +} else { + console.warn(result.error.code); +} +``` + +`safeParseLinkedInPDF` calls the strict parser internally, so its success branch +contains schema-validated output. + +## Zod Schema Changes + +`ParseResultSchema` now requires `diagnostics`, and the package exports +`ParseDiagnosticsSchema`. + +`LinkedInProfileSchema` now requires `honors_awards` and `experience_groups`. +The package also exports: + +- `ContactLinkSchema` +- `ExperienceGroupSchema` +- `ExperienceGroupPositionSchema` + +Before: + +```ts +const result = ParseResultSchema.parse(await parseLinkedInPDF(pdfData)); +``` + +After, prefer the strict API: + +```ts +const result = await parseLinkedInPDFStrict(pdfData); +``` + +Direct schema parsing still works if your JSON includes the new required +fields. + +## Parser Output Differences To Expect + +2.1.0 includes many extraction improvements. Existing fixtures can change even +when your input PDFs have not changed. + +Expect more or different values in these areas: + +- `profile.contact.links` and `profile.contact.linkedin_url` +- `profile.honors_awards` +- `profile.experience_groups` and flattened `profile.experience` +- `dates.durationText` for duration strings such as parenthetical durations or + German `Jahr`/`Jahre` +- `profile.summary` for binary PDFs, because structural PDF parsing now uses an + explicit Summary/About section instead of falling back to unrelated long + lines +- `profile.languages`, especially wrapped values such as + `Chinese (Traditional) (Limited Working)` +- `profile.education`, especially wrapped institutions, month/year ranges, and + degree lines with embedded dates +- `warnings`, because contact warnings are suppressed when another structural + parser resolves contact data and section warnings now include + `honors_awards` + +If you assert complete JSON equality, regenerate baselines after upgrading. If +you only need stable application behavior, prefer assertions around the fields +your application consumes. + +## PDF Source Debugging + +The package now exports `extractLinkedInPDFSourceDebug` for advanced debugging +of binary PDF extraction: + +```ts +import { extractLinkedInPDFSourceDebug } from 'linkedin-parser-serverless'; + +const artifacts = await extractLinkedInPDFSourceDebug(pdfBytes); + +console.log(artifacts.rawText); +console.log(artifacts.structuralLines); +``` + +It returns: + +```ts +interface LinkedInPDFSourceDebugArtifacts { + layout: LayoutInfo; + rawText: string; + structuralLines: StructuralLine[]; + textItems: TextItem[]; +} +``` + +Use this when a PDF's visual layout and the parsed result disagree. It is most +useful for debugging or support tooling; normal application code should usually +call `parseLinkedInPDF`. + +## CLI and Fixture Workflows + +The CLI still supports the same main commands: + +```bash +linkedin-pdf-parser ./resume.pdf +linkedin-pdf-parser write-json ./fixtures --force +linkedin-pdf-parser verify-json ./fixtures +``` + +Because the JSON shape changed, `verify-json` will report diffs until fixtures +are updated. Regenerate fixtures with `write-json --force` only after reviewing +that the new output is acceptable for your use case. + +This repository also adds local sample verification commands for maintainers +and teams that keep a private sample corpus: + +```bash +pnpm run samples:verify +pnpm run source:inspect -- samples/Profile.pdf +``` + +`samples/` is local and gitignored in this repository. The sample verifier can +bootstrap missing JSON for PDFs, but generated JSON is parser output to review, +not source truth. diff --git a/docs/work-experience-semantics.md b/docs/work-experience-semantics.md new file mode 100644 index 0000000..01bddfa --- /dev/null +++ b/docs/work-experience-semantics.md @@ -0,0 +1,74 @@ +# Work Experience Semantics + +This document explains how the parser treats LinkedIn work experience entries when a profile contains multiple roles at the same organization or separate employment periods with the same company. + +## Terms + +- **Work Experience**: A continuous period of employment at an organization without breaks working elsewhere. If the person returns to the same company after a break or employment elsewhere, that later period is a separate work experience. +- **Organization/Company**: The employer entity, such as "TechCorp" or "DataSystems Inc". +- **Position/Role**: The job title within that work experience period, such as "Engineering Manager" or "Senior Developer". + +## JSON Output Shapes + +The parser emits work history in two shapes: + +- **`experience_groups`**: the canonical grouped representation. Each entry is one continuous work experience at an organization and contains a `positions` array. Use this when preserving LinkedIn's company grouping, multi-role progressions, or organization-level total duration matters. +- **`experience`**: a flattened compatibility representation. Each entry is one position and repeats the company name on that position. Use this when callers need a simple list of roles and do not need organization grouping. + +Both shapes represent the same parsed work history. `experience_groups` preserves the parser's work-experience model, while `experience` is derived from those groups for consumers that already expect the older flat schema. + +```json +{ + "experience_groups": [ + { + "company": "TechCorp", + "totalDuration": "5 yrs", + "positions": [ + { + "title": "Engineering Manager", + "duration": "2022 - Present" + }, + { + "title": "Senior Developer", + "duration": "2019 - 2022" + } + ] + } + ], + "experience": [ + { + "company": "TechCorp", + "title": "Engineering Manager", + "duration": "2022 - Present" + }, + { + "company": "TechCorp", + "title": "Senior Developer", + "duration": "2019 - 2022" + } + ] +} +``` + +## Single Organization, Multiple Positions + +When a person holds multiple consecutive roles at the same organization, those roles belong to one continuous work experience period. + +```text +TechCorp (1 work experience, 3 positions): +- Engineering Manager +- Senior Developer +- Software Developer +``` + +## Same Organization, Separate Work Experiences + +When a person returns to the same company after working elsewhere, each employment period is treated as a separate work experience. + +```text +DataSystems Inc (2 separate work experiences, 2 positions): +1st work experience: Lead Engineer (2018-2020) +2nd work experience: Technical Architect (2023-Present) +``` + +The key principle is continuity: if there is a break in employment at an organization because the person worked elsewhere, the later return counts as a separate work experience. This preserves career progression and distinguishes different employment periods. diff --git a/esbuild.config.js b/esbuild.config.js deleted file mode 100644 index 91b1be8..0000000 --- a/esbuild.config.js +++ /dev/null @@ -1,20 +0,0 @@ -import esbuild from 'esbuild'; - -// Build ultra-minified version -esbuild.build({ - entryPoints: ['src/index.ts'], - bundle: true, - outfile: 'dist/index.min.js', - format: 'esm', - platform: 'node', - target: 'node18', - minify: true, - minifyWhitespace: true, - minifyIdentifiers: true, - minifySyntax: true, - sourcemap: true, - external: ['pdf-parse'], - tsconfig: 'tsconfig.json', - treeShaking: true, - drop: ['console', 'debugger'], -}).catch(() => process.exit(1)); \ No newline at end of file diff --git a/jest.config.cjs b/jest.config.cjs index 9106896..6703f87 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -16,9 +16,7 @@ module.exports = { moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1' }, - transformIgnorePatterns: [ - 'node_modules/(?!(unpdf|@babel)/)' - ], + transformIgnorePatterns: ['node_modules/'], collectCoverageFrom: [ 'src/**/*.ts', '!src/**/*.test.ts', @@ -26,4 +24,12 @@ module.exports = { ], coverageDirectory: 'coverage', coverageReporters: ['text', 'lcov', 'html'], -}; \ No newline at end of file + coverageThreshold: { + global: { + branches: 97, + functions: 97, + lines: 97, + statements: 97, + }, + }, +}; diff --git a/knip.json b/knip.json new file mode 100644 index 0000000..50b9963 --- /dev/null +++ b/knip.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://unpkg.com/knip@6/schema.json", + "entry": [ + "tests/unit/**/*.test.ts", + "tests/e2e/**/*.js" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts", + "tests/**/*.js", + "bin/**/*.js", + "*.js", + "*.cjs" + ], + "ignore": [ + "tests/fixtures/expected-test-resume-profile.d.ts" + ] +} diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index aed8a37..0000000 --- a/package-lock.json +++ /dev/null @@ -1,7156 +0,0 @@ -{ - "name": "@zalko/linkedin-parser", - "version": "1.0.2", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@zalko/linkedin-parser", - "version": "1.0.2", - "license": "MIT", - "dependencies": { - "pdfjs-dist": "^5.4.394" - }, - "devDependencies": { - "@rollup/plugin-node-resolve": "^16.0.3", - "@rollup/plugin-typescript": "^12.3.0", - "@types/jest": "^30.0.0", - "@types/node": "^20.10.5", - "@typescript-eslint/eslint-plugin": "^8.48.0", - "@typescript-eslint/parser": "^8.48.0", - "esbuild": "^0.27.0", - "eslint": "^9.39.1", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-prettier": "^5.5.4", - "jest": "^30.2.0", - "prettier": "^3.7.1", - "rollup": "^4.53.3", - "terser": "^5.44.1", - "ts-jest": "^29.4.5", - "tslib": "^2.8.1", - "typescript": "^5.3.3" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.5" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-bigint": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.12.13" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-static-block": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", - "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", - "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", - "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-private-property-in-object": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", - "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", - "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@emnapi/core": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", - "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", - "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz", - "integrity": "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.0.tgz", - "integrity": "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.0.tgz", - "integrity": "sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.0.tgz", - "integrity": "sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.0.tgz", - "integrity": "sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.0.tgz", - "integrity": "sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.0.tgz", - "integrity": "sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.0.tgz", - "integrity": "sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.0.tgz", - "integrity": "sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.0.tgz", - "integrity": "sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.0.tgz", - "integrity": "sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.0.tgz", - "integrity": "sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.0.tgz", - "integrity": "sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.0.tgz", - "integrity": "sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.0.tgz", - "integrity": "sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.0.tgz", - "integrity": "sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.0.tgz", - "integrity": "sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.0.tgz", - "integrity": "sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.0.tgz", - "integrity": "sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.0.tgz", - "integrity": "sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.0.tgz", - "integrity": "sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.0.tgz", - "integrity": "sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.0.tgz", - "integrity": "sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.0.tgz", - "integrity": "sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.0.tgz", - "integrity": "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.0.tgz", - "integrity": "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/eslintrc/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz", - "integrity": "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/console": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.2.0.tgz", - "integrity": "sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.2.0", - "@types/node": "*", - "chalk": "^4.1.2", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/core": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.2.0.tgz", - "integrity": "sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "30.2.0", - "@jest/pattern": "30.0.1", - "@jest/reporters": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "ansi-escapes": "^4.3.2", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "exit-x": "^0.2.2", - "graceful-fs": "^4.2.11", - "jest-changed-files": "30.2.0", - "jest-config": "30.2.0", - "jest-haste-map": "30.2.0", - "jest-message-util": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-resolve-dependencies": "30.2.0", - "jest-runner": "30.2.0", - "jest-runtime": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "jest-watcher": "30.2.0", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/diff-sequences": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", - "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/environment": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", - "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "jest-mock": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/expect": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.2.0.tgz", - "integrity": "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "expect": "30.2.0", - "jest-snapshot": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/expect-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", - "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.1.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/fake-timers": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", - "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.2.0", - "@sinonjs/fake-timers": "^13.0.0", - "@types/node": "*", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/get-type": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", - "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/globals": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.2.0.tgz", - "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "30.2.0", - "@jest/expect": "30.2.0", - "@jest/types": "30.2.0", - "jest-mock": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/pattern": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", - "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "jest-regex-util": "30.0.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/reporters": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.2.0.tgz", - "integrity": "sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", - "@jridgewell/trace-mapping": "^0.3.25", - "@types/node": "*", - "chalk": "^4.1.2", - "collect-v8-coverage": "^1.0.2", - "exit-x": "^0.2.2", - "glob": "^10.3.10", - "graceful-fs": "^4.2.11", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^6.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^5.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "jest-worker": "30.2.0", - "slash": "^3.0.0", - "string-length": "^4.0.2", - "v8-to-istanbul": "^9.0.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/schemas": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", - "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.34.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/snapshot-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz", - "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.2.0", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "natural-compare": "^1.4.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/source-map": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", - "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "callsites": "^3.1.0", - "graceful-fs": "^4.2.11" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/test-result": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.2.0.tgz", - "integrity": "sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "30.2.0", - "@jest/types": "30.2.0", - "@types/istanbul-lib-coverage": "^2.0.6", - "collect-v8-coverage": "^1.0.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/test-sequencer": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.2.0.tgz", - "integrity": "sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/test-result": "30.2.0", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/transform": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.2.0.tgz", - "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.27.4", - "@jest/types": "30.2.0", - "@jridgewell/trace-mapping": "^0.3.25", - "babel-plugin-istanbul": "^7.0.1", - "chalk": "^4.1.2", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-util": "30.2.0", - "micromatch": "^4.0.8", - "pirates": "^4.0.7", - "slash": "^3.0.0", - "write-file-atomic": "^5.0.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/pattern": "30.0.1", - "@jest/schemas": "30.0.5", - "@types/istanbul-lib-coverage": "^2.0.6", - "@types/istanbul-reports": "^3.0.4", - "@types/node": "*", - "@types/yargs": "^17.0.33", - "chalk": "^4.1.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", - "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@napi-rs/canvas": { - "version": "0.1.83", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.83.tgz", - "integrity": "sha512-f9GVB9VNc9vn/nroc9epXRNkVpvNPZh69+qzLJIm9DfruxFqX0/jsXG46OGWAJgkO4mN0HvFHjRROMXKVmPszg==", - "license": "MIT", - "optional": true, - "workspaces": [ - "e2e/*" - ], - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@napi-rs/canvas-android-arm64": "0.1.83", - "@napi-rs/canvas-darwin-arm64": "0.1.83", - "@napi-rs/canvas-darwin-x64": "0.1.83", - "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.83", - "@napi-rs/canvas-linux-arm64-gnu": "0.1.83", - "@napi-rs/canvas-linux-arm64-musl": "0.1.83", - "@napi-rs/canvas-linux-riscv64-gnu": "0.1.83", - "@napi-rs/canvas-linux-x64-gnu": "0.1.83", - "@napi-rs/canvas-linux-x64-musl": "0.1.83", - "@napi-rs/canvas-win32-x64-msvc": "0.1.83" - } - }, - "node_modules/@napi-rs/canvas-android-arm64": { - "version": "0.1.83", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.83.tgz", - "integrity": "sha512-TbKM2fh9zXjqFIU8bgMfzG7rkrIYdLKMafgPhFoPwKrpWk1glGbWP7LEu8Y/WrMDqTGFdRqUmuX89yQEzZbkiw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-darwin-arm64": { - "version": "0.1.83", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.83.tgz", - "integrity": "sha512-gp8IDVUloPUmkepHly4xRUOfUJSFNvA4jR7ZRF5nk3YcGzegSFGeICiT4PnYyPgSKEhYAFe1Y2XNy0Mp6Tu8mQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-darwin-x64": { - "version": "0.1.83", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.83.tgz", - "integrity": "sha512-r4ZJxiP9OgUbdGZhPDEXD3hQ0aIPcVaywtcTXvamYxTU/SWKAbKVhFNTtpRe1J30oQ25gWyxTkUKSBgUkNzdnw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { - "version": "0.1.83", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.83.tgz", - "integrity": "sha512-Uc6aSB05qH1r+9GUDxIE6F5ZF7L0nTFyyzq8ublWUZhw8fEGK8iy931ff1ByGFT04+xHJad1kBcL4R1ZEV8z7Q==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-arm64-gnu": { - "version": "0.1.83", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.83.tgz", - "integrity": "sha512-eEeaJA7V5KOFq7W0GtoRVbd3ak8UZpK+XLkCgUiFGtlunNw+ZZW9Cr/92MXflGe7o3SqqMUg+f975LPxO/vsOQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-arm64-musl": { - "version": "0.1.83", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.83.tgz", - "integrity": "sha512-cAvonp5XpbatVGegF9lMQNchs3z5RH6EtamRVnQvtoRtwbzOMcdzwuLBqDBQxQF79MFbuZNkWj3YRJjZCjHVzw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { - "version": "0.1.83", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.83.tgz", - "integrity": "sha512-WFUPQ9qZy31vmLxIJ3MfmHw+R2g/mLCgk8zmh7maJW8snV3vLPA7pZfIS65Dc61EVDp1vaBskwQ2RqPPzwkaew==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-x64-gnu": { - "version": "0.1.83", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.83.tgz", - "integrity": "sha512-X9YwIjsuy50WwOyYeNhEHjKHO8rrfH9M4U8vNqLuGmqsZdKua/GrUhdQGdjq7lTgdY3g4+Ta5jF8MzAa7UAs/g==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-linux-x64-musl": { - "version": "0.1.83", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.83.tgz", - "integrity": "sha512-Vv2pLWQS8EnlSM1bstJ7vVhKA+mL4+my4sKUIn/bgIxB5O90dqiDhQjUDLP+5xn9ZMestRWDt3tdQEkGAmzq/A==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/canvas-win32-x64-msvc": { - "version": "0.1.83", - "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.83.tgz", - "integrity": "sha512-K1TtjbScfRNYhq8dengLLufXGbtEtWdUXPV505uLFPovyGHzDUGXLFP/zUJzj6xWXwgUjHNLgEPIt7mye0zr6Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@pkgr/core": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", - "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/pkgr" - } - }, - "node_modules/@rollup/plugin-node-resolve": { - "version": "16.0.3", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", - "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "@types/resolve": "1.20.2", - "deepmerge": "^4.2.2", - "is-module": "^1.0.0", - "resolve": "^1.22.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^2.78.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/plugin-typescript": { - "version": "12.3.0", - "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-12.3.0.tgz", - "integrity": "sha512-7DP0/p7y3t67+NabT9f8oTBFE6gGkto4SA6Np2oudYmZE/m1dt8RB0SjL1msMxFpLo631qjRCcBlAbq1ml/Big==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.1.0", - "resolve": "^1.22.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^2.14.0||^3.0.0||^4.0.0", - "tslib": "*", - "typescript": ">=3.7.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - }, - "tslib": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", - "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, - "node_modules/@rollup/pluginutils/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", - "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", - "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.3.tgz", - "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", - "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", - "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", - "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", - "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", - "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", - "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", - "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", - "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", - "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", - "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", - "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", - "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", - "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", - "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", - "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", - "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", - "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", - "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", - "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@sinclair/typebox": { - "version": "0.34.41", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", - "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1" - } - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", - "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-coverage": "*" - } - }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", - "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, - "node_modules/@types/jest": { - "version": "30.0.0", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", - "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", - "dev": true, - "license": "MIT", - "dependencies": { - "expect": "^30.0.0", - "pretty-format": "^30.0.0" - } - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "20.19.25", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", - "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/resolve": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", - "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/stack-utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", - "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/yargs": { - "version": "17.0.35", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", - "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz", - "integrity": "sha512-XxXP5tL1txl13YFtrECECQYeZjBZad4fyd3cFV4a19LkAY/bIp9fev3US4S5fDVV2JaYFiKAZ/GRTOLer+mbyQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.48.0", - "@typescript-eslint/type-utils": "8.48.0", - "@typescript-eslint/utils": "8.48.0", - "@typescript-eslint/visitor-keys": "8.48.0", - "graphemer": "^1.4.0", - "ignore": "^7.0.0", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.48.0", - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.0.tgz", - "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/scope-manager": "8.48.0", - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/typescript-estree": "8.48.0", - "@typescript-eslint/visitor-keys": "8.48.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.0.tgz", - "integrity": "sha512-Ne4CTZyRh1BecBf84siv42wv5vQvVmgtk8AuiEffKTUo3DrBaGYZueJSxxBZ8fjk/N3DrgChH4TOdIOwOwiqqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.48.0", - "@typescript-eslint/types": "^8.48.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.0.tgz", - "integrity": "sha512-uGSSsbrtJrLduti0Q1Q9+BF1/iFKaxGoQwjWOIVNJv0o6omrdyR8ct37m4xIl5Zzpkp69Kkmvom7QFTtue89YQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/visitor-keys": "8.48.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.0.tgz", - "integrity": "sha512-WNebjBdFdyu10sR1M4OXTt2OkMd5KWIL+LLfeH9KhgP+jzfDV/LI3eXzwJ1s9+Yc0Kzo2fQCdY/OpdusCMmh6w==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.0.tgz", - "integrity": "sha512-zbeVaVqeXhhab6QNEKfK96Xyc7UQuoFWERhEnj3mLVnUWrQnv15cJNseUni7f3g557gm0e46LZ6IJ4NJVOgOpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/typescript-estree": "8.48.0", - "@typescript-eslint/utils": "8.48.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.0.tgz", - "integrity": "sha512-cQMcGQQH7kwKoVswD1xdOytxQR60MWKM1di26xSUtxehaDs/32Zpqsu5WJlXTtTTqyAVK8R7hvsUnIXRS+bjvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.0.tgz", - "integrity": "sha512-ljHab1CSO4rGrQIAyizUS6UGHHCiAYhbfcIZ1zVJr5nMryxlXMVWS3duFPSKvSUbFPwkXMFk1k0EMIjub4sRRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.48.0", - "@typescript-eslint/tsconfig-utils": "8.48.0", - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/visitor-keys": "8.48.0", - "debug": "^4.3.4", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.0.tgz", - "integrity": "sha512-yTJO1XuGxCsSfIVt1+1UrLHtue8xz16V8apzPYI06W0HbEbEWHxHXgZaAgavIkoh+GeV6hKKd5jm0sS6OYxWXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.48.0", - "@typescript-eslint/types": "8.48.0", - "@typescript-eslint/typescript-estree": "8.48.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <6.0.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.48.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.0.tgz", - "integrity": "sha512-T0XJMaRPOH3+LBbAfzR2jalckP1MSG/L9eUtY0DEzUyVaXJ/t6zN0nR7co5kz0Jko/nkSYCBRkz1djvjajVTTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.48.0", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, - "license": "ISC" - }, - "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", - "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", - "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", - "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", - "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", - "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", - "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", - "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", - "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", - "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", - "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", - "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", - "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", - "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", - "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", - "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", - "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", - "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", - "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", - "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/babel-jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", - "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/transform": "30.2.0", - "@types/babel__core": "^7.20.5", - "babel-plugin-istanbul": "^7.0.1", - "babel-preset-jest": "30.2.0", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "slash": "^3.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.11.0 || ^8.0.0-0" - } - }, - "node_modules/babel-plugin-istanbul": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", - "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", - "dev": true, - "license": "BSD-3-Clause", - "workspaces": [ - "test/babel-8" - ], - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-instrument": "^6.0.2", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/babel-plugin-jest-hoist": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz", - "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/babel__core": "^7.20.5" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/babel-preset-current-node-syntax": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", - "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.12.13", - "@babel/plugin-syntax-class-static-block": "^7.14.5", - "@babel/plugin-syntax-import-attributes": "^7.24.7", - "@babel/plugin-syntax-import-meta": "^7.10.4", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.10.4", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5", - "@babel/plugin-syntax-top-level-await": "^7.14.5" - }, - "peerDependencies": { - "@babel/core": "^7.0.0 || ^8.0.0-0" - } - }, - "node_modules/babel-preset-jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", - "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "babel-plugin-jest-hoist": "30.2.0", - "babel-preset-current-node-syntax": "^1.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.11.0 || ^8.0.0-beta.1" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.8.31", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz", - "integrity": "sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, - "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.28.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.0.tgz", - "integrity": "sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.8.25", - "caniuse-lite": "^1.0.30001754", - "electron-to-chromium": "^1.5.249", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.1.4" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/bs-logger": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", - "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-json-stable-stringify": "2.x" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "node-int64": "^0.4.0" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001757", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001757.tgz", - "integrity": "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/ci-info": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", - "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cjs-module-lexer": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.1.tgz", - "integrity": "sha512-+CmxIZ/L2vNcEfvNtLdU0ZQ6mbq3FZnwAP2PPTiKP+1QOoKwlKlPgb8UKV0Dds7QVaMnHm+FwSft2VB0s/SLjQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">= 1.0.0", - "node": ">= 0.12.0" - } - }, - "node_modules/collect-v8-coverage": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", - "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", - "dev": true, - "license": "MIT" - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/dedent": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", - "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "babel-plugin-macros": "^3.1.0" - }, - "peerDependenciesMeta": { - "babel-plugin-macros": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/detect-newline": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.262", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.262.tgz", - "integrity": "sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/emittery": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" - } - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/esbuild": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.0.tgz", - "integrity": "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.0", - "@esbuild/android-arm": "0.27.0", - "@esbuild/android-arm64": "0.27.0", - "@esbuild/android-x64": "0.27.0", - "@esbuild/darwin-arm64": "0.27.0", - "@esbuild/darwin-x64": "0.27.0", - "@esbuild/freebsd-arm64": "0.27.0", - "@esbuild/freebsd-x64": "0.27.0", - "@esbuild/linux-arm": "0.27.0", - "@esbuild/linux-arm64": "0.27.0", - "@esbuild/linux-ia32": "0.27.0", - "@esbuild/linux-loong64": "0.27.0", - "@esbuild/linux-mips64el": "0.27.0", - "@esbuild/linux-ppc64": "0.27.0", - "@esbuild/linux-riscv64": "0.27.0", - "@esbuild/linux-s390x": "0.27.0", - "@esbuild/linux-x64": "0.27.0", - "@esbuild/netbsd-arm64": "0.27.0", - "@esbuild/netbsd-x64": "0.27.0", - "@esbuild/openbsd-arm64": "0.27.0", - "@esbuild/openbsd-x64": "0.27.0", - "@esbuild/openharmony-arm64": "0.27.0", - "@esbuild/sunos-x64": "0.27.0", - "@esbuild/win32-arm64": "0.27.0", - "@esbuild/win32-ia32": "0.27.0", - "@esbuild/win32-x64": "0.27.0" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/eslint": { - "version": "9.39.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz", - "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.1", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-config-prettier": { - "version": "10.1.8", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", - "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", - "dev": true, - "license": "MIT", - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "funding": { - "url": "https://opencollective.com/eslint-config-prettier" - }, - "peerDependencies": { - "eslint": ">=7.0.0" - } - }, - "node_modules/eslint-plugin-prettier": { - "version": "5.5.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz", - "integrity": "sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "prettier-linter-helpers": "^1.0.0", - "synckit": "^0.11.7" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint-plugin-prettier" - }, - "peerDependencies": { - "@types/eslint": ">=8.0.0", - "eslint": ">=8.0.0", - "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", - "prettier": ">=3.0.0" - }, - "peerDependenciesMeta": { - "@types/eslint": { - "optional": true - }, - "eslint-config-prettier": { - "optional": true - } - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/eslint/node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/eslint/node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/execa/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/exit-x": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", - "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/expect": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", - "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/expect-utils": "30.2.0", - "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-diff": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", - "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bser": "2.1.1" - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, - "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" - }, - "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" - } - }, - "node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/import-fresh/node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/import-local": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", - "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - }, - "bin": { - "import-local-fixture": "fixtures/cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-generator-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", - "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", - "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/core": "30.2.0", - "@jest/types": "30.2.0", - "import-local": "^3.2.0", - "jest-cli": "30.2.0" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/jest-changed-files": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.2.0.tgz", - "integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "execa": "^5.1.1", - "jest-util": "30.2.0", - "p-limit": "^3.1.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-circus": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.2.0.tgz", - "integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "30.2.0", - "@jest/expect": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "chalk": "^4.1.2", - "co": "^4.6.0", - "dedent": "^1.6.0", - "is-generator-fn": "^2.1.0", - "jest-each": "30.2.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-runtime": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", - "p-limit": "^3.1.0", - "pretty-format": "30.2.0", - "pure-rand": "^7.0.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-cli": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.2.0.tgz", - "integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/core": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", - "chalk": "^4.1.2", - "exit-x": "^0.2.2", - "import-local": "^3.2.0", - "jest-config": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "yargs": "^17.7.2" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/jest-config": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz", - "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.27.4", - "@jest/get-type": "30.1.0", - "@jest/pattern": "30.0.1", - "@jest/test-sequencer": "30.2.0", - "@jest/types": "30.2.0", - "babel-jest": "30.2.0", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "deepmerge": "^4.3.1", - "glob": "^10.3.10", - "graceful-fs": "^4.2.11", - "jest-circus": "30.2.0", - "jest-docblock": "30.2.0", - "jest-environment-node": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-runner": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "micromatch": "^4.0.8", - "parse-json": "^5.2.0", - "pretty-format": "30.2.0", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "@types/node": "*", - "esbuild-register": ">=3.4.0", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "esbuild-register": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/jest-diff": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", - "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/diff-sequences": "30.0.1", - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "pretty-format": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-docblock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", - "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "detect-newline": "^3.1.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-each": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.2.0.tgz", - "integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.1.0", - "@jest/types": "30.2.0", - "chalk": "^4.1.2", - "jest-util": "30.2.0", - "pretty-format": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-environment-node": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.2.0.tgz", - "integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "30.2.0", - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "jest-mock": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-haste-map": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", - "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.2.0", - "@types/node": "*", - "anymatch": "^3.1.3", - "fb-watchman": "^2.0.2", - "graceful-fs": "^4.2.11", - "jest-regex-util": "30.0.1", - "jest-util": "30.2.0", - "jest-worker": "30.2.0", - "micromatch": "^4.0.8", - "walker": "^1.0.8" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.3" - } - }, - "node_modules/jest-leak-detector": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz", - "integrity": "sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.1.0", - "pretty-format": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-matcher-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", - "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "jest-diff": "30.2.0", - "pretty-format": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-message-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", - "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@jest/types": "30.2.0", - "@types/stack-utils": "^2.0.3", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-mock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", - "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.2.0", - "@types/node": "*", - "jest-util": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "peerDependencies": { - "jest-resolve": "*" - }, - "peerDependenciesMeta": { - "jest-resolve": { - "optional": true - } - } - }, - "node_modules/jest-regex-util": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", - "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-resolve": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.2.0.tgz", - "integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "slash": "^3.0.0", - "unrs-resolver": "^1.7.11" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-resolve-dependencies": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz", - "integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-regex-util": "30.0.1", - "jest-snapshot": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-runner": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.2.0.tgz", - "integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "30.2.0", - "@jest/environment": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "chalk": "^4.1.2", - "emittery": "^0.13.1", - "exit-x": "^0.2.2", - "graceful-fs": "^4.2.11", - "jest-docblock": "30.2.0", - "jest-environment-node": "30.2.0", - "jest-haste-map": "30.2.0", - "jest-leak-detector": "30.2.0", - "jest-message-util": "30.2.0", - "jest-resolve": "30.2.0", - "jest-runtime": "30.2.0", - "jest-util": "30.2.0", - "jest-watcher": "30.2.0", - "jest-worker": "30.2.0", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-runtime": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", - "integrity": "sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "30.2.0", - "@jest/fake-timers": "30.2.0", - "@jest/globals": "30.2.0", - "@jest/source-map": "30.0.1", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "chalk": "^4.1.2", - "cjs-module-lexer": "^2.1.0", - "collect-v8-coverage": "^1.0.2", - "glob": "^10.3.10", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-snapshot": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", - "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.27.4", - "@babel/generator": "^7.27.5", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.27.1", - "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.2.0", - "@jest/get-type": "30.1.0", - "@jest/snapshot-utils": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", - "babel-preset-current-node-syntax": "^1.2.0", - "chalk": "^4.1.2", - "expect": "30.2.0", - "graceful-fs": "^4.2.11", - "jest-diff": "30.2.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "pretty-format": "30.2.0", - "semver": "^7.7.2", - "synckit": "^0.11.8" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.2.0", - "@types/node": "*", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-util/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/jest-validate": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", - "integrity": "sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.1.0", - "@jest/types": "30.2.0", - "camelcase": "^6.3.0", - "chalk": "^4.1.2", - "leven": "^3.1.0", - "pretty-format": "30.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-validate/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/jest-watcher": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.2.0.tgz", - "integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", - "@types/node": "*", - "ansi-escapes": "^4.3.2", - "chalk": "^4.1.2", - "emittery": "^0.13.1", - "jest-util": "30.2.0", - "string-length": "^4.0.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-worker": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", - "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.2.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.1.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/lodash.memoize": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-dir/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC" - }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tmpl": "1.0.5" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/napi-postinstall": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", - "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", - "dev": true, - "license": "MIT", - "bin": { - "napi-postinstall": "lib/cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/napi-postinstall" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-locate/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/pdfjs-dist": { - "version": "5.4.394", - "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.394.tgz", - "integrity": "sha512-9ariAYGqUJzx+V/1W4jHyiyCep6IZALmDzoaTLZ6VNu8q9LWi1/ukhzHgE2Xsx96AZi0mbZuK4/ttIbqSbLypg==", - "license": "Apache-2.0", - "engines": { - "node": ">=20.16.0 || >=22.3.0" - }, - "optionalDependencies": { - "@napi-rs/canvas": "^0.1.81" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prettier": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.1.tgz", - "integrity": "sha512-RWKXE4qB3u5Z6yz7omJkjWwmTfLdcbv44jUVHC5NpfXwFGzvpQM798FGv/6WNK879tc+Cn0AAyherCl1KjbyZQ==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prettier-linter-helpers": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", - "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-diff": "^1.1.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "30.0.5", - "ansi-styles": "^5.2.0", - "react-is": "^18.3.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/pure-rand": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", - "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, - "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/rollup": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", - "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.3", - "@rollup/rollup-android-arm64": "4.53.3", - "@rollup/rollup-darwin-arm64": "4.53.3", - "@rollup/rollup-darwin-x64": "4.53.3", - "@rollup/rollup-freebsd-arm64": "4.53.3", - "@rollup/rollup-freebsd-x64": "4.53.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", - "@rollup/rollup-linux-arm-musleabihf": "4.53.3", - "@rollup/rollup-linux-arm64-gnu": "4.53.3", - "@rollup/rollup-linux-arm64-musl": "4.53.3", - "@rollup/rollup-linux-loong64-gnu": "4.53.3", - "@rollup/rollup-linux-ppc64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-musl": "4.53.3", - "@rollup/rollup-linux-s390x-gnu": "4.53.3", - "@rollup/rollup-linux-x64-gnu": "4.53.3", - "@rollup/rollup-linux-x64-musl": "4.53.3", - "@rollup/rollup-openharmony-arm64": "4.53.3", - "@rollup/rollup-win32-arm64-msvc": "4.53.3", - "@rollup/rollup-win32-ia32-msvc": "4.53.3", - "@rollup/rollup-win32-x64-gnu": "4.53.3", - "@rollup/rollup-win32-x64-msvc": "4.53.3", - "fsevents": "~2.3.2" - } - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/string-length": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", - "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "char-regex": "^1.0.2", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/string-length/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-length/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/synckit": { - "version": "0.11.11", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", - "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@pkgr/core": "^0.2.9" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/synckit" - } - }, - "node_modules/terser": { - "version": "5.44.1", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.1.tgz", - "integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.15.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser/node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/test-exclude/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/test-exclude/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "node_modules/ts-jest": { - "version": "29.4.5", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.5.tgz", - "integrity": "sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "bs-logger": "^0.2.6", - "fast-json-stable-stringify": "^2.1.0", - "handlebars": "^4.7.8", - "json5": "^2.2.3", - "lodash.memoize": "^4.1.2", - "make-error": "^1.3.6", - "semver": "^7.7.3", - "type-fest": "^4.41.0", - "yargs-parser": "^21.1.1" - }, - "bin": { - "ts-jest": "cli.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "@babel/core": ">=7.0.0-beta.0 <8", - "@jest/transform": "^29.0.0 || ^30.0.0", - "@jest/types": "^29.0.0 || ^30.0.0", - "babel-jest": "^29.0.0 || ^30.0.0", - "jest": "^29.0.0 || ^30.0.0", - "jest-util": "^29.0.0 || ^30.0.0", - "typescript": ">=4.3 <6" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "@jest/transform": { - "optional": true - }, - "@jest/types": { - "optional": true - }, - "babel-jest": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jest-util": { - "optional": true - } - } - }, - "node_modules/ts-jest/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/ts-jest/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD" - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/uglify-js": { - "version": "3.19.3", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", - "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "bin": { - "uglifyjs": "bin/uglifyjs" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/unrs-resolver": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", - "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "napi-postinstall": "^0.3.0" - }, - "funding": { - "url": "https://opencollective.com/unrs-resolver" - }, - "optionalDependencies": { - "@unrs/resolver-binding-android-arm-eabi": "1.11.1", - "@unrs/resolver-binding-android-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-x64": "1.11.1", - "@unrs/resolver-binding-freebsd-x64": "1.11.1", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", - "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-musl": "1.11.1", - "@unrs/resolver-binding-wasm32-wasi": "1.11.1", - "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", - "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", - "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", - "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/v8-to-istanbul": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", - "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", - "dev": true, - "license": "ISC", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.12", - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^2.0.0" - }, - "engines": { - "node": ">=10.12.0" - } - }, - "node_modules/walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "makeerror": "1.0.12" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/wordwrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/write-file-atomic": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", - "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/package.json b/package.json index 0dd4f64..b1a1383 100644 --- a/package.json +++ b/package.json @@ -1,80 +1,137 @@ { - "name": "@zalko/linkedin-parser", - "version": "1.0.2", - "description": "LinkedIn resume PDF parser with comprehensive Jest testing and test data generation", - "main": "dist/index.cjs", - "module": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.cjs" - } - }, - "author": { - "name": "Arkady Zalkowitsch", - "email": "arkady@zalko.com" - }, - "license": "MIT", - "keywords": [ - "pdf", - "parser", - "resume", - "linkedin", - "jest", - "testing" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/zalkowitsch/linkedin-parser.git" - }, - "homepage": "https://github.com/zalkowitsch/linkedin-parser#readme", - "engines": { - "node": ">=18.0.0" - }, - "scripts": { - "build": "npm run clean && tsc && npm run build:bundle && npm run build:minify", - "build:bundle": "rollup -c", - "build:minify": "node esbuild.config.js", - "build:dev": "tsc", - "format": "prettier --write \"src/**/*.{ts,tsx}\"", - "format:check": "prettier --check \"src/**/*.{ts,tsx}\"", - "lint": "eslint src/**/*.ts", - "lint:fix": "eslint src/**/*.ts --fix", - "test": "jest", - "test:watch": "jest --watch", - "test:coverage": "jest --coverage", - "clean": "rm -rf dist coverage", - "size:check": "ls -lh dist/index.* | awk '{print $5, $9}'", - "generate:pdf": "npx tsx utils/generate-pdf.ts", - "quality:check": "npm run lint && npm run format:check && npm run test:coverage", - "prepublishOnly": "npm run quality:check && npm run build" - }, - "files": [ - "dist" - ], - "dependencies": { - "pdfjs-dist": "^5.4.394" - }, - "devDependencies": { - "@rollup/plugin-node-resolve": "^16.0.3", - "@rollup/plugin-typescript": "^12.3.0", - "@types/jest": "^30.0.0", - "@types/node": "^20.10.5", - "@typescript-eslint/eslint-plugin": "^8.48.0", - "@typescript-eslint/parser": "^8.48.0", - "esbuild": "^0.27.0", - "eslint": "^9.39.1", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-prettier": "^5.5.4", - "jest": "^30.2.0", - "prettier": "^3.7.1", - "rollup": "^4.53.3", - "terser": "^5.44.1", - "ts-jest": "^29.4.5", - "tslib": "^2.8.1", - "typescript": "^5.3.3" - }, - "type": "module" + "name": "linkedin-parser-serverless", + "version": "2.1.0", + "description": "Parse LinkedIn PDF exports into structured data with unpdf. Serverless-ready TypeScript library for Node, Vercel Edge, and other JS runtimes.", + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "bin": { + "linkedin-pdf-parser": "./bin/cli.js" + }, + "author": { + "name": "Harold Martin", + "email": "harold.martin@gmail.com", + "url": "https://www.linkedin.com/in/harold-martin-98526971/" + }, + "license": "MIT", + "keywords": [ + "pdf", + "parser", + "resume", + "linkedin" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/hbmartin/linkedin-parser-serverless.git" + }, + "homepage": "https://github.com/hbmartin/linkedin-parser-serverless#readme", + "engines": { + "node": ">=22.0.0" + }, + "scripts": { + "build": "pnpm run clean && tsc --noEmit && pnpm run build:bundle && pnpm run build:types:cjs", + "build:bundle": "rollup -c", + "build:types:cjs": "cp dist/index.d.ts dist/index.d.cts", + "build:dev": "tsc", + "check": "pnpm run format && pnpm run dupes && pnpm run test && pnpm run build && pnpm run knip", + "clean": "rm -rf dist coverage", + "cli": "pnpm run build && node bin/cli.js", + "cli:built": "node bin/cli.js", + "dupes": "jscpd", + "format": "prettier --write \"src/**/*.{ts,tsx}\"", + "format:check": "prettier --check \"src/**/*.{ts,tsx}\"", + "fta": "fta src", + "knip": "knip", + "lint": "eslint src/**/*.ts", + "lint:fix": "eslint src/**/*.ts --fix", + "prepublishOnly": "pnpm run quality:check", + "publint": "pnpm exec publint --pack npm", + "quality:check": "pnpm run lint && pnpm run format:check && pnpm run dupes && pnpm run type-coverage && pnpm run build && pnpm run verify:artifacts && pnpm run verify:package && pnpm run knip && pnpm run publint && pnpm run types:lint && pnpm run test:coverage", + "samples:audit-coverage": "node scripts/sample-completeness-audit.mjs", + "samples:check-warnings": "pnpm run build && node scripts/check-sample-warnings.mjs", + "samples:verify": "node scripts/verify-samples.mjs", + "source:inspect": "pnpm run build && node scripts/inspect-pdf-source.mjs", + "size:check": "node scripts/check-size-budget.mjs", + "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", + "test:profile": "node --experimental-vm-modules node_modules/jest/bin/jest.js tests/unit/profile-fixture.test.ts", + "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch", + "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage", + "type-coverage": "type-coverage --project tsconfig.json --strict --at-least 100", + "types:lint": "npm_config_cache=.npm-cache attw --pack .", + "verify:artifacts": "node scripts/verify-artifacts.mjs", + "verify:package": "node scripts/verify-packed-package.mjs" + }, + "files": [ + "bin", + "dist" + ], + "devDependencies": { + "@arethetypeswrong/cli": "^0.18.2", + "@jest/globals": "^30.4.1", + "@rollup/plugin-node-resolve": "^16.0.3", + "@rollup/plugin-terser": "^1.0.0", + "@rollup/plugin-typescript": "^12.3.0", + "@types/jest": "^30.0.0", + "@types/node": "^22", + "@typescript-eslint/eslint-plugin": "^8.59.4", + "@typescript-eslint/parser": "^8.59.4", + "eslint": "^9.39.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "fta-cli": "^3.0.0", + "jest": "^30.2.0", + "jscpd": "^4.2.4", + "knip": "^6.14.2", + "prettier": "^3.7.1", + "publint": "^0.3.20", + "rollup": "^4.60.4", + "ts-jest": "^29.4.11", + "tslib": "^2.8.1", + "type-coverage": "^2.29.7", + "typescript": "^6.0.3" + }, + "type": "module", + "dependencies": { + "chrono-node": "^2.9.1", + "unpdf": "^1.6.2", + "zod": "^4.4.3" + }, + "resolutions": { + "@types/node": "^22" + }, + "packageManager": "pnpm@11.1.3", + "jscpd": { + "threshold": 0, + "reporters": [ + "json", + "console" + ], + "ignore": [ + "**/node_modules/**", + "**/.npm-cache/**", + "**/dist/**", + "**/coverage/**", + "**/test/**", + "**/src/generated/**", + "**/tests/fixtures/*.txt", + "**/*.json", + "**/*.test.ts", + "**/*.md", + "**/*.yml" + ], + "absolute": false + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..c54729a --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,5735 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + chrono-node: + specifier: ^2.9.1 + version: 2.9.1 + unpdf: + specifier: ^1.6.2 + version: 1.6.2 + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@arethetypeswrong/cli': + specifier: ^0.18.2 + version: 0.18.2 + '@jest/globals': + specifier: ^30.4.1 + version: 30.4.1 + '@rollup/plugin-node-resolve': + specifier: ^16.0.3 + version: 16.0.3(rollup@4.60.4) + '@rollup/plugin-terser': + specifier: ^1.0.0 + version: 1.0.0(rollup@4.60.4) + '@rollup/plugin-typescript': + specifier: ^12.3.0 + version: 12.3.0(rollup@4.60.4)(tslib@2.8.1)(typescript@6.0.3) + '@types/jest': + specifier: ^30.0.0 + version: 30.0.0 + '@types/node': + specifier: ^22 + version: 22.19.19 + '@typescript-eslint/eslint-plugin': + specifier: ^8.59.4 + version: 8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/parser': + specifier: ^8.59.4 + version: 8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + eslint: + specifier: ^9.39.1 + version: 9.39.4(jiti@2.7.0) + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@9.39.4(jiti@2.7.0)) + eslint-plugin-prettier: + specifier: ^5.5.4 + version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0))(prettier@3.8.3) + fta-cli: + specifier: ^3.0.0 + version: 3.0.0 + jest: + specifier: ^30.2.0 + version: 30.4.2(@types/node@22.19.19) + jscpd: + specifier: ^4.2.4 + version: 4.2.4 + knip: + specifier: ^6.14.2 + version: 6.14.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + prettier: + specifier: ^3.7.1 + version: 3.8.3 + publint: + specifier: ^0.3.20 + version: 0.3.21 + rollup: + specifier: ^4.60.4 + version: 4.60.4 + ts-jest: + specifier: ^29.4.11 + version: 29.4.11(@babel/core@7.29.0)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.0))(jest-util@30.4.1)(jest@30.4.2(@types/node@22.19.19))(typescript@6.0.3) + tslib: + specifier: ^2.8.1 + version: 2.8.1 + type-coverage: + specifier: ^2.29.7 + version: 2.29.7(typescript@6.0.3) + typescript: + specifier: ^6.0.3 + version: 6.0.3 + +packages: + + '@andrewbranch/untar.js@1.0.3': + resolution: {integrity: sha512-Jh15/qVmrLGhkKJBdXlK1+9tY4lZruYjsgkDFj08ZmDiWVBLJcqkok7Z0/R0In+i1rScBpJlSvrTS2Lm41Pbnw==} + + '@arethetypeswrong/cli@0.18.2': + resolution: {integrity: sha512-PcFM20JNlevEDKBg4Re29Rtv2xvjvQZzg7ENnrWFSS0PHgdP2njibVFw+dRUhNkPgNfac9iUqO0ohAXqQL4hbw==} + engines: {node: '>=20'} + hasBin: true + + '@arethetypeswrong/core@0.18.2': + resolution: {integrity: sha512-GiwTmBFOU1/+UVNqqCGzFJYfBXEytUkiI+iRZ6Qx7KmUVtLm00sYySkfe203C9QtPG11yOz1ZaMek8dT/xnlgg==} + engines: {node: '>=20'} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.3': + resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.28.6': + resolution: {integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + '@braidai/lang@1.1.2': + resolution: {integrity: sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==} + + '@colors/colors@1.5.0': + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + + '@jest/console@30.4.1': + resolution: {integrity: sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/core@30.4.2': + resolution: {integrity: sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/diff-sequences@30.4.0': + resolution: {integrity: sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/environment@30.4.1': + resolution: {integrity: sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/expect-utils@30.4.1': + resolution: {integrity: sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/expect@30.4.1': + resolution: {integrity: sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/fake-timers@30.4.1': + resolution: {integrity: sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/get-type@30.1.0': + resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/globals@30.4.1': + resolution: {integrity: sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/pattern@30.4.0': + resolution: {integrity: sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/reporters@30.4.1': + resolution: {integrity: sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/schemas@30.4.1': + resolution: {integrity: sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/snapshot-utils@30.4.1': + resolution: {integrity: sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/source-map@30.0.1': + resolution: {integrity: sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/test-result@30.4.1': + resolution: {integrity: sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/test-sequencer@30.4.1': + resolution: {integrity: sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/transform@30.4.1': + resolution: {integrity: sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/types@30.4.1': + resolution: {integrity: sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@jscpd/badge-reporter@4.2.4': + resolution: {integrity: sha512-g5vu05u0lX9rcHA0k3CptLfpOiuMzxh5+mUe2iYRAznTwH3ks6JAVAf9aPi5mBFttMCRiJh2zSt3xnSadHtMGg==} + + '@jscpd/core@4.2.4': + resolution: {integrity: sha512-9V9YzmmhYg9682kFqi+n0KGOhXNSoqxHbuIP3i/l/oSd6upBOnnSeBWDZMGOenQRQnyKEtCIbnS9YFz+3B+siQ==} + + '@jscpd/finder@4.2.4': + resolution: {integrity: sha512-4LLEuAAmAraud/TAAlB5BByVdWfy7SYiPKacj5yEggpkNs0qsw2kiZ5EyU3LonB+/vntJJEDDpJMmvOeS58e0A==} + + '@jscpd/html-reporter@4.2.4': + resolution: {integrity: sha512-6UljCTVGf7O+o6D6fs1zNBG+vR1PTn47W2mSgb5hzSrvNw60rLrVoAMZMnr/TeIEdd/OEgAu+icbdvvVBfnvJw==} + + '@jscpd/tokenizer@4.2.4': + resolution: {integrity: sha512-nM4kGyDvpcevt8t0zOsMQ82ShSc65c3LIQUHClTYwraiOGOmWgUQyen+JIiFCNF8eDCGR2Qa5iI5XBfGWYQzIg==} + + '@loaderkit/resolve@1.0.5': + resolution: {integrity: sha512-fhkdGM57xhJ7CO91MUgbQlb0ClP0AJ9vB3yoVnBTslYJqrJOCVEbOprZcxZlexdMbmTBPQqVcQYr+j4oRRtIZA==} + + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oxc-parser/binding-android-arm-eabi@0.130.0': + resolution: {integrity: sha512-h/xYU8/7ADWzVSf5I+YalLpj33LOy9CI/zgbJNIZ5eunRBG+Czqa3lZsvuPHHf3rOt6z1c5+UzoxjbAzAvhwVw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.130.0': + resolution: {integrity: sha512-oFWFJrsGv9siFM4HjMqKNB7IuIZD/SMmZdCXl8xyx7lDplGvPKyewpOo272rSWgMXe2Wx7bWI0Yj+gkHv4qbeg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.130.0': + resolution: {integrity: sha512-sGUzupdTplK9jQg7eJZ878HfEgQjJNBc6dAYVWJ9W5aU+J8rLfRJhTVsKThiu1pNwm6Y1qKCcbC6WhNWSXR3Ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.130.0': + resolution: {integrity: sha512-PsB4cdCISbC00Uy8eiD8bc2AkGWjZqrSrJnkBFuG2ptrrf6mZ2F5gLFSjOAVMMgZPg8B1D7OydJwLWSfyI2Plg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.130.0': + resolution: {integrity: sha512-DgABp3l38hS77JbXCV4qk1+n6DPym5u8zzwuweokezm2tX194nDSJDENbDRECxVsiNbprKATLbk+Z5wlHT0OHw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.130.0': + resolution: {integrity: sha512-4Kn3CTEmwFrzhTSC/JuUW16qovmaMdX7jeSKbL8w0pLtLww7To1a2XJi9Z5uD8QWUkfUHhqfV+VD6dVzBnWzoA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.130.0': + resolution: {integrity: sha512-D35KZM3F4rRu1uAFKyBlg3Gaf/ybCjyaPR1hfgvk5ex8NtcTmRgc0JgSighEyNg96TPrFhemFba68SZuxaha8w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.130.0': + resolution: {integrity: sha512-Q9o7oVlo955KHwS8l1u0bCzIx+JsZUA3XToLXC+MsMhye/9LeBQbt84nh120cl2XLy+TEzvugYDiHShg5yaX6Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-arm64-musl@0.130.0': + resolution: {integrity: sha512-EiJ/gC0ljbcwVpycC8YWw6ggMbtsPX8XMOt0mPx0aqWeMsNR+L9m05Flbvd5T+GlivG+GkSWQL7tM9SRFpM/dw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-ppc64-gnu@0.130.0': + resolution: {integrity: sha512-b+h/lsLLurp756dMGizNs5uPaJfyEdWrTcV5t8M609jWm1DEHB1StpRXCkyvwtkJx3m+qL5BNQ0dEKan/4yGFA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-gnu@0.130.0': + resolution: {integrity: sha512-O19Cil83XAyjEFfo8WhkMwY58ALqZ7ckjGL+25mjMIuF84urWBeANH0FC8B8BsSSygWU3/1aY3ADdDbp+wlBnw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-musl@0.130.0': + resolution: {integrity: sha512-BgXRVC0+83n3YzCscLQjj6nbyeBIVeZYPTI4fFMAE4WNm2+4RXhWp03IVizL7esIz36kgmT48aebk1iM+cs8sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-s390x-gnu@0.130.0': + resolution: {integrity: sha512-6tJz0xvnGhsokE7N1WlUSBXibpYmT9xSJFS1Ce41Km/+8gQvdlW8MLhRv8PD0L7ix8vRG0FDDepp3jdOFzdVdw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-gnu@0.130.0': + resolution: {integrity: sha512-9aCWj83dp3heTQGmGnZGdIWgxjZrr/7VQ0TGFHH5PKByxJKF2Hcr4qvaSUHhhGEa3MSsDjTL1YDP8RAgdL5/Cg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-musl@0.130.0': + resolution: {integrity: sha512-afXt87aZBqrUVli8TB/I8H1G50RDWcwirjWtXGXYqJ2ZqWEiErH7V72j3LUSDZaivmtu2OLX0KQ/mbhP81mr7A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-openharmony-arm64@0.130.0': + resolution: {integrity: sha512-I0NCrZV/YZuCGWgqwNN/GO/iXlLF2z+Wgc7u+Aa9N4P51oYeIa0XT+zVBUne4csO9GqxskXgI4g8JzzWGRpfOw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-wasm32-wasi@0.130.0': + resolution: {integrity: sha512-sJgQkGaBX0WJvPUDfwciex6IcTk5O5NLQ1bhEb6f3nBruh1GshKMRSMt2bxZlYrgBzjyBbJzsnO+InPG0bg+fA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@oxc-parser/binding-win32-arm64-msvc@0.130.0': + resolution: {integrity: sha512-bjcma99sQrNh6RY4mPO9yTkfxql6TDFoN3HWdK31RCKXwNhcDgJXW/l8PUtzKNiQ+9vpKJfJtQq+LklBuxSOBA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.130.0': + resolution: {integrity: sha512-hRYbv6HhpSTzT4xTiIkadLI7upLQxuOdLPR/9nL1fTjwhgutBTPXrwaAPb/jTFVx6/8C7Jb5HcUKhmNwloTbFA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.130.0': + resolution: {integrity: sha512-RBpA9TsRucJq6HNVNCFF1iKg+QeTkLdZf7hi4xaOGCPvMZWvDHjQgSOEZMUpuW4JNciHbxNhLEYmz5CVygjVGQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@oxc-project/types@0.130.0': + resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==} + + '@oxc-resolver/binding-android-arm-eabi@11.19.1': + resolution: {integrity: sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==} + cpu: [arm] + os: [android] + + '@oxc-resolver/binding-android-arm64@11.19.1': + resolution: {integrity: sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA==} + cpu: [arm64] + os: [android] + + '@oxc-resolver/binding-darwin-arm64@11.19.1': + resolution: {integrity: sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ==} + cpu: [arm64] + os: [darwin] + + '@oxc-resolver/binding-darwin-x64@11.19.1': + resolution: {integrity: sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ==} + cpu: [x64] + os: [darwin] + + '@oxc-resolver/binding-freebsd-x64@11.19.1': + resolution: {integrity: sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw==} + cpu: [x64] + os: [freebsd] + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1': + resolution: {integrity: sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm-musleabihf@11.19.1': + resolution: {integrity: sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-gnu@11.19.1': + resolution: {integrity: sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-arm64-musl@11.19.1': + resolution: {integrity: sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-ppc64-gnu@11.19.1': + resolution: {integrity: sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-gnu@11.19.1': + resolution: {integrity: sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-musl@11.19.1': + resolution: {integrity: sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-s390x-gnu@11.19.1': + resolution: {integrity: sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-x64-gnu@11.19.1': + resolution: {integrity: sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-x64-musl@11.19.1': + resolution: {integrity: sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-openharmony-arm64@11.19.1': + resolution: {integrity: sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA==} + cpu: [arm64] + os: [openharmony] + + '@oxc-resolver/binding-wasm32-wasi@11.19.1': + resolution: {integrity: sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-resolver/binding-win32-arm64-msvc@11.19.1': + resolution: {integrity: sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ==} + cpu: [arm64] + os: [win32] + + '@oxc-resolver/binding-win32-ia32-msvc@11.19.1': + resolution: {integrity: sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA==} + cpu: [ia32] + os: [win32] + + '@oxc-resolver/binding-win32-x64-msvc@11.19.1': + resolution: {integrity: sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw==} + cpu: [x64] + os: [win32] + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@pkgr/core@0.2.9': + resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + + '@publint/pack@0.1.4': + resolution: {integrity: sha512-HDVTWq3H0uTXiU0eeSQntcVUTPP3GamzeXI41+x7uU9J65JgWQh3qWZHblR1i0npXfFtF+mxBiU2nJH8znxWnQ==} + engines: {node: '>=18'} + + '@rollup/plugin-node-resolve@16.0.3': + resolution: {integrity: sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.78.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-terser@1.0.0': + resolution: {integrity: sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + rollup: ^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/plugin-typescript@12.3.0': + resolution: {integrity: sha512-7DP0/p7y3t67+NabT9f8oTBFE6gGkto4SA6Np2oudYmZE/m1dt8RB0SjL1msMxFpLo631qjRCcBlAbq1ml/Big==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.14.0||^3.0.0||^4.0.0 + tslib: '*' + typescript: '>=3.7.0' + peerDependenciesMeta: + rollup: + optional: true + tslib: + optional: true + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + + '@sinclair/typebox@0.34.49': + resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} + + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@15.4.0': + resolution: {integrity: sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==} + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/jest@30.0.0': + resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@22.19.19': + resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} + + '@types/resolve@1.20.2': + resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + + '@types/sarif@2.1.7': + resolution: {integrity: sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ==} + + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + + '@typescript-eslint/eslint-plugin@8.59.4': + resolution: {integrity: sha512-PegsU+XfyJJNjd4+u/k6f9yTyp0lEXXiPopUNobZcIAUJFGICFLN+sP0Rb3JehVmiij1Ph0dFGYqODoRo/2+6A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.4 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.59.4': + resolution: {integrity: sha512-zORHqO/tuhxY1zWuTvMUqddRxpiFJ72xVfcNoWpqdLjs6lfPbuQBJuW4pk+49/uBMy7Ssr4bzgjiKmmDB1UbZQ==} + 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.59.4': + resolution: {integrity: sha512-Ly00Vu4oAacfDeHp2Zg85ioNG6l8HG+tN1D7J+xTHSxu9y0awYKJ2zH1rFBn8ZSfuGK+7FxK3Cgl3uAz0aZZLg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.59.4': + resolution: {integrity: sha512-mUeR/3H1WrTAddJrwut8OoPjfauaztMQmRwV5fQTUyNVJCLiUXXe4lGEyYIL2oFDpP7UtgbGJXCt72wT0z2S3Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.59.4': + resolution: {integrity: sha512-DLCpnKgD4alVxTBSKulK+gU1KCqOgUXfDRDXh2mZgzokQKa/70ax93I2uVO3m/LLvIAtWZIFoiifudmIqAxpMA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.59.4': + resolution: {integrity: sha512-uonTuPAAKr9XaBGqJ3LjYTh72zy5DyGesljO9gtmk/eFW0W1fRHjnwVYKB35Lm8d5Q5CluEW3gPHjTvZTmgrfA==} + 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.59.4': + resolution: {integrity: sha512-F1o7WJcCq+bc8dwcO/YsSEOudAH8RDtaOhM6wcAQhcUsFhnWQl81JKy48q1hoxAU0qrzM89+31GYh1515Zde3Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.59.4': + resolution: {integrity: sha512-F+RuOmcDXo4+TPdfd/TCLS3m2nw8gE9XXyZLrA3JBfaA5tz9TtdkyD3YJFmPxulyc2cKbEok/CvFE3MgSLWnag==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.59.4': + resolution: {integrity: sha512-cYXeNAUsG4lJo5dbc1FcKm+JwIWrj1/UpTORsC6tGMjEZ81DYcvIr9/ueikhMa/Y/gDQYGp+YX9/xQrXje5BJw==} + 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.59.4': + resolution: {integrity: sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@ungap/structured-clone@1.3.1': + resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.11.1': + resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.11.1': + resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} + cpu: [x64] + os: [win32] + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@7.4.1: + resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==} + engines: {node: '>=0.4.0'} + hasBin: true + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + + assert-never@1.4.0: + resolution: {integrity: sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==} + + babel-jest@30.4.1: + resolution: {integrity: sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@babel/core': ^7.11.0 || ^8.0.0-0 + + babel-plugin-istanbul@7.0.1: + resolution: {integrity: sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==} + engines: {node: '>=12'} + + babel-plugin-jest-hoist@30.4.0: + resolution: {integrity: sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} + peerDependencies: + '@babel/core': ^7.0.0 || ^8.0.0-0 + + babel-preset-jest@30.4.0: + resolution: {integrity: sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@babel/core': ^7.11.0 || ^8.0.0-beta.1 + + babel-walk@3.0.0-canary-5: + resolution: {integrity: sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==} + engines: {node: '>= 10.0.0'} + + badgen@3.3.2: + resolution: {integrity: sha512-fbQwK9norfdzbdsoPwbLIAmgBXDGEme3jeIyqPAH7o6vp9lmuLHS7uXULvOiQ6XnMLkYNG4gDjILf74hgtTAug==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.29: + resolution: {integrity: sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + blamer@1.0.7: + resolution: {integrity: sha512-GbBStl/EVlSWkiJQBZps3H1iARBrC7vt++Jb/TTmCNu/jZ04VW7tSN1nScbFXBUy1AN+jzeL7Zep9sbQxLhXKA==} + engines: {node: '>=8.9'} + + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bs-logger@0.2.6: + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-lite@1.0.30001792: + resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + + character-parser@2.2.0: + resolution: {integrity: sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==} + + chrono-node@2.9.1: + resolution: {integrity: sha512-nqP8Zp11efCYQIESXPxeDM8ikzN5BDb3Zzou+a66fZq+X2hzKFdsNLQE2/uBAh//BZEMbaMo1eTnagK7hOenAg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + cjs-module-lexer@1.4.3: + resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} + + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + + cli-highlight@2.1.11: + resolution: {integrity: sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==} + engines: {node: '>=8.0.0', npm: '>=5.0.0'} + hasBin: true + + cli-table3@0.6.5: + resolution: {integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==} + engines: {node: 10.* || >= 12.*} + + cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + collect-v8-coverage@1.0.3: + resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colors@1.4.0: + resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==} + engines: {node: '>=0.1.90'} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + commander@5.1.0: + resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} + engines: {node: '>= 6'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + constantinople@4.0.1: + resolution: {integrity: sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + dedent@1.7.2: + resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + + doctypes@1.1.0: + resolution: {integrity: sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + electron-to-chromium@1.5.355: + resolution: {integrity: sha512-LUPZhKzZPYSPme1jEYohpkA+ybYCJztr1quAdBd7E7h3+VOBVcKkwwtBJu41nrjawrRzfb8mtMfzWozoaK0ZIQ==} + + emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + emojilib@2.4.0: + resolution: {integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-prettier@5.5.5: + resolution: {integrity: sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + '@types/eslint': '>=8.0.0' + eslint: '>=8.0.0' + eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0' + prettier: '>=3.0.0' + peerDependenciesMeta: + '@types/eslint': + optional: true + eslint-config-prettier: + optional: true + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + 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: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + execa@4.1.0: + resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==} + engines: {node: '>=10'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + exit-x@0.2.2: + resolution: {integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==} + engines: {node: '>= 0.8.0'} + + expect@30.4.1: + resolution: {integrity: sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-diff@1.3.0: + resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + + fd-package-json@2.0.0: + resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + formatly@0.3.0: + resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} + engines: {node: '>=18.3.0'} + hasBin: true + + fs-extra@11.3.5: + resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} + engines: {node: '>=14.14'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fta-cli@3.0.0: + resolution: {integrity: sha512-SBmoqIwbN7PLDmwmrPgjr6Z6/S9jPhNz5TCPmEVFkIaeloc/T2WXLeeXqhG1+C0UQxpOfGrC7CUb4friqbc2kQ==} + hasBin: true + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} + engines: {node: '>=0.4.7'} + hasBin: true + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + highlight.js@10.7.3: + resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + human-signals@1.1.1: + resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==} + engines: {node: '>=8.12.0'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-expression@4.0.0: + resolution: {integrity: sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-module@1.0.0: + resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-promise@2.2.2: + resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jest-changed-files@30.4.1: + resolution: {integrity: sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-circus@30.4.2: + resolution: {integrity: sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-cli@30.4.2: + resolution: {integrity: sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@30.4.2: + resolution: {integrity: sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@types/node': '*' + esbuild-register: '>=3.4.0' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + esbuild-register: + optional: true + ts-node: + optional: true + + jest-diff@30.4.1: + resolution: {integrity: sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-docblock@30.4.0: + resolution: {integrity: sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-each@30.4.1: + resolution: {integrity: sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-environment-node@30.4.1: + resolution: {integrity: sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-haste-map@30.4.1: + resolution: {integrity: sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-leak-detector@30.4.1: + resolution: {integrity: sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-matcher-utils@30.4.1: + resolution: {integrity: sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-message-util@30.4.1: + resolution: {integrity: sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-mock@30.4.1: + resolution: {integrity: sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-pnp-resolver@1.2.3: + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + + jest-regex-util@30.4.0: + resolution: {integrity: sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-resolve-dependencies@30.4.2: + resolution: {integrity: sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-resolve@30.4.1: + resolution: {integrity: sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-runner@30.4.2: + resolution: {integrity: sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-runtime@30.4.2: + resolution: {integrity: sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-snapshot@30.4.1: + resolution: {integrity: sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-util@30.4.1: + resolution: {integrity: sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-validate@30.4.1: + resolution: {integrity: sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-watcher@30.4.1: + resolution: {integrity: sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-worker@30.4.1: + resolution: {integrity: sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest@30.4.2: + resolution: {integrity: sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-stringify@1.0.2: + resolution: {integrity: sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jscpd-sarif-reporter@4.2.4: + resolution: {integrity: sha512-JtX79kFSyAhqJh5TdLUcvtYJtJd1F8UW8b4Miaga+EIgUn2/nR0N2zWL9mH5cRXgbzLuQbbsw9kReUVIECApwQ==} + + jscpd@4.2.4: + resolution: {integrity: sha512-PSo2U0G8OxULayGyQMv7T/0ZQ+c3PPltdMOz/57v9Xnmq5xSIhh4cnZ0oYZPKqejy10aFwAbMVxqAlo24+PQ3g==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + jstransformer@1.0.0: + resolution: {integrity: sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + knip@6.14.2: + resolution: {integrity: sha512-Vg3JhIINjZew1I7qAFI4UHemW1mc4azP/BxJvsq9eGDfxpGO7oVCuD/bsWkog9TO/ZwwJeAeOMFZ1kd9jnY9+Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.3.6: + resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + + markdown-table@2.0.0: + resolution: {integrity: sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==} + + marked-terminal@7.3.0: + resolution: {integrity: sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==} + engines: {node: '>=16.0.0'} + peerDependencies: + marked: '>=1 <16' + + marked@9.1.6: + resolution: {integrity: sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==} + engines: {node: '>= 16'} + hasBin: true + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + node-emoji@2.2.0: + resolution: {integrity: sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==} + engines: {node: '>=18'} + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + node-releases@2.0.44: + resolution: {integrity: sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==} + + node-sarif-builder@3.4.0: + resolution: {integrity: sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg==} + engines: {node: '>=20'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + oxc-parser@0.130.0: + resolution: {integrity: sha512-X0PJ+NmOok8qP3vK9uaW431ngkdM9UPEK7KG466urtIL2+EYTEgbZK2yqe2MWKJKBjRlFweP/pJPx0x9muMEVw==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-resolver@11.19.1: + resolution: {integrity: sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse5-htmlparser2-tree-adapter@6.0.1: + resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} + + parse5@5.1.1: + resolution: {integrity: sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==} + + parse5@6.0.1: + resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-linter-helpers@1.0.1: + resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} + engines: {node: '>=6.0.0'} + + prettier@3.8.3: + resolution: {integrity: sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==} + engines: {node: '>=14'} + hasBin: true + + pretty-format@30.4.1: + resolution: {integrity: sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + promise@7.3.1: + resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} + + publint@0.3.21: + resolution: {integrity: sha512-OqejcnMV6E9zel2oCrUOJEiiFkGiAAni0A6ibfQNh1k9Gu5z4F+Yso8lllam7AzmV6Do0vp7u3UpZNRBwuXaHQ==} + engines: {node: '>=18'} + hasBin: true + + pug-attrs@3.0.0: + resolution: {integrity: sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==} + + pug-code-gen@3.0.4: + resolution: {integrity: sha512-6okWYIKdasTyXICyEtvobmTZAVX57JkzgzIi4iRJlin8kmhG+Xry2dsus+Mun/nGCn6F2U49haHI5mkELXB14g==} + + pug-error@2.1.0: + resolution: {integrity: sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==} + + pug-filters@4.0.0: + resolution: {integrity: sha512-yeNFtq5Yxmfz0f9z2rMXGw/8/4i1cCFecw/Q7+D0V2DdtII5UvqE12VaZ2AY7ri6o5RNXiweGH79OCq+2RQU4A==} + + pug-lexer@5.0.1: + resolution: {integrity: sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w==} + + pug-linker@4.0.0: + resolution: {integrity: sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw==} + + pug-load@3.0.0: + resolution: {integrity: sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ==} + + pug-parser@6.0.0: + resolution: {integrity: sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==} + + pug-runtime@3.0.1: + resolution: {integrity: sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg==} + + pug-strip-comments@2.0.0: + resolution: {integrity: sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ==} + + pug-walk@2.0.0: + resolution: {integrity: sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==} + + pug@3.0.4: + resolution: {integrity: sha512-kFfq5mMzrS7+wrl5pLJzZEzemx34OQ0w4SARfhy/3yxTlhbstsudDwJzhf1hP02yHzbjoVMSXUj/Sz6RNfMyXg==} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pure-rand@7.0.1: + resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-is@19.2.6: + resolution: {integrity: sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==} + + repeat-string@1.6.1: + resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} + engines: {node: '>=0.10'} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + + semver@7.8.1: + resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} + engines: {node: '>=10'} + hasBin: true + + serialize-javascript@7.0.5: + resolution: {integrity: sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==} + engines: {node: '>=20.0.0'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + skin-tone@2.0.0: + resolution: {integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==} + engines: {node: '>=8'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + smob@1.6.2: + resolution: {integrity: sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==} + engines: {node: '>=20.0.0'} + + smol-toml@1.6.1: + resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==} + engines: {node: '>= 18'} + + source-map-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + spark-md5@3.0.2: + resolution: {integrity: sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-hyperlinks@3.2.0: + resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} + engines: {node: '>=14.18'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + synckit@0.11.12: + resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} + engines: {node: ^14.18.0 || >=16.0.0} + + terser@5.47.1: + resolution: {integrity: sha512-tPbLXTI6ohPASb/1YViL428oEHu6/qv1OxqYnfaonVCFHqx4+wCd95pHrQWsL5X4pl90CTyW9piSAsS2L0VoMw==} + engines: {node: '>=10'} + hasBin: true + + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + token-stream@1.0.0: + resolution: {integrity: sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-jest@29.4.11: + resolution: {integrity: sha512-IrFl7l9AuB/qrNw5quqvAv/hmKMb8dhWOH4jQOGo0Oq8tCeo1O86/iTFG1FaRimgUkF13l4PcepO8ATFT6Ns4g==} + engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/transform': ^29.0.0 || ^30.0.0 + '@jest/types': ^29.0.0 || ^30.0.0 + babel-jest: ^29.0.0 || ^30.0.0 + esbuild: '*' + jest: ^29.0.0 || ^30.0.0 + jest-util: ^29.0.0 || ^30.0.0 + typescript: '>=4.3 <7' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/transform': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + jest-util: + optional: true + + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsutils@3.21.0: + resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} + engines: {node: '>= 6'} + peerDependencies: + typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-coverage-core@2.29.7: + resolution: {integrity: sha512-bt+bnXekw3p5NnqiZpNupOOxfUKGw2Z/YJedfGHkxpeyGLK7DZ59a6Wds8eq1oKjJc5Wulp2xL207z8FjFO14Q==} + peerDependencies: + typescript: 2 || 3 || 4 || 5 + + type-coverage@2.29.7: + resolution: {integrity: sha512-E67Chw7SxFe++uotisxt/xzB1UxxvLztzzQqVyUZ/jKujsejVqvoO5vn25oMvqJydqYrASBVBCQCy082E2qQYQ==} + hasBin: true + + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + typescript@5.6.1-rc: + resolution: {integrity: sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ==} + engines: {node: '>=14.17'} + hasBin: true + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + + unbash@3.0.0: + resolution: {integrity: sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA==} + engines: {node: '>=14'} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unicode-emoji-modifier-base@1.0.0: + resolution: {integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==} + engines: {node: '>=4'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unpdf@1.6.2: + resolution: {integrity: sha512-zQ80ySoPuPHOsvIoRp/nJyQt8TOUoTh1+WBCGcBvlddQNgKDLRwm0AY3x8Q35I7+kIiRSgqMx+Ma2pl9McIp7A==} + peerDependencies: + '@napi-rs/canvas': ^0.1.69 + peerDependenciesMeta: + '@napi-rs/canvas': + optional: true + + unrs-resolver@1.11.1: + resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + + validate-npm-package-name@5.0.1: + resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + + walk-up-path@4.0.0: + resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} + engines: {node: 20 || >=22} + + walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + with@7.0.2: + resolution: {integrity: sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==} + engines: {node: '>= 10.0.0'} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@5.0.1: + resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@20.2.9: + resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} + engines: {node: '>=10'} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@16.2.0: + resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} + engines: {node: '>=10'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@andrewbranch/untar.js@1.0.3': {} + + '@arethetypeswrong/cli@0.18.2': + dependencies: + '@arethetypeswrong/core': 0.18.2 + chalk: 4.1.2 + cli-table3: 0.6.5 + commander: 10.0.1 + marked: 9.1.6 + marked-terminal: 7.3.0(marked@9.1.6) + semver: 7.8.0 + + '@arethetypeswrong/core@0.18.2': + dependencies: + '@andrewbranch/untar.js': 1.0.3 + '@loaderkit/resolve': 1.0.5 + cjs-module-lexer: 1.4.3 + fflate: 0.8.2 + lru-cache: 11.3.6 + semver: 7.8.0 + typescript: 5.6.1-rc + validate-npm-package-name: 5.0.1 + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.3': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.3 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@0.2.3': {} + + '@braidai/lang@1.1.2': {} + + '@colors/colors@1.5.0': + optional: true + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.7.0))': + dependencies: + eslint: 9.39.4(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.2 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.6': {} + + '@jest/console@30.4.1': + dependencies: + '@jest/types': 30.4.1 + '@types/node': 22.19.19 + chalk: 4.1.2 + jest-message-util: 30.4.1 + jest-util: 30.4.1 + slash: 3.0.0 + + '@jest/core@30.4.2': + dependencies: + '@jest/console': 30.4.1 + '@jest/pattern': 30.4.0 + '@jest/reporters': 30.4.1 + '@jest/test-result': 30.4.1 + '@jest/transform': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 22.19.19 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 4.4.0 + exit-x: 0.2.2 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-changed-files: 30.4.1 + jest-config: 30.4.2(@types/node@22.19.19) + jest-haste-map: 30.4.1 + jest-message-util: 30.4.1 + jest-regex-util: 30.4.0 + jest-resolve: 30.4.1 + jest-resolve-dependencies: 30.4.2 + jest-runner: 30.4.2 + jest-runtime: 30.4.2 + jest-snapshot: 30.4.1 + jest-util: 30.4.1 + jest-validate: 30.4.1 + jest-watcher: 30.4.1 + pretty-format: 30.4.1 + slash: 3.0.0 + transitivePeerDependencies: + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + + '@jest/diff-sequences@30.4.0': {} + + '@jest/environment@30.4.1': + dependencies: + '@jest/fake-timers': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 22.19.19 + jest-mock: 30.4.1 + + '@jest/expect-utils@30.4.1': + dependencies: + '@jest/get-type': 30.1.0 + + '@jest/expect@30.4.1': + dependencies: + expect: 30.4.1 + jest-snapshot: 30.4.1 + transitivePeerDependencies: + - supports-color + + '@jest/fake-timers@30.4.1': + dependencies: + '@jest/types': 30.4.1 + '@sinonjs/fake-timers': 15.4.0 + '@types/node': 22.19.19 + jest-message-util: 30.4.1 + jest-mock: 30.4.1 + jest-util: 30.4.1 + + '@jest/get-type@30.1.0': {} + + '@jest/globals@30.4.1': + dependencies: + '@jest/environment': 30.4.1 + '@jest/expect': 30.4.1 + '@jest/types': 30.4.1 + jest-mock: 30.4.1 + transitivePeerDependencies: + - supports-color + + '@jest/pattern@30.4.0': + dependencies: + '@types/node': 22.19.19 + jest-regex-util: 30.4.0 + + '@jest/reporters@30.4.1': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 30.4.1 + '@jest/test-result': 30.4.1 + '@jest/transform': 30.4.1 + '@jest/types': 30.4.1 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 22.19.19 + chalk: 4.1.2 + collect-v8-coverage: 1.0.3 + exit-x: 0.2.2 + glob: 10.5.0 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + jest-message-util: 30.4.1 + jest-util: 30.4.1 + jest-worker: 30.4.1 + slash: 3.0.0 + string-length: 4.0.2 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + + '@jest/schemas@30.4.1': + dependencies: + '@sinclair/typebox': 0.34.49 + + '@jest/snapshot-utils@30.4.1': + dependencies: + '@jest/types': 30.4.1 + chalk: 4.1.2 + graceful-fs: 4.2.11 + natural-compare: 1.4.0 + + '@jest/source-map@30.0.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + '@jest/test-result@30.4.1': + dependencies: + '@jest/console': 30.4.1 + '@jest/types': 30.4.1 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.3 + + '@jest/test-sequencer@30.4.1': + dependencies: + '@jest/test-result': 30.4.1 + graceful-fs: 4.2.11 + jest-haste-map: 30.4.1 + slash: 3.0.0 + + '@jest/transform@30.4.1': + dependencies: + '@babel/core': 7.29.0 + '@jest/types': 30.4.1 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 7.0.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.4.1 + jest-regex-util: 30.4.0 + jest-util: 30.4.1 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + + '@jest/types@30.4.1': + dependencies: + '@jest/pattern': 30.4.0 + '@jest/schemas': 30.4.1 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 22.19.19 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@jscpd/badge-reporter@4.2.4': + dependencies: + badgen: 3.3.2 + colors: 1.4.0 + fs-extra: 11.3.5 + + '@jscpd/core@4.2.4': + dependencies: + eventemitter3: 5.0.4 + + '@jscpd/finder@4.2.4': + dependencies: + '@jscpd/core': 4.2.4 + '@jscpd/tokenizer': 4.2.4 + blamer: 1.0.7 + bytes: 3.1.2 + cli-table3: 0.6.5 + colors: 1.4.0 + fast-glob: 3.3.3 + fs-extra: 11.3.5 + markdown-table: 2.0.0 + pug: 3.0.4 + + '@jscpd/html-reporter@4.2.4': + dependencies: + colors: 1.4.0 + fs-extra: 11.3.5 + pug: 3.0.4 + + '@jscpd/tokenizer@4.2.4': + dependencies: + '@jscpd/core': 4.2.4 + spark-md5: 3.0.2 + + '@loaderkit/resolve@1.0.5': + dependencies: + '@braidai/lang': 1.1.2 + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@oxc-parser/binding-android-arm-eabi@0.130.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.130.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.130.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.130.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.130.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.130.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.130.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.130.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.130.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.130.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.130.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.130.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.130.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.130.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.130.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.130.0': + optional: true + + '@oxc-parser/binding-wasm32-wasi@0.130.0': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.130.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.130.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.130.0': + optional: true + + '@oxc-project/types@0.130.0': {} + + '@oxc-resolver/binding-android-arm-eabi@11.19.1': + optional: true + + '@oxc-resolver/binding-android-arm64@11.19.1': + optional: true + + '@oxc-resolver/binding-darwin-arm64@11.19.1': + optional: true + + '@oxc-resolver/binding-darwin-x64@11.19.1': + optional: true + + '@oxc-resolver/binding-freebsd-x64@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-arm-musleabihf@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-arm64-gnu@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-arm64-musl@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-ppc64-gnu@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-riscv64-gnu@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-riscv64-musl@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-s390x-gnu@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-x64-gnu@11.19.1': + optional: true + + '@oxc-resolver/binding-linux-x64-musl@11.19.1': + optional: true + + '@oxc-resolver/binding-openharmony-arm64@11.19.1': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@oxc-resolver/binding-win32-arm64-msvc@11.19.1': + optional: true + + '@oxc-resolver/binding-win32-ia32-msvc@11.19.1': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@11.19.1': + optional: true + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@pkgr/core@0.2.9': {} + + '@publint/pack@0.1.4': {} + + '@rollup/plugin-node-resolve@16.0.3(rollup@4.60.4)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.60.4) + '@types/resolve': 1.20.2 + deepmerge: 4.3.1 + is-module: 1.0.0 + resolve: 1.22.12 + optionalDependencies: + rollup: 4.60.4 + + '@rollup/plugin-terser@1.0.0(rollup@4.60.4)': + dependencies: + serialize-javascript: 7.0.5 + smob: 1.6.2 + terser: 5.47.1 + optionalDependencies: + rollup: 4.60.4 + + '@rollup/plugin-typescript@12.3.0(rollup@4.60.4)(tslib@2.8.1)(typescript@6.0.3)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.60.4) + resolve: 1.22.12 + typescript: 6.0.3 + optionalDependencies: + rollup: 4.60.4 + tslib: 2.8.1 + + '@rollup/pluginutils@5.3.0(rollup@4.60.4)': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.4 + optionalDependencies: + rollup: 4.60.4 + + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + + '@sinclair/typebox@0.34.49': {} + + '@sindresorhus/is@4.6.0': {} + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@15.4.0': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/estree@1.0.8': {} + + '@types/estree@1.0.9': {} + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/jest@30.0.0': + dependencies: + expect: 30.4.1 + pretty-format: 30.4.1 + + '@types/json-schema@7.0.15': {} + + '@types/node@22.19.19': + dependencies: + undici-types: 6.21.0 + + '@types/resolve@1.20.2': {} + + '@types/sarif@2.1.7': {} + + '@types/stack-utils@2.0.3': {} + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@typescript-eslint/eslint-plugin@8.59.4(@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/type-utils': 8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.59.4 + eslint: 9.39.4(jiti@2.7.0) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.59.4 + debug: 4.4.3 + eslint: 9.39.4(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.4(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@6.0.3) + '@typescript-eslint/types': 8.59.4 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.59.4': + dependencies: + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/visitor-keys': 8.59.4 + + '@typescript-eslint/tsconfig-utils@8.59.4(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/type-utils@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) + debug: 4.4.3 + eslint: 9.39.4(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.59.4': {} + + '@typescript-eslint/typescript-estree@8.59.4(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.4(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.59.4(typescript@6.0.3) + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/visitor-keys': 8.59.4 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.1 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.4(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.59.4 + '@typescript-eslint/types': 8.59.4 + '@typescript-eslint/typescript-estree': 8.59.4(typescript@6.0.3) + eslint: 9.39.4(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.59.4': + dependencies: + '@typescript-eslint/types': 8.59.4 + eslint-visitor-keys: 5.0.1 + + '@ungap/structured-clone@1.3.1': {} + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + optional: true + + '@unrs/resolver-binding-android-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + optional: true + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@7.4.1: {} + + acorn@8.16.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + asap@2.0.6: {} + + assert-never@1.4.0: {} + + babel-jest@30.4.1(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@jest/transform': 30.4.1 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 7.0.1 + babel-preset-jest: 30.4.0(@babel/core@7.29.0) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@7.0.1: + dependencies: + '@babel/helper-plugin-utils': 7.28.6 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-instrument: 6.0.3 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@30.4.0: + dependencies: + '@types/babel__core': 7.20.5 + + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.0) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0) + + babel-preset-jest@30.4.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + babel-plugin-jest-hoist: 30.4.0 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + + babel-walk@3.0.0-canary-5: + dependencies: + '@babel/types': 7.29.7 + + badgen@3.3.2: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.29: {} + + blamer@1.0.7: + dependencies: + execa: 4.1.0 + which: 2.0.2 + + brace-expansion@1.1.14: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.29 + caniuse-lite: 1.0.30001792 + electron-to-chromium: 1.5.355 + node-releases: 2.0.44 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + bs-logger@0.2.6: + dependencies: + fast-json-stable-stringify: 2.1.0 + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + + buffer-from@1.1.2: {} + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + + caniuse-lite@1.0.30001792: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + char-regex@1.0.2: {} + + character-parser@2.2.0: + dependencies: + is-regex: 1.2.1 + + chrono-node@2.9.1: {} + + ci-info@4.4.0: {} + + cjs-module-lexer@1.4.3: {} + + cjs-module-lexer@2.2.0: {} + + cli-highlight@2.1.11: + dependencies: + chalk: 4.1.2 + highlight.js: 10.7.3 + mz: 2.7.0 + parse5: 5.1.1 + parse5-htmlparser2-tree-adapter: 6.0.1 + yargs: 16.2.0 + + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + co@4.6.0: {} + + collect-v8-coverage@1.0.3: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colors@1.4.0: {} + + commander@10.0.1: {} + + commander@2.20.3: {} + + commander@5.1.0: {} + + concat-map@0.0.1: {} + + constantinople@4.0.1: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + convert-source-map@2.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + dedent@1.7.2: {} + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + detect-newline@3.1.0: {} + + doctypes@1.1.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + electron-to-chromium@1.5.355: {} + + emittery@0.13.1: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + emojilib@2.4.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + environment@1.1.0: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + escalade@3.2.0: {} + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.7.0)): + dependencies: + eslint: 9.39.4(jiti@2.7.0) + + eslint-plugin-prettier@5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4(jiti@2.7.0)))(eslint@9.39.4(jiti@2.7.0))(prettier@3.8.3): + dependencies: + eslint: 9.39.4(jiti@2.7.0) + prettier: 3.8.3 + prettier-linter-helpers: 1.0.1 + synckit: 0.11.12 + optionalDependencies: + eslint-config-prettier: 10.1.8(eslint@9.39.4(jiti@2.7.0)) + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esprima@4.0.1: {} + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + esutils@2.0.3: {} + + eventemitter3@5.0.4: {} + + execa@4.1.0: + dependencies: + cross-spawn: 7.0.6 + get-stream: 5.2.0 + human-signals: 1.1.1 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + exit-x@0.2.2: {} + + expect@30.4.1: + dependencies: + '@jest/expect-utils': 30.4.1 + '@jest/get-type': 30.1.0 + jest-matcher-utils: 30.4.1 + jest-message-util: 30.4.1 + jest-mock: 30.4.1 + jest-util: 30.4.1 + + fast-deep-equal@3.1.3: {} + + fast-diff@1.3.0: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + + fd-package-json@2.0.0: + dependencies: + walk-up-path: 4.0.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fflate@0.8.2: {} + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + formatly@0.3.0: + dependencies: + fd-package-json: 2.0.0 + + fs-extra@11.3.5: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + fta-cli@3.0.0: {} + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-package-type@0.1.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + + get-stream@6.0.1: {} + + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + globals@14.0.0: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + handlebars@4.7.9: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + highlight.js@10.7.3: {} + + html-escaper@2.0.2: {} + + human-signals@1.1.1: {} + + human-signals@2.1.0: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + is-arrayish@0.2.1: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.3 + + is-expression@4.0.0: + dependencies: + acorn: 7.4.1 + object-assign: 4.1.1 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-generator-fn@2.1.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-module@1.0.0: {} + + is-number@7.0.0: {} + + is-promise@2.2.2: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + is-stream@2.0.1: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@6.0.3: + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.3 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-coverage: 3.2.2 + semver: 7.8.1 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jest-changed-files@30.4.1: + dependencies: + execa: 5.1.1 + jest-util: 30.4.1 + p-limit: 3.1.0 + + jest-circus@30.4.2: + dependencies: + '@jest/environment': 30.4.1 + '@jest/expect': 30.4.1 + '@jest/test-result': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 22.19.19 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.2 + is-generator-fn: 2.1.0 + jest-each: 30.4.1 + jest-matcher-utils: 30.4.1 + jest-message-util: 30.4.1 + jest-runtime: 30.4.2 + jest-snapshot: 30.4.1 + jest-util: 30.4.1 + p-limit: 3.1.0 + pretty-format: 30.4.1 + pure-rand: 7.0.1 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@30.4.2(@types/node@22.19.19): + dependencies: + '@jest/core': 30.4.2 + '@jest/test-result': 30.4.1 + '@jest/types': 30.4.1 + chalk: 4.1.2 + exit-x: 0.2.2 + import-local: 3.2.0 + jest-config: 30.4.2(@types/node@22.19.19) + jest-util: 30.4.1 + jest-validate: 30.4.1 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + + jest-config@30.4.2(@types/node@22.19.19): + dependencies: + '@babel/core': 7.29.0 + '@jest/get-type': 30.1.0 + '@jest/pattern': 30.4.0 + '@jest/test-sequencer': 30.4.1 + '@jest/types': 30.4.1 + babel-jest: 30.4.1(@babel/core@7.29.0) + chalk: 4.1.2 + ci-info: 4.4.0 + deepmerge: 4.3.1 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-circus: 30.4.2 + jest-docblock: 30.4.0 + jest-environment-node: 30.4.1 + jest-regex-util: 30.4.0 + jest-resolve: 30.4.1 + jest-runner: 30.4.2 + jest-util: 30.4.1 + jest-validate: 30.4.1 + parse-json: 5.2.0 + pretty-format: 30.4.1 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 22.19.19 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@30.4.1: + dependencies: + '@jest/diff-sequences': 30.4.0 + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + pretty-format: 30.4.1 + + jest-docblock@30.4.0: + dependencies: + detect-newline: 3.1.0 + + jest-each@30.4.1: + dependencies: + '@jest/get-type': 30.1.0 + '@jest/types': 30.4.1 + chalk: 4.1.2 + jest-util: 30.4.1 + pretty-format: 30.4.1 + + jest-environment-node@30.4.1: + dependencies: + '@jest/environment': 30.4.1 + '@jest/fake-timers': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 22.19.19 + jest-mock: 30.4.1 + jest-util: 30.4.1 + jest-validate: 30.4.1 + + jest-haste-map@30.4.1: + dependencies: + '@jest/types': 30.4.1 + '@types/node': 22.19.19 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 30.4.0 + jest-util: 30.4.1 + jest-worker: 30.4.1 + picomatch: 4.0.4 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-leak-detector@30.4.1: + dependencies: + '@jest/get-type': 30.1.0 + pretty-format: 30.4.1 + + jest-matcher-utils@30.4.1: + dependencies: + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + jest-diff: 30.4.1 + pretty-format: 30.4.1 + + jest-message-util@30.4.1: + dependencies: + '@babel/code-frame': 7.29.0 + '@jest/types': 30.4.1 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-util: 30.4.1 + picomatch: 4.0.4 + pretty-format: 30.4.1 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@30.4.1: + dependencies: + '@jest/types': 30.4.1 + '@types/node': 22.19.19 + jest-util: 30.4.1 + + jest-pnp-resolver@1.2.3(jest-resolve@30.4.1): + optionalDependencies: + jest-resolve: 30.4.1 + + jest-regex-util@30.4.0: {} + + jest-resolve-dependencies@30.4.2: + dependencies: + jest-regex-util: 30.4.0 + jest-snapshot: 30.4.1 + transitivePeerDependencies: + - supports-color + + jest-resolve@30.4.1: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 30.4.1 + jest-pnp-resolver: 1.2.3(jest-resolve@30.4.1) + jest-util: 30.4.1 + jest-validate: 30.4.1 + slash: 3.0.0 + unrs-resolver: 1.11.1 + + jest-runner@30.4.2: + dependencies: + '@jest/console': 30.4.1 + '@jest/environment': 30.4.1 + '@jest/test-result': 30.4.1 + '@jest/transform': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 22.19.19 + chalk: 4.1.2 + emittery: 0.13.1 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-docblock: 30.4.0 + jest-environment-node: 30.4.1 + jest-haste-map: 30.4.1 + jest-leak-detector: 30.4.1 + jest-message-util: 30.4.1 + jest-resolve: 30.4.1 + jest-runtime: 30.4.2 + jest-util: 30.4.1 + jest-watcher: 30.4.1 + jest-worker: 30.4.1 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + + jest-runtime@30.4.2: + dependencies: + '@jest/environment': 30.4.1 + '@jest/fake-timers': 30.4.1 + '@jest/globals': 30.4.1 + '@jest/source-map': 30.0.1 + '@jest/test-result': 30.4.1 + '@jest/transform': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 22.19.19 + chalk: 4.1.2 + cjs-module-lexer: 2.2.0 + collect-v8-coverage: 1.0.3 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.4.1 + jest-message-util: 30.4.1 + jest-mock: 30.4.1 + jest-regex-util: 30.4.0 + jest-resolve: 30.4.1 + jest-snapshot: 30.4.1 + jest-util: 30.4.1 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + jest-snapshot@30.4.1: + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/types': 7.29.0 + '@jest/expect-utils': 30.4.1 + '@jest/get-type': 30.1.0 + '@jest/snapshot-utils': 30.4.1 + '@jest/transform': 30.4.1 + '@jest/types': 30.4.1 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + chalk: 4.1.2 + expect: 30.4.1 + graceful-fs: 4.2.11 + jest-diff: 30.4.1 + jest-matcher-utils: 30.4.1 + jest-message-util: 30.4.1 + jest-util: 30.4.1 + pretty-format: 30.4.1 + semver: 7.8.0 + synckit: 0.11.12 + transitivePeerDependencies: + - supports-color + + jest-util@30.4.1: + dependencies: + '@jest/types': 30.4.1 + '@types/node': 22.19.19 + chalk: 4.1.2 + ci-info: 4.4.0 + graceful-fs: 4.2.11 + picomatch: 4.0.4 + + jest-validate@30.4.1: + dependencies: + '@jest/get-type': 30.1.0 + '@jest/types': 30.4.1 + camelcase: 6.3.0 + chalk: 4.1.2 + leven: 3.1.0 + pretty-format: 30.4.1 + + jest-watcher@30.4.1: + dependencies: + '@jest/test-result': 30.4.1 + '@jest/types': 30.4.1 + '@types/node': 22.19.19 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 30.4.1 + string-length: 4.0.2 + + jest-worker@30.4.1: + dependencies: + '@types/node': 22.19.19 + '@ungap/structured-clone': 1.3.1 + jest-util: 30.4.1 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@30.4.2(@types/node@22.19.19): + dependencies: + '@jest/core': 30.4.2 + '@jest/types': 30.4.1 + import-local: 3.2.0 + jest-cli: 30.4.2(@types/node@22.19.19) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + + jiti@2.7.0: {} + + js-stringify@1.0.2: {} + + js-tokens@4.0.0: {} + + js-yaml@3.14.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jscpd-sarif-reporter@4.2.4: + dependencies: + colors: 1.4.0 + fs-extra: 11.3.5 + node-sarif-builder: 3.4.0 + + jscpd@4.2.4: + dependencies: + '@jscpd/badge-reporter': 4.2.4 + '@jscpd/core': 4.2.4 + '@jscpd/finder': 4.2.4 + '@jscpd/html-reporter': 4.2.4 + '@jscpd/tokenizer': 4.2.4 + colors: 1.4.0 + commander: 5.1.0 + fs-extra: 11.3.5 + jscpd-sarif-reporter: 4.2.4 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jstransformer@1.0.0: + dependencies: + is-promise: 2.2.2 + promise: 7.3.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + knip@6.14.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + formatly: 0.3.0 + get-tsconfig: 4.14.0 + jiti: 2.7.0 + minimist: 1.2.8 + oxc-parser: 0.130.0 + oxc-resolver: 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + picomatch: 4.0.4 + smol-toml: 1.6.1 + strip-json-comments: 5.0.3 + tinyglobby: 0.2.16 + unbash: 3.0.0 + yaml: 2.9.0 + zod: 4.4.3 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + leven@3.1.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lines-and-columns@1.2.4: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.memoize@4.1.2: {} + + lodash.merge@4.6.2: {} + + lru-cache@10.4.3: {} + + lru-cache@11.3.6: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.1 + + make-error@1.3.6: {} + + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + + markdown-table@2.0.0: + dependencies: + repeat-string: 1.6.1 + + marked-terminal@7.3.0(marked@9.1.6): + dependencies: + ansi-escapes: 7.3.0 + ansi-regex: 6.2.2 + chalk: 5.6.2 + cli-highlight: 2.1.11 + cli-table3: 0.6.5 + marked: 9.1.6 + node-emoji: 2.2.0 + supports-hyperlinks: 3.2.0 + + marked@9.1.6: {} + + math-intrinsics@1.1.0: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mimic-fn@2.1.0: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.14 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.0 + + minimist@1.2.8: {} + + minipass@7.1.3: {} + + mri@1.2.0: {} + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + napi-postinstall@0.3.4: {} + + natural-compare@1.4.0: {} + + neo-async@2.6.2: {} + + node-emoji@2.2.0: + dependencies: + '@sindresorhus/is': 4.6.0 + char-regex: 1.0.2 + emojilib: 2.4.0 + skin-tone: 2.0.0 + + node-int64@0.4.0: {} + + node-releases@2.0.44: {} + + node-sarif-builder@3.4.0: + dependencies: + '@types/sarif': 2.1.7 + fs-extra: 11.3.5 + + normalize-path@3.0.0: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + object-assign@4.1.1: {} + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + oxc-parser@0.130.0: + dependencies: + '@oxc-project/types': 0.130.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.130.0 + '@oxc-parser/binding-android-arm64': 0.130.0 + '@oxc-parser/binding-darwin-arm64': 0.130.0 + '@oxc-parser/binding-darwin-x64': 0.130.0 + '@oxc-parser/binding-freebsd-x64': 0.130.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.130.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.130.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.130.0 + '@oxc-parser/binding-linux-arm64-musl': 0.130.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.130.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.130.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.130.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.130.0 + '@oxc-parser/binding-linux-x64-gnu': 0.130.0 + '@oxc-parser/binding-linux-x64-musl': 0.130.0 + '@oxc-parser/binding-openharmony-arm64': 0.130.0 + '@oxc-parser/binding-wasm32-wasi': 0.130.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.130.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.130.0 + '@oxc-parser/binding-win32-x64-msvc': 0.130.0 + + oxc-resolver@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.19.1 + '@oxc-resolver/binding-android-arm64': 11.19.1 + '@oxc-resolver/binding-darwin-arm64': 11.19.1 + '@oxc-resolver/binding-darwin-x64': 11.19.1 + '@oxc-resolver/binding-freebsd-x64': 11.19.1 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.19.1 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.19.1 + '@oxc-resolver/binding-linux-arm64-gnu': 11.19.1 + '@oxc-resolver/binding-linux-arm64-musl': 11.19.1 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.19.1 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.19.1 + '@oxc-resolver/binding-linux-riscv64-musl': 11.19.1 + '@oxc-resolver/binding-linux-s390x-gnu': 11.19.1 + '@oxc-resolver/binding-linux-x64-gnu': 11.19.1 + '@oxc-resolver/binding-linux-x64-musl': 11.19.1 + '@oxc-resolver/binding-openharmony-arm64': 11.19.1 + '@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@oxc-resolver/binding-win32-arm64-msvc': 11.19.1 + '@oxc-resolver/binding-win32-ia32-msvc': 11.19.1 + '@oxc-resolver/binding-win32-x64-msvc': 11.19.1 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-try@2.2.0: {} + + package-json-from-dist@1.0.1: {} + + package-manager-detector@1.6.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse5-htmlparser2-tree-adapter@6.0.1: + dependencies: + parse5: 6.0.1 + + parse5@5.1.1: {} + + parse5@6.0.1: {} + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pirates@4.0.7: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + prelude-ls@1.2.1: {} + + prettier-linter-helpers@1.0.1: + dependencies: + fast-diff: 1.3.0 + + prettier@3.8.3: {} + + pretty-format@30.4.1: + dependencies: + '@jest/schemas': 30.4.1 + ansi-styles: 5.2.0 + react-is-18: react-is@18.3.1 + react-is-19: react-is@19.2.6 + + promise@7.3.1: + dependencies: + asap: 2.0.6 + + publint@0.3.21: + dependencies: + '@publint/pack': 0.1.4 + package-manager-detector: 1.6.0 + picocolors: 1.1.1 + sade: 1.8.1 + + pug-attrs@3.0.0: + dependencies: + constantinople: 4.0.1 + js-stringify: 1.0.2 + pug-runtime: 3.0.1 + + pug-code-gen@3.0.4: + dependencies: + constantinople: 4.0.1 + doctypes: 1.1.0 + js-stringify: 1.0.2 + pug-attrs: 3.0.0 + pug-error: 2.1.0 + pug-runtime: 3.0.1 + void-elements: 3.1.0 + with: 7.0.2 + + pug-error@2.1.0: {} + + pug-filters@4.0.0: + dependencies: + constantinople: 4.0.1 + jstransformer: 1.0.0 + pug-error: 2.1.0 + pug-walk: 2.0.0 + resolve: 1.22.12 + + pug-lexer@5.0.1: + dependencies: + character-parser: 2.2.0 + is-expression: 4.0.0 + pug-error: 2.1.0 + + pug-linker@4.0.0: + dependencies: + pug-error: 2.1.0 + pug-walk: 2.0.0 + + pug-load@3.0.0: + dependencies: + object-assign: 4.1.1 + pug-walk: 2.0.0 + + pug-parser@6.0.0: + dependencies: + pug-error: 2.1.0 + token-stream: 1.0.0 + + pug-runtime@3.0.1: {} + + pug-strip-comments@2.0.0: + dependencies: + pug-error: 2.1.0 + + pug-walk@2.0.0: {} + + pug@3.0.4: + dependencies: + pug-code-gen: 3.0.4 + pug-filters: 4.0.0 + pug-lexer: 5.0.1 + pug-linker: 4.0.0 + pug-load: 3.0.0 + pug-parser: 6.0.0 + pug-runtime: 3.0.1 + pug-strip-comments: 2.0.0 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode@2.3.1: {} + + pure-rand@7.0.1: {} + + queue-microtask@1.2.3: {} + + react-is@18.3.1: {} + + react-is@19.2.6: {} + + repeat-string@1.6.1: {} + + require-directory@2.1.1: {} + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + semver@6.3.1: {} + + semver@7.8.0: {} + + semver@7.8.1: {} + + serialize-javascript@7.0.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + skin-tone@2.0.0: + dependencies: + unicode-emoji-modifier-base: 1.0.0 + + slash@3.0.0: {} + + smob@1.6.2: {} + + smol-toml@1.6.1: {} + + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + spark-md5@3.0.2: {} + + sprintf-js@1.0.3: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@4.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-json-comments@3.1.1: {} + + strip-json-comments@5.0.3: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@3.2.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + synckit@0.11.12: + dependencies: + '@pkgr/core': 0.2.9 + + terser@5.47.1: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.16.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 7.2.3 + minimatch: 3.1.5 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tmpl@1.0.5: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + token-stream@1.0.0: {} + + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + ts-jest@29.4.11(@babel/core@7.29.0)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.0))(jest-util@30.4.1)(jest@30.4.2(@types/node@22.19.19))(typescript@6.0.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.9 + jest: 30.4.2(@types/node@22.19.19) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.8.1 + type-fest: 4.41.0 + typescript: 6.0.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.29.0 + '@jest/transform': 30.4.1 + '@jest/types': 30.4.1 + babel-jest: 30.4.1(@babel/core@7.29.0) + jest-util: 30.4.1 + + tslib@1.14.1: {} + + tslib@2.8.1: {} + + tsutils@3.21.0(typescript@6.0.3): + dependencies: + tslib: 1.14.1 + typescript: 6.0.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-coverage-core@2.29.7(typescript@6.0.3): + dependencies: + fast-glob: 3.3.3 + minimatch: 10.2.5 + normalize-path: 3.0.0 + tslib: 2.8.1 + tsutils: 3.21.0(typescript@6.0.3) + typescript: 6.0.3 + + type-coverage@2.29.7(typescript@6.0.3): + dependencies: + chalk: 4.1.2 + minimist: 1.2.8 + type-coverage-core: 2.29.7(typescript@6.0.3) + transitivePeerDependencies: + - typescript + + type-detect@4.0.8: {} + + type-fest@0.21.3: {} + + type-fest@4.41.0: {} + + typescript@5.6.1-rc: {} + + typescript@6.0.3: {} + + uglify-js@3.19.3: + optional: true + + unbash@3.0.0: {} + + undici-types@6.21.0: {} + + unicode-emoji-modifier-base@1.0.0: {} + + universalify@2.0.1: {} + + unpdf@1.6.2: {} + + unrs-resolver@1.11.1: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.11.1 + '@unrs/resolver-binding-android-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-x64': 1.11.1 + '@unrs/resolver-binding-freebsd-x64': 1.11.1 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 + '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-musl': 1.11.1 + '@unrs/resolver-binding-wasm32-wasi': 1.11.1 + '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 + '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 + '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + + validate-npm-package-name@5.0.1: {} + + void-elements@3.1.0: {} + + walk-up-path@4.0.0: {} + + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + with@7.0.2: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + assert-never: 1.4.0 + babel-walk: 3.0.0-canary-5 + + word-wrap@1.2.5: {} + + wordwrap@1.0.0: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yaml@2.9.0: {} + + yargs-parser@20.2.9: {} + + yargs-parser@21.1.1: {} + + yargs@16.2.0: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + + zod@4.4.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..6c750ff --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + unrs-resolver: true diff --git a/rollup.config.js b/rollup.config.js index e358617..1338e7e 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,38 +1,70 @@ +import terser from '@rollup/plugin-terser'; import typescript from '@rollup/plugin-typescript'; import resolve from '@rollup/plugin-node-resolve'; +import { defineConfig } from 'rollup'; -export default { - input: 'src/index.ts', - output: [ - { - file: 'dist/index.js', - format: 'es', - sourcemap: true, - inlineDynamicImports: true, - }, - { - file: 'dist/index.cjs', - format: 'cjs', - sourcemap: true, - inlineDynamicImports: true, - }, - { - file: 'dist/index.min.js', - format: 'es', - sourcemap: true, - inlineDynamicImports: true, - }, - ], - external: ['pdf-parse'], - plugins: [ +const external = ['chrono-node', 'unpdf', 'zod']; + +function sourcePlugins({ declarations }) { + return [ resolve({ preferBuiltins: true, }), typescript({ tsconfig: './tsconfig.json', - declaration: true, - declarationDir: 'dist', + declaration: declarations, + declarationDir: declarations ? 'dist' : undefined, rootDir: 'src', }), - ], -}; \ No newline at end of file + ]; +} + +export default defineConfig([ + { + input: 'src/index.ts', + output: [ + { + file: 'dist/index.js', + format: 'es', + sourcemap: true, + inlineDynamicImports: true, + }, + { + file: 'dist/index.cjs', + format: 'cjs', + sourcemap: true, + inlineDynamicImports: true, + }, + { + file: 'dist/index.min.js', + format: 'es', + sourcemap: true, + inlineDynamicImports: true, + plugins: [ + terser({ + compress: { + drop_console: true, + drop_debugger: true, + }, + format: { + comments: false, + }, + }), + ], + }, + ], + external, + plugins: sourcePlugins({ declarations: true }), + }, + { + input: 'src/cli.ts', + output: { + file: 'dist/cli.js', + format: 'es', + sourcemap: true, + inlineDynamicImports: true, + }, + external: [...external, './index.js'], + plugins: sourcePlugins({ declarations: false }), + }, +]); diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..5a3a04f --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,88 @@ +# Scripts + +This directory contains repository-maintenance scripts that supplement the unit +tests. They are most useful when checking build artifacts, package behavior, +bundle size, and real LinkedIn PDF samples during parser debugging. + +Run commands from the repository root unless noted otherwise. + +## Verification scripts + +| Script | Package command | What it checks | Why it is useful | +| --- | --- | --- | --- | +| `verify-artifacts.mjs` | `pnpm run verify:artifacts` | Confirms the expected `dist/` and `bin/` files exist, validates `package.json` entrypoints, verifies external dependencies stay external, imports ESM/CJS/minified bundles, and exercises the CLI entrypoint. | Catches broken builds, export-map regressions, missing declarations, and CLI packaging mistakes before publish. | +| `verify-packed-package.mjs` | `pnpm run verify:package` | Runs `npm pack`, installs the packed archive into a temporary consumer project, then verifies ESM import, CJS require, TypeScript types, and the installed CLI. | Tests the package the way downstream users consume it, not just the local workspace files. | +| `check-size-budget.mjs` | `pnpm run size:check` | Checks raw and gzip size budgets for generated JavaScript artifacts and ensures the minified bundle is smaller than the regular bundle. | Prevents accidental bundle growth and catches minification or bundling regressions. | + +These scripts assume `dist/` exists. Run `pnpm run build` first when invoking +them directly. + +## Sample and PDF debugging scripts + +For the investigation workflow and artifact interpretation, use the repo-local +skill at `.agents/skills/debug-linkedin-sample-pdfs`. + +| Script | Package command | What it does | Why it is useful | +| --- | --- | --- | --- | +| `check-sample-warnings.mjs` | `pnpm run samples:check-warnings` | Parses every PDF in `samples/` with the built parser and fails if any output contains a `section_parse_warning`. | Gives a fast regression check for section parsing against real sample PDFs. | +| `extract-sample-layout-text.mjs` | none | Runs `pdftotext -layout` for each sample PDF and writes layout-preserving text files plus a manifest to `.debug-dist/sample-layout-text/` by default. Supports `--samples ` and `--output `. | Makes PDF line layout visible when debugging column breaks, headings, contact blocks, or parser misses. | +| `inspect-pdf-source.mjs` | `pnpm run source:inspect` | Builds first, then writes a source evidence bundle for one or more PDFs, defaulting to `samples/` when no PDF path or `--samples` value is provided. Each bundle includes Poppler text, bbox XHTML, pdf metadata, pdfplumber words/chars, raw unpdf items, parser structural lines, parser JSON with warnings and diagnostics, source coverage reports, rendered page PNGs, and `overlay.html`. Supports positional PDF paths, `--samples `, and `--output `. | Gives a parser-independent view of the PDF plus the parser's own reconstruction so extraction bugs can be investigated from source geometry instead of trusting generated JSON. | +| `sample-completeness-audit.mjs` | `pnpm run samples:audit-coverage -- --samples samples/` | Compares layout-extracted sample text with matching sample JSON files by inferred source section, reports unmatched source segments, loose token-only matches, cross-section output matches, field-mismatch output matches, untraced output values, section coverage, and `section_parse_warning` entries. Supports `--samples `, `--layouts `, `--report `, `--fail-on-unmatched`, `--fail-on-loose`, `--fail-on-field-mismatches`, `--fail-on-untraced-output`, `--fail-on-section-warnings`, and `--strict`. | Helps identify PDF content missing from parsed JSON, JSON values that are not traceable to source text, and same-section values assigned to the wrong field. Treat cross-section matches as review prompts because the section inference and matching remain heuristic. | +| `verify-samples.mjs` | `pnpm run samples:verify` | Builds once, generates initial suspect JSON when `samples/` has PDFs but no JSON files, verifies local sample JSON baselines with the built CLI, checks sample section warnings, and runs the strict completeness audit. Fails clearly when `samples/` is absent or has no PDFs. | Gives a single local robustness gate for the ignored `samples/` corpus without making `pnpm run check` depend on private sample files. Generated JSON is parser output for review, not golden truth. | + +The layout extraction and completeness audit scripts require the Poppler +`pdftotext` executable. The source inspection script uses Poppler tools +(`pdftotext`, `pdfinfo`, `pdffonts`, `pdfimages`, and `pdftoppm`), `uvx` with +`pdfplumber`, and the built parser in `dist/`. The audit script will generate +missing layout text on demand, so it also depends on `pdftotext` unless the +requested layout files already exist. + +## Shared helpers + +- `lib/verification-helpers.mjs` provides shared path resolution, assertions, + JSON reads, executable path handling, and synchronous command execution for + build/package verification scripts. +- `lib/sample-script-helpers.mjs` provides shared sample-directory defaults, + simple CLI option parsing, sorted PDF discovery, child-process execution, and + `section_parse_warning` extraction for sample-oriented scripts. +- `lib/source-coverage-helpers.mjs` provides source-section inference, text + normalization, same-section source-to-output coverage matching, and output + traceability checks for PDF debugging scripts. + +## Typical workflows + +After parser or build changes, run the standard repository check: + +```bash +pnpm run check +``` + +After that, run the local sample gate when `samples/` is available: + +```bash +pnpm run samples:verify +``` + +When a sample PDF parses incorrectly, use the repo-local debugging skill. The +lowest-cost command-only workflow is: + +```bash +node scripts/extract-sample-layout-text.mjs --samples samples/ +pnpm run samples:audit-coverage -- --samples samples/ --strict +``` + +For deeper single-PDF investigation, generate a source evidence bundle: + +```bash +pnpm run source:inspect +``` + +For package-release confidence, build first and then run the artifact, package, +and size checks: + +```bash +pnpm run build +pnpm run verify:artifacts +pnpm run verify:package +pnpm run size:check +``` diff --git a/scripts/check-sample-warnings.mjs b/scripts/check-sample-warnings.mjs new file mode 100644 index 0000000..8d22a6b --- /dev/null +++ b/scripts/check-sample-warnings.mjs @@ -0,0 +1,59 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { parseLinkedInPDF } from '../dist/index.js'; +import { + defaultSamplesDir, + optionValue, + readSortedPdfFileNames, + repoRoot, + sampleWarningFailureDetailLines, + sectionParseWarnings, + unknownErrorMessage, +} from './lib/sample-script-helpers.mjs'; + +const samplesDir = path.resolve( + repoRoot, + optionValue('--samples') ?? defaultSamplesDir +); +const pdfFileNames = await readSortedPdfFileNames( + samplesDir, + `No sample PDFs found in ${samplesDir}` +); + +const failures = []; + +for (const pdfFileName of pdfFileNames) { + const pdfPath = path.join(samplesDir, pdfFileName); + + try { + const result = await parseLinkedInPDF(await fs.readFile(pdfPath)); + const warnings = sectionParseWarnings(result); + + if (warnings.length === 0) { + continue; + } + + failures.push({ + pdfFileName, + warnings, + }); + } catch (error) { + failures.push({ + parseError: unknownErrorMessage(error), + pdfFileName, + warnings: [], + }); + } +} + +if (failures.length > 0) { + const details = sampleWarningFailureDetailLines(failures).join('\n'); + + throw new Error( + `Found section_parse_warning warnings in sample PDFs:\n${details}` + ); +} + +console.log( + `No section_parse_warning warnings found in ${pdfFileNames.length} sample PDF(s).` +); diff --git a/scripts/check-size-budget.mjs b/scripts/check-size-budget.mjs new file mode 100644 index 0000000..7dabf67 --- /dev/null +++ b/scripts/check-size-budget.mjs @@ -0,0 +1,110 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { gzipSync } from 'node:zlib'; +import { + assertCondition, + ensureRegularFile, + formatBytes, + repoPath, +} from './lib/verification-helpers.mjs'; + +export const fileBudgets = [ + { + file: 'dist/index.js', + gzipBytes: 100 * 1024, + rawBytes: 256 * 1024, + }, + { + file: 'dist/index.cjs', + gzipBytes: 100 * 1024, + rawBytes: 256 * 1024, + }, + { + file: 'dist/index.min.js', + gzipBytes: 30 * 1024, + rawBytes: 100 * 1024, + }, + { + file: 'dist/cli.js', + gzipBytes: 5 * 1024, + rawBytes: 20 * 1024, + }, +]; +export const totalTopLevelJavaScriptBudget = 632 * 1024; + +function main() { + const results = fileBudgets.map(budget => { + const file = ensureRegularFile(budget.file); + const gzipBytes = gzipSync(readFileSync(file.absolutePath)).length; + + assertCondition( + file.size <= budget.rawBytes, + `${budget.file} is ${formatBytes(file.size)}, above raw budget ${formatBytes( + budget.rawBytes + )}` + ); + assertCondition( + gzipBytes <= budget.gzipBytes, + `${budget.file} is ${formatBytes( + gzipBytes + )} gzipped, above gzip budget ${formatBytes(budget.gzipBytes)}` + ); + + return { + ...file, + gzipBytes, + }; + }); + + const indexFile = results.find( + result => result.relativePath === 'dist/index.js' + ); + const minifiedFile = results.find( + result => result.relativePath === 'dist/index.min.js' + ); + + assertCondition(indexFile !== undefined, 'Missing dist/index.js size result'); + assertCondition( + minifiedFile !== undefined, + 'Missing dist/index.min.js size result' + ); + assertCondition( + minifiedFile.size < indexFile.size, + 'dist/index.min.js must be smaller than dist/index.js' + ); + + const totalTopLevelJavaScriptBytes = readdirSync(repoPath('dist')) + .filter(fileName => /\.(?:cjs|js)$/.test(fileName)) + .reduce( + (totalBytes, fileName) => + totalBytes + ensureRegularFile(`dist/${fileName}`).size, + 0 + ); + + assertCondition( + totalTopLevelJavaScriptBytes <= totalTopLevelJavaScriptBudget, + `Top-level dist JavaScript is ${formatBytes( + totalTopLevelJavaScriptBytes + )}, above budget ${formatBytes(totalTopLevelJavaScriptBudget)}` + ); + + for (const result of results) { + console.log( + `${result.relativePath}: ${formatBytes(result.size)} raw, ${formatBytes( + result.gzipBytes + )} gzip` + ); + } + console.log( + `Top-level dist JavaScript: ${formatBytes(totalTopLevelJavaScriptBytes)} raw` + ); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); + } +} diff --git a/scripts/extract-sample-layout-text.mjs b/scripts/extract-sample-layout-text.mjs new file mode 100644 index 0000000..ec428ad --- /dev/null +++ b/scripts/extract-sample-layout-text.mjs @@ -0,0 +1,88 @@ +#!/usr/bin/env node +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { + defaultSamplesDir, + execFileAsync, + optionValue, + readSortedPdfFileNames, + repoRoot, + unknownErrorMessage, +} from './lib/sample-script-helpers.mjs'; + +const defaultOutputDir = path.join( + repoRoot, + '.debug-dist', + 'sample-layout-text' +); + +const samplesDir = path.resolve( + repoRoot, + optionValue('--samples') ?? defaultSamplesDir +); +const outputDir = path.resolve( + repoRoot, + optionValue('--output') ?? defaultOutputDir +); + +function layoutTextName(pdfFileName) { + return `${path.basename(pdfFileName, path.extname(pdfFileName))}.layout.txt`; +} + +const pdfFileNames = await readSortedPdfFileNames( + samplesDir, + `No PDF files found in ${samplesDir}` +); + +await fs.mkdir(outputDir, { recursive: true }); + +const extractedFiles = []; +const failures = []; + +for (const pdfFileName of pdfFileNames) { + const pdfPath = path.join(samplesDir, pdfFileName); + const outputPath = path.join(outputDir, layoutTextName(pdfFileName)); + + try { + await execFileAsync('pdftotext', ['-layout', pdfPath, outputPath]); + extractedFiles.push({ + pdfFileName, + outputPath: path.relative(repoRoot, outputPath), + }); + } catch (error) { + failures.push({ + pdfFileName, + message: unknownErrorMessage(error), + }); + } +} + +await fs.writeFile( + path.join(outputDir, 'manifest.json'), + `${JSON.stringify( + { + generatedAt: new Date().toISOString(), + samplesDir: path.relative(repoRoot, samplesDir), + outputDir: path.relative(repoRoot, outputDir), + files: extractedFiles, + failures, + }, + null, + 2 + )}\n` +); + +if (failures.length > 0) { + const details = failures + .map(failure => `${failure.pdfFileName}: ${failure.message}`) + .join('\n'); + + throw new Error(`Failed to extract layout text for PDFs:\n${details}`); +} + +console.log( + `Extracted layout text for ${extractedFiles.length} sample PDF(s) to ${path.relative( + repoRoot, + outputDir + )}.` +); diff --git a/scripts/inspect-pdf-source.mjs b/scripts/inspect-pdf-source.mjs new file mode 100644 index 0000000..1fbd099 --- /dev/null +++ b/scripts/inspect-pdf-source.mjs @@ -0,0 +1,824 @@ +#!/usr/bin/env node +import { extractTextItems, getDocumentProxy } from 'unpdf'; +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { + execFileAsync, + defaultSamplesDir, + optionValue, + readSortedPdfFileNames, + repoRoot, + unknownErrorMessage, +} from './lib/sample-script-helpers.mjs'; +import { + createSourceCoverageReport, + createSourceSegmentsFromLayoutText, +} from './lib/source-coverage-helpers.mjs'; + +const commandMaxBuffer = 64 * 1024 * 1024; +const renderScale = 2; +const usageText = ` +Usage: + node scripts/inspect-pdf-source.mjs [--output ] + node scripts/inspect-pdf-source.mjs [more-pdfs...] [--output ] + node scripts/inspect-pdf-source.mjs --samples [--output ] + +Writes a PDF source evidence bundle with Poppler text, pdfplumber geometry, +raw unpdf items, parser structural lines, rendered page PNGs, and an HTML box +overlay. Defaults to samples/ when no PDF path or --samples value is provided. +Run pnpm run build first, or use the package script that builds first. +`; + +if (isCliEntrypoint()) { + await runCli(); +} + +export async function runCli() { + if (process.argv.includes('--help') || process.argv.includes('-h')) { + process.stdout.write(usageText); + process.exit(0); + } + + const outputOption = optionValue('--output'); + const samplesOption = optionValue('--samples'); + const pdfPaths = await resolvePdfPaths({ samplesOption }); + + if (pdfPaths.length === 0) { + throw new Error(`No PDF files provided.\n${usageText}`); + } + + const bundleSummaries = []; + const outputDirs = resolveBundleOutputDirs({ outputOption, pdfPaths }); + + for (const [index, pdfPath] of pdfPaths.entries()) { + bundleSummaries.push( + await inspectPdf({ + outputDir: outputDirs[index], + pdfPath, + sequence: index, + }) + ); + } + + console.log( + bundleSummaries + .map(summary => { + const failureText = + summary.failureCount === 0 + ? 'no failures' + : `${summary.failureCount} failure(s)`; + + return `Wrote ${summary.pdfFileName} source bundle to ${path.relative( + repoRoot, + summary.outputDir + )} (${failureText}).`; + }) + .join('\n') + ); +} + +function isCliEntrypoint() { + return ( + process.argv[1] !== undefined && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) + ); +} + +export async function resolvePdfPaths({ + dependencies = { readSortedPdfFileNames }, + positionalPdfPaths = positionalArgs(), + samplesOption, +} = {}) { + if (samplesOption !== undefined && positionalPdfPaths.length > 0) { + throw new Error('Cannot specify both --samples and positional PDF paths.'); + } + + if (samplesOption !== undefined || positionalPdfPaths.length === 0) { + const samplesDir = path.resolve( + repoRoot, + samplesOption ?? defaultSamplesDir + ); + const pdfFileNames = await dependencies.readSortedPdfFileNames( + samplesDir, + `No PDF files found in ${samplesDir}` + ); + + return pdfFileNames.map(pdfFileName => path.join(samplesDir, pdfFileName)); + } + + return positionalPdfPaths.map(pdfPath => path.resolve(repoRoot, pdfPath)); +} + +function positionalArgs() { + const args = []; + const optionsWithValues = new Set(['--output', '--samples']); + + for (let index = 2; index < process.argv.length; index += 1) { + const arg = process.argv[index]; + + if (optionsWithValues.has(arg)) { + index += 1; + continue; + } + + if (arg.startsWith('-')) { + continue; + } + + args.push(arg); + } + + return args; +} + +export function resolveBundleOutputDirs({ outputOption, pdfPaths }) { + const outputRoot = + outputOption === undefined + ? path.join(repoRoot, '.debug') + : path.resolve(repoRoot, outputOption); + + if (outputOption !== undefined && pdfPaths.length === 1) { + return [outputRoot]; + } + + const safeStems = pdfPaths.map(pdfPath => safeFileStem(pdfPath)); + const stemCounts = new Map(); + + for (const safeStem of safeStems) { + stemCounts.set(safeStem, (stemCounts.get(safeStem) ?? 0) + 1); + } + + return pdfPaths.map((pdfPath, index) => { + const safeStem = safeStems[index]; + const bundleDirName = + stemCounts.get(safeStem) === 1 + ? safeStem + : `${safeStem}-${shortPathDigest(pdfPath)}`; + + return path.join(outputRoot, bundleDirName); + }); +} + +async function inspectPdf({ outputDir, pdfPath, sequence }) { + const pdfFileName = path.basename(pdfPath); + const files = []; + const failures = []; + + await fs.mkdir(outputDir, { recursive: true }); + + const pdfBuffer = await fs.readFile(pdfPath); + const popplerArtifacts = await writePopplerArtifacts({ + failures, + files, + outputDir, + pdfPath, + }); + const unpdfArtifacts = await writeUnpdfArtifacts({ + failures, + files, + outputDir, + pdfBuffer, + }); + const parserArtifacts = await writeParserArtifacts({ + failures, + files, + outputDir, + pdfBuffer, + }); + + await writePdfplumberArtifacts({ + failures, + files, + outputDir, + pdfPath, + }); + + await writeSourceSegmentArtifacts({ + failures, + files, + layoutText: popplerArtifacts.layoutText, + outputDir, + parserOutput: parserArtifacts.parserOutput, + pdfFileName, + pdfPath, + }); + + await writeOverlayHtml({ + files, + outputDir, + pageImages: popplerArtifacts.pageImages, + parserLayout: parserArtifacts.parserLayout, + unpdfPages: unpdfArtifacts.pages, + }); + + await writeJsonFile({ + data: { + generatedAt: new Date().toISOString(), + pdfFileName, + pdfPath: path.relative(repoRoot, pdfPath), + outputDir: path.relative(repoRoot, outputDir), + sequence, + files, + failures, + }, + files, + outputDir, + relativePath: 'manifest.json', + }); + + return { + failureCount: failures.length, + outputDir, + pdfFileName, + }; +} + +async function writePopplerArtifacts({ failures, files, outputDir, pdfPath }) { + const layoutText = await writeCommandStdout({ + args: ['-layout', pdfPath, '-'], + command: 'pdftotext', + failures, + files, + outputDir, + relativePath: 'poppler.layout.txt', + }); + await writeCommandStdout({ + args: ['-raw', pdfPath, '-'], + command: 'pdftotext', + failures, + files, + outputDir, + relativePath: 'poppler.raw.txt', + }); + await writeCommandStdout({ + args: ['-bbox-layout', pdfPath, '-'], + command: 'pdftotext', + failures, + files, + outputDir, + relativePath: 'poppler.bbox.xhtml', + }); + await writeCommandStdout({ + args: [pdfPath], + command: 'pdfinfo', + failures, + files, + outputDir, + relativePath: 'pdfinfo.txt', + }); + await writeCommandStdout({ + args: [pdfPath], + command: 'pdffonts', + failures, + files, + outputDir, + relativePath: 'pdffonts.txt', + }); + await writeCommandStdout({ + args: ['-list', pdfPath], + command: 'pdfimages', + failures, + files, + outputDir, + relativePath: 'pdfimages.txt', + }); + + const pageImages = await renderPageImages({ + failures, + files, + outputDir, + pdfPath, + }); + + return { + layoutText, + pageImages, + }; +} + +async function renderPageImages({ failures, files, outputDir, pdfPath }) { + const pagePrefix = path.join(outputDir, 'page'); + + try { + await execFileAsync( + 'pdftoppm', + ['-png', '-r', String(72 * renderScale), pdfPath, pagePrefix], + { maxBuffer: commandMaxBuffer } + ); + } catch (error) { + await writeFailureFile({ + artifact: 'rendered page PNGs', + error, + failures, + outputDir, + relativePath: 'page-render-error.txt', + }); + + return []; + } + + const pageImages = (await fs.readdir(outputDir)) + .filter(fileName => /^page-\d+\.png$/.test(fileName)) + .sort((left, right) => + left.localeCompare(right, undefined, { numeric: true }) + ); + + for (const pageImage of pageImages) { + files.push(pageImage); + } + + return pageImages; +} + +async function writeUnpdfArtifacts({ failures, files, outputDir, pdfBuffer }) { + try { + const pdf = await getDocumentProxy(new Uint8Array(pdfBuffer)); + const pageDimensions = await readPageDimensions(pdf); + const { items } = await extractTextItems(pdf); + const pages = items.map((pageItems, pageIndex) => ({ + height: pageDimensions[pageIndex]?.height, + items: pageItems.map(item => normalizeUnpdfTextItem(item)), + pageIndex, + pageNumber: pageIndex + 1, + width: pageDimensions[pageIndex]?.width, + })); + + await writeJsonFile({ + data: { + pageCount: pages.length, + pages, + }, + files, + outputDir, + relativePath: 'unpdf.items.json', + }); + + return { pages }; + } catch (error) { + await writeFailureFile({ + artifact: 'unpdf.items.json', + error, + failures, + outputDir, + relativePath: 'unpdf-error.txt', + }); + + return { pages: [] }; + } +} + +export function normalizeUnpdfTextItem(item) { + if (item === null || item === undefined) { + return { + height: 0, + str: '', + width: 0, + x: 0, + y: 0, + }; + } + + return { + ...item, + x: textItemCoordinate({ item, propertyName: 'x', transformIndex: 4 }), + y: textItemCoordinate({ item, propertyName: 'y', transformIndex: 5 }), + }; +} + +function textItemCoordinate({ item, propertyName, transformIndex }) { + const directValue = item[propertyName]; + + if (Number.isFinite(directValue)) { + return directValue; + } + + const transformValue = Array.isArray(item.transform) + ? item.transform[transformIndex] + : undefined; + + return Number.isFinite(transformValue) ? transformValue : 0; +} + +async function readPageDimensions(pdf) { + const pageDimensions = []; + + for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) { + const page = await pdf.getPage(pageNumber); + const viewport = page.getViewport({ scale: 1 }); + + pageDimensions.push({ + height: viewport.height, + width: viewport.width, + }); + } + + return pageDimensions; +} + +async function writeParserArtifacts({ failures, files, outputDir, pdfBuffer }) { + try { + const parserModule = await import( + pathToFileURL(path.join(repoRoot, 'dist', 'index.js')).href + ); + const sourceDebug = + await parserModule.extractLinkedInPDFSourceDebug(pdfBuffer); + const parserOutput = await parserModule.parseLinkedInPDF(pdfBuffer, { + includeRawText: true, + }); + + await writeJsonFile({ + data: sourceDebug, + files, + outputDir, + relativePath: 'parser.structural.json', + }); + await writeJsonFile({ + data: sourceDebug.structuralLines, + files, + outputDir, + relativePath: 'parser-lines.json', + }); + await writeJsonFile({ + data: parserOutput, + files, + outputDir, + relativePath: 'parser-output.json', + }); + + return { + parserLayout: sourceDebug.layout, + parserOutput, + }; + } catch (error) { + await writeFailureFile({ + artifact: 'parser artifacts', + error, + failures, + outputDir, + relativePath: 'parser-error.txt', + }); + + return { + parserLayout: undefined, + parserOutput: undefined, + }; + } +} + +async function writePdfplumberArtifacts({ + failures, + files, + outputDir, + pdfPath, +}) { + const pythonScript = ` +import json +import sys +import pdfplumber + +pdf_path = sys.argv[1] +pages = [] +with pdfplumber.open(pdf_path) as pdf: + for page_index, page in enumerate(pdf.pages): + pages.append({ + "pageIndex": page_index, + "pageNumber": page_index + 1, + "width": page.width, + "height": page.height, + "words": page.extract_words(extra_attrs=["fontname", "size"]), + "chars": page.chars, + }) +json.dump({"pageCount": len(pages), "pages": pages}, sys.stdout) +`; + + let parsedOutput; + + try { + const { stdout } = await execFileAsync( + 'uvx', + ['--from', 'pdfplumber', 'python', '-c', pythonScript, pdfPath], + { maxBuffer: commandMaxBuffer } + ); + + parsedOutput = JSON.parse(stdout); + } catch (error) { + await writeFailureFile({ + artifact: 'pdfplumber artifacts', + error, + failures, + outputDir, + relativePath: 'pdfplumber-error.txt', + }); + + return; + } + + await writeJsonFile({ + data: { + pageCount: parsedOutput.pageCount, + pages: parsedOutput.pages.map(page => ({ + height: page.height, + pageIndex: page.pageIndex, + pageNumber: page.pageNumber, + width: page.width, + words: page.words, + })), + }, + files, + outputDir, + relativePath: 'pdfplumber.words.json', + }); + await writeJsonFile({ + data: { + pageCount: parsedOutput.pageCount, + pages: parsedOutput.pages.map(page => ({ + chars: page.chars, + height: page.height, + pageIndex: page.pageIndex, + pageNumber: page.pageNumber, + width: page.width, + })), + }, + files, + outputDir, + relativePath: 'pdfplumber.chars.json', + }); +} + +async function writeSourceSegmentArtifacts({ + failures, + files, + layoutText, + outputDir, + parserOutput, + pdfFileName, + pdfPath, +}) { + if (layoutText === undefined) { + return; + } + + const sourceView = createSourceSegmentsFromLayoutText(layoutText); + + await writeJsonFile({ + data: sourceView, + files, + outputDir, + relativePath: 'source-segments.json', + }); + + if (parserOutput !== undefined) { + await writeJsonFile({ + data: createSourceCoverageReport({ + layoutText, + parsedJson: parserOutput, + pdfFileName, + }), + files, + outputDir, + relativePath: 'parser-source-coverage.json', + }); + } + + const baselineJsonPath = replaceExtension(pdfPath, '.json'); + + try { + const baselineJson = JSON.parse( + await fs.readFile(baselineJsonPath, 'utf8') + ); + + await writeJsonFile({ + data: createSourceCoverageReport({ + layoutText, + parsedJson: baselineJson, + pdfFileName, + }), + files, + outputDir, + relativePath: 'baseline-source-coverage.json', + }); + } catch (error) { + if (error?.code !== 'ENOENT') { + await writeFailureFile({ + artifact: 'baseline-source-coverage.json', + error, + failures, + outputDir, + relativePath: 'baseline-source-coverage-error.txt', + }); + } + } +} + +async function writeOverlayHtml({ + files, + outputDir, + pageImages, + parserLayout, + unpdfPages, +}) { + const html = createOverlayHtml({ + pageImages, + parserLayout, + unpdfPages, + }); + + await writeTextFile({ + content: html, + files, + outputDir, + relativePath: 'overlay.html', + }); +} + +function createOverlayHtml({ pageImages, parserLayout, unpdfPages }) { + const pageSections = unpdfPages + .map((page, pageIndex) => + createPageOverlayHtml({ + imageName: pageImages[pageIndex], + page, + parserLayout, + }) + ) + .join('\n'); + + return ` + + + +PDF Source Overlay + + + +${pageSections} + + +`; +} + +function createPageOverlayHtml({ imageName, page, parserLayout }) { + const width = Number(page.width ?? 612); + const height = Number(page.height ?? 792); + const scaledWidth = width * renderScale; + const scaledHeight = height * renderScale; + const items = page.items + .filter(item => item.str.trim().length > 0) + .map(item => createItemOverlayHtml({ height, item, parserLayout })) + .join('\n'); + const imageHtml = + imageName === undefined + ? '
Rendered page image unavailable
' + : `Rendered PDF page ${page.pageNumber}`; + + return `
+${imageHtml} + +${items} + +
`; +} + +export function createItemOverlayHtml({ height, item, parserLayout }) { + const x = item.x * renderScale; + const y = Math.max(0, (height - item.y - item.height) * renderScale); + const width = Math.max(1, item.width * renderScale); + const itemHeight = Math.max(1, item.height * renderScale); + const columnClass = + parserLayout?.type === 'two-column' && + parserLayout.mainBounds !== undefined && + item.x < parserLayout.mainBounds.left + ? ' sidebar' + : ''; + + return ` + +${escapeHtml(item.str)} +`; +} + +async function writeCommandStdout({ + args, + command, + failures, + files, + outputDir, + relativePath, +}) { + try { + const { stdout } = await execFileAsync(command, args, { + maxBuffer: commandMaxBuffer, + }); + + await writeTextFile({ + content: stdout, + files, + outputDir, + relativePath, + }); + + return stdout; + } catch (error) { + await writeFailureFile({ + artifact: relativePath, + error, + failures, + outputDir, + relativePath: replaceExtension(relativePath, '.error.txt'), + }); + + return undefined; + } +} + +async function writeJsonFile({ data, files, outputDir, relativePath }) { + await writeTextFile({ + content: `${JSON.stringify(data, null, 2)}\n`, + files, + outputDir, + relativePath, + }); +} + +async function writeTextFile({ content, files, outputDir, relativePath }) { + await fs.writeFile(path.join(outputDir, relativePath), content); + files.push(relativePath); +} + +async function writeFailureFile({ + artifact, + error, + failures, + outputDir, + relativePath, +}) { + const message = unknownErrorMessage(error); + + failures.push( + createFailureManifestEntry({ + artifact, + message, + relativePath, + }) + ); + await fs.writeFile(path.join(outputDir, relativePath), `${message}\n`); +} + +export function createFailureManifestEntry({ + artifact, + message, + relativePath, +}) { + return { + artifact, + message, + relativePath, + }; +} + +function replaceExtension(filePath, extension) { + return `${filePath.slice(0, -path.extname(filePath).length)}${extension}`; +} + +function safeFileStem(filePath) { + return ( + path + .basename(filePath, path.extname(filePath)) + .replace(/[^a-z0-9._-]+/gi, '-') + .replace(/^-+|-+$/g, '') || 'pdf' + ); +} + +function shortPathDigest(filePath) { + return createHash('sha256') + .update(path.relative(repoRoot, path.resolve(repoRoot, filePath))) + .digest('hex') + .slice(0, 8); +} + +function formatNumber(value) { + return Number(value).toFixed(2); +} + +function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} diff --git a/scripts/lib/sample-script-helpers.mjs b/scripts/lib/sample-script-helpers.mjs new file mode 100644 index 0000000..d63a097 --- /dev/null +++ b/scripts/lib/sample-script-helpers.mjs @@ -0,0 +1,79 @@ +import { execFile } from 'node:child_process'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; + +export const execFileAsync = promisify(execFile); +export const repoRoot = fileURLToPath(new URL('../../', import.meta.url)); +export const defaultSamplesDir = path.join(repoRoot, 'samples'); + +export function hasFlag(name) { + return process.argv.includes(name); +} + +export function optionValue(name) { + const index = process.argv.indexOf(name); + + if (index === -1) { + return undefined; + } + + const value = process.argv[index + 1]; + + if (value === undefined || value.startsWith('-')) { + throw new Error(`Missing value for ${name}`); + } + + return value; +} + +export async function readSortedPdfFileNames(samplesDir, emptyMessage) { + const entries = await fs.readdir(samplesDir, { withFileTypes: true }); + const pdfFileNames = entries + .filter( + entry => entry.isFile() && entry.name.toLowerCase().endsWith('.pdf') + ) + .map(entry => entry.name) + .sort((left, right) => left.localeCompare(right)); + + if (pdfFileNames.length === 0) { + throw new Error(emptyMessage); + } + + return pdfFileNames; +} + +export function sectionParseWarnings(parsedJson) { + const warnings = Array.isArray(parsedJson.warnings) + ? parsedJson.warnings + : []; + + return warnings.filter( + warning => + warning !== null && + typeof warning === 'object' && + 'code' in warning && + warning.code === 'section_parse_warning' + ); +} + +export function sampleWarningFailureDetailLines(failures) { + return failures.flatMap(failure => { + if (failure.parseError) { + return [`${failure.pdfFileName} parse_error ${failure.parseError}`]; + } + + return failure.warnings.map(warning => { + const field = warning.field ? `.${warning.field}` : ''; + const entry = warning.entry === undefined ? '' : `#${warning.entry}`; + const rawText = warning.rawText ? `: ${warning.rawText}` : ''; + + return `${failure.pdfFileName} ${warning.section}${field}${entry} ${warning.message}${rawText}`; + }); + }); +} + +export function unknownErrorMessage(error) { + return error instanceof Error ? error.message : String(error); +} diff --git a/scripts/lib/source-coverage-helpers.mjs b/scripts/lib/source-coverage-helpers.mjs new file mode 100644 index 0000000..34efb03 --- /dev/null +++ b/scripts/lib/source-coverage-helpers.mjs @@ -0,0 +1,1325 @@ +export const sourceCoverageSections = [ + 'identity', + 'contact', + 'summary', + 'experience', + 'education', + 'top_skills', + 'languages', + 'certifications', + 'volunteer_work', + 'projects', + 'publications', + 'honors_awards', + 'recommendations', + 'interests', + 'causes', + 'activity', + 'unknown', +]; + +const defaultMainColumnStart = 24; +const ignoredSegmentPatterns = [ + /^page\s+\d+\s+of\s+\d+$/, + /^linkedin$/, + /^open profile$/, +]; +const headingSections = [ + ['contact', /^contact$/], + ['top_skills', /^top skills$/], + ['languages', /^languages$/], + ['certifications', /^certifications$/], + ['summary', /^summary$/], + ['experience', /^(?:experience|berufserfahrung)$/], + ['education', /^education$/], + ['projects', /^projects$/], + ['publications', /^publications$/], + ['honors_awards', /^honors(?:[-\s]+(?:and[-\s]+)?awards)?$/], + ['volunteer_work', /^volunteer(?:ing)?(?: experience)?$/], + ['recommendations', /^recommendations$/], + ['interests', /^interests$/], + ['causes', /^causes$/], + ['activity', /^activity$/], + ['contact', /^kontakt$/], +]; +const profileSectionByKey = new Map([ + ['contact', 'contact'], + ['top_skills', 'top_skills'], + ['languages', 'languages'], + ['certifications', 'certifications'], + ['volunteer_work', 'volunteer_work'], + ['projects', 'projects'], + ['publications', 'publications'], + ['honors_awards', 'honors_awards'], + ['summary', 'summary'], + ['experience_groups', 'experience'], + ['experience', 'experience'], + ['education', 'education'], +]); +const identityProfileKeys = new Set(['headline', 'location', 'name']); +const derivedOutputPathPatterns = [ + /^warnings(?:\.|\[|$)/, + /\.dates\.(?:start|end)\.iso$/, + /\.dates\.(?:start|end)\.precision$/, + /\.dates\.kind$/, +]; +const languageProficiencyTokens = new Set([ + 'bilingual', + 'elementary', + 'full', + 'limited', + 'native', + 'professional', + 'working', +]); +const sourceMetadataFieldRoles = new Set(['duration', 'location']); +const standaloneLocationPlaceNames = setFromList( + 'atlanta|austin|baltimore|berlin|boston|charlottesville|chicago|dallas|denver|dubai|geneva|greenwich|harjumaa|the hague|houston|incheon|kauai|london|los angeles|miami|minneapolis st paul|minneapolis|munich|münchen|new york|new york city|palo alto|paris|reno|rio de janeiro|san diego|san francisco|sao paulo|seattle|seoul|singapore|smithfield|st louis|stamford|sydney|tallinn|tel aviv|tokyo|toronto|washington' +); +const standaloneLocationCountryRegions = setFromList( + 'australia|brasil|brazil|canada|china|deutschland|england|estonia|france|germany|india|ireland|israel|italy|japan|korea|mexico|netherlands|portugal|scotland|singapore|spain|switzerland|united arab emirates|united kingdom|united states|vereinigte arabische emirate|vatican city state holy see|wales' +); +const standaloneLocationAdminRegions = setFromList( + 'bayern|california|colorado|connecticut|florida|georgia|harjumaa|hawaii|illinois|maryland|massachusetts|michigan|minnesota|nevada|new york|ohio|ontario|pennsylvania|quebec|rhode island|texas|virginia' +); +const standaloneLocationRegionCodes = setFromList( + 'ak|al|ar|az|ca|can|co|dc|de|fl|ga|hi|ia|id|il|in|ks|ky|la|ma|md|me|mi|mn|mo|ms|mt|nc|nd|ne|nh|nj|nm|nv|ny|oh|ok|on|or|pa|qc|ri|sc|sd|tn|tx|uk|us|usa|ut|va|vt|wa|wi|wv|wy' +); +const standaloneLocationGenericQualifiers = setFromList( + 'area|bay|county|metropolitan|metro|province|region|state' +); +const standaloneLocationNegativeWords = setFromList( + 'assistant|associate|chief|college|company|consulate|consultant|corporate|corporation|director|engineer|federation|finance|fellow|foundation|founder|forex|group|head|intern|investor|law|manager|officer|partner|partners|president|principal|professor|researcher|school|scientist|service|services|university' +); +const ambiguousStandaloneLocationRegionCodes = setFromList('in|me|or'); + +export function normalizeText(value) { + return value + .normalize('NFKC') + .replace(/[“”]/g, '"') + .replace(/[‘’]/g, "'") + .replace(/[‐‑‒–—]/g, '-') + .replace(/[•·]/g, ' ') + .replace(/\u00a0/g, ' ') + .replace(/([a-z])\.([A-Z])/g, '$1. $2') + .replace(/\s+([,.;:!?])/g, '$1') + .replace(/([([{])\s+/g, '$1') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); +} + +export function createSourceSegmentsFromLayoutText(layoutText) { + const pages = layoutText.split('\f'); + const rawLines = pages.flatMap(pageText => pageText.split(/\r?\n/)); + const mainColumnStart = inferMainColumnStart(rawLines); + const sectionState = { + main: undefined, + sidebar: undefined, + }; + const segments = []; + let lineNumber = 0; + + for (const [pageOffset, pageText] of pages.entries()) { + if (pageOffset > 0) { + sectionState.sidebar = undefined; + } + + const pageLines = pageText.split(/\r?\n/); + + for (const rawLine of pageLines) { + lineNumber += 1; + + for (const layoutSegment of splitLayoutSegments(rawLine)) { + const text = layoutSegment.text.trim(); + const normalizedText = normalizeText(text); + + if (normalizedText.length === 0 || ignoredSegment(normalizedText)) { + continue; + } + + const column = + layoutSegment.startColumn >= mainColumnStart ? 'main' : 'sidebar'; + const headingSection = sectionFromHeading(normalizedText); + + if (headingSection !== undefined) { + sectionState[column] = headingSection; + + if (pageOffset > 0 && column === 'sidebar') { + sectionState.main = headingSection; + } + + continue; + } + + const section = inferSegmentSection({ + column, + pageIndex: pageOffset, + sectionState, + }); + + segments.push({ + column, + lineNumber, + pageIndex: pageOffset, + rawLine, + section, + startColumn: layoutSegment.startColumn, + text, + }); + } + } + } + + return { + mainColumnStart, + segments: annotateSourceSegmentsWithFieldRoles(segments), + }; +} + +export function createSourceCoverageReport({ + layoutText, + parsedJson, + pdfFileName, +}) { + const sourceView = createSourceSegmentsFromLayoutText(layoutText); + const outputValues = collectOutputValues(parsedJson); + const outputValuesBySection = groupBySection(outputValues); + const sourceSegmentsBySection = groupBySection(sourceView.segments); + const combinedSourceTextBySection = combineSourceTextBySection( + sourceSegmentsBySection + ); + const unmatchedSourceSegments = []; + const looseSourceMatches = []; + const crossSectionOutputMatches = []; + const fieldMismatchOutputMatches = []; + const untracedOutputValues = []; + + for (const [index, segment] of sourceView.segments.entries()) { + const matchingOutputValues = + outputValuesBySection.get(segment.section) ?? []; + const match = bestSourceTextMatch( + sourceTextCandidatesForSegment(sourceView.segments, index), + matchingOutputValues.map(value => value.value) + ); + + if (match.kind === 'none') { + unmatchedSourceSegments.push(segment); + continue; + } + + if (match.kind === 'loose') { + looseSourceMatches.push({ + ...segment, + matchedValue: match.value, + }); + } + } + + for (const outputValue of outputValues) { + const combinedSourceText = + combinedSourceTextBySection.get(outputValue.section) ?? ''; + const match = bestTextMatch(outputValue.value, [combinedSourceText]); + + if (match.kind === 'none') { + const crossSectionMatch = crossSectionOutputMatch({ + combinedSourceTextBySection, + outputValue, + }); + + if (crossSectionMatch !== undefined) { + crossSectionOutputMatches.push(crossSectionMatch); + continue; + } + + untracedOutputValues.push(outputValue); + } + } + + fieldMismatchOutputMatches.push( + ...createFieldMismatchOutputMatches({ + outputValues, + sourceSegmentsBySection, + }) + ); + + const sections = createSectionReports({ + fieldMismatchOutputMatches, + outputValuesBySection, + sourceSegmentsBySection, + crossSectionOutputMatches, + unmatchedSourceSegments, + untracedOutputValues, + }); + + return { + pdfFileName, + mainColumnStart: sourceView.mainColumnStart, + rawSegmentCount: sourceView.segments.length, + unmatchedSourceSegmentCount: unmatchedSourceSegments.length, + unmatchedSourceSegments, + looseSourceMatchCount: looseSourceMatches.length, + looseSourceMatches, + crossSectionOutputMatchCount: crossSectionOutputMatches.length, + crossSectionOutputMatches, + fieldMismatchOutputMatchCount: fieldMismatchOutputMatches.length, + fieldMismatchOutputMatches, + outputValueCount: outputValues.length, + untracedOutputValueCount: untracedOutputValues.length, + untracedOutputValues, + sections, + }; +} + +export function collectOutputValues(value) { + const entries = []; + + collectOutputValuesAtPath(value, '', entries); + + return entries; +} + +function splitLayoutSegments(rawLine) { + return Array.from(rawLine.matchAll(/\S(?:.*?\S)?(?=\s{2,}|$)/g)).map( + match => ({ + startColumn: match.index ?? 0, + text: match[0], + }) + ); +} + +function annotateSourceSegmentsWithFieldRoles(segments) { + const fieldRolesByIndex = new Map(); + + annotateExperienceFieldRoles({ fieldRolesByIndex, segments }); + + return segments.map((segment, index) => { + const fieldMetadata = fieldRolesByIndex.get(index); + + return fieldMetadata === undefined + ? segment + : { + ...segment, + ...fieldMetadata, + }; + }); +} + +function annotateExperienceFieldRoles({ fieldRolesByIndex, segments }) { + const entries = sectionSegmentEntries({ section: 'experience', segments }); + let entryState = 'start'; + let groupIndex = -1; + let positionIndex = -1; + + for (const [entryIndex, { index, segment }] of entries.entries()) { + const role = experienceFieldRole({ + entries, + entryIndex, + entryState, + text: segment.text, + }); + + if (role !== undefined) { + if (role === 'organization') { + groupIndex += 1; + positionIndex += 1; + } else if ( + role === 'title' && + (entryState === 'afterDuration' || + entryState === 'afterLocation' || + entryState === 'afterDescription') + ) { + positionIndex += 1; + } + + fieldRolesByIndex.set(index, { + experienceGroupIndex: groupIndex, + experiencePositionIndex: positionIndex, + fieldRole: role, + }); + entryState = nextEntryState(role); + } + } +} + +function sectionSegmentEntries({ section, segments }) { + return segments + .map((segment, index) => ({ + index, + segment, + })) + .filter(entry => entry.segment.section === section); +} + +function experienceFieldRole({ entries, entryIndex, entryState, text }) { + if (isDurationText(text)) { + return 'duration'; + } + + if (entryState === 'start' || startsExperienceEntry(entries, entryIndex)) { + return 'organization'; + } + + if ( + (entryState === 'afterOrganization' || entryState === 'afterDescription') && + nextEntryTextIsDuration(entries, entryIndex) + ) { + return 'title'; + } + + if ( + (entryState === 'afterDuration' || entryState === 'afterLocation') && + nextEntryTextIsDuration(entries, entryIndex) + ) { + return 'title'; + } + + if ( + (entryState === 'afterDuration' || entryState === 'afterLocation') && + isLikelyStandaloneLocation(text) && + !startsExperienceEntry(entries, entryIndex) + ) { + return 'location'; + } + + if ( + entryState === 'afterDuration' || + entryState === 'afterLocation' || + entryState === 'afterDescription' + ) { + return 'description'; + } + + if (entryState === 'afterOrganization') { + return 'title'; + } + + return undefined; +} + +function nextEntryState(role) { + switch (role) { + case 'organization': + return 'afterOrganization'; + case 'title': + return 'afterTitle'; + case 'duration': + return 'afterDuration'; + case 'location': + return 'afterLocation'; + case 'description': + return 'afterDescription'; + default: + return 'start'; + } +} + +function startsExperienceEntry(entries, entryIndex) { + return ( + !isDurationText(entries[entryIndex].segment.text) && + entries[entryIndex + 1] !== undefined && + entries[entryIndex + 2] !== undefined && + !isDurationText(entries[entryIndex + 1].segment.text) && + isDurationText(entries[entryIndex + 2].segment.text) + ); +} + +function nextEntryTextIsDuration(entries, entryIndex) { + const nextEntry = entries[entryIndex + 1]; + + return ( + nextEntry !== undefined && + (isDurationText(nextEntry.segment.text) || + isEducationYearText(nextEntry.segment.text)) + ); +} + +function isDurationText(value) { + const normalizedValue = normalizeText(value); + + return ( + /(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?|\d{4})\s*-\s*(?:present|(?:jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?|\d{4}))/u.test( + normalizedValue + ) || + /^\d+\s+(?:year|years|yr|yrs|month|months|mo|mos)\b(?:\s+\d+\s+(?:month|months|mo|mos)\b)?/u.test( + normalizedValue + ) || + /\(\d+\s+(?:year|years|yr|yrs|month|months|mo|mos)\b/u.test(normalizedValue) + ); +} + +function isEducationYearText(value) { + return /^(?:\d{4})(?:\s*-\s*(?:\d{4}|present))?$/u.test(normalizeText(value)); +} + +function isLikelyStandaloneLocation(value) { + const normalizedValue = normalizeText(value); + + if ( + normalizedValue.length === 0 || + normalizedValue.length > 80 || + isDurationText(value) || + /[$@]/u.test(normalizedValue) || + /[!?;:]/u.test(normalizedValue) || + startsWithSentenceVerb(value) + ) { + return false; + } + + if (/^(?:remote|hybrid|onsite)$/u.test(normalizedValue)) { + return true; + } + + return standaloneLocationScore({ normalizedValue, value }) >= 4; +} + +function standaloneLocationScore({ normalizedValue, value }) { + const lookupText = normalizeLocationLookupText(value); + const lookupWords = lookupText.split(/\s+/u).filter(Boolean); + const hasKnownPlace = containsKnownStandaloneLocationPhrase( + lookupText, + standaloneLocationPlaceNames + ); + const hasCountryRegion = containsKnownStandaloneLocationPhrase( + lookupText, + standaloneLocationCountryRegions + ); + const hasAdminRegion = containsKnownStandaloneLocationPhrase( + lookupText, + standaloneLocationAdminRegions + ); + const hasRegionCode = hasContextualStandaloneRegionCode({ + hasKnownPlace, + lookupWords, + value, + }); + const hasGenericQualifier = lookupWords.some(word => + standaloneLocationGenericQualifiers.has(word) + ); + const hasNegativeWord = lookupWords.some(word => + standaloneLocationNegativeWords.has(word) + ); + let score = 1; + + if (!looksLikeLocationWords(value)) { + score -= 3; + } + + if (hasNegativeWord) { + score -= 4; + } + + if (standaloneLocationPlaceNames.has(lookupText)) { + score += 4; + } else if (hasKnownPlace) { + score += 3; + } + + if (standaloneLocationCountryRegions.has(lookupText)) { + score += 4; + } else if (hasCountryRegion) { + score += 3; + } + + if (standaloneLocationAdminRegions.has(lookupText)) { + score += 4; + } else if (hasAdminRegion) { + score += 2; + } + + if (hasRegionCode) { + score += 3; + } + + if (hasCommaSeparatedStandaloneRegionEvidence(value)) { + score += 2; + } + + if ( + hasGenericQualifier && + (hasKnownPlace || hasCountryRegion || hasAdminRegion || hasRegionCode) + ) { + score += 2; + } + + if ( + startsWithSentenceVerb(value) || + normalizedValue.split(/\s+/u).length > 8 + ) { + score -= 4; + } + + return score; +} + +function looksLikeLocationWords(value) { + const normalizedValue = normalizeText(value); + const words = value + .split(/\s+/u) + .map(word => word.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '')) + .filter(word => word.length > 0); + + return ( + words.length >= 1 && + words.length <= 7 && + words.every( + word => + /^[\p{Lu}\d][\p{L}\d.'-]*$/u.test(word) || + /^(?:of|and|de|del|la|the)$/iu.test(word) + ) && + !/\b(?:llc|llp|inc|corp|corporation|company|group|partners|university|college|school|foundation|law|engineer|manager|director|partner|consultant|professor|assistant|associate|scientist|researcher|fellow|intern|president|founder|officer|chief|head|principal|investor|service|services|federation|forex)\b/u.test( + normalizedValue + ) + ); +} + +function setFromList(value) { + return new Set( + value + .split('|') + .map(item => normalizeLocationLookupText(item)) + .filter(Boolean) + ); +} + +function normalizeLocationLookupText(value) { + return normalizeText(value) + .normalize('NFKD') + .replace(/\p{M}+/gu, '') + .replace(/-/g, ' ') + .replace(/[().,]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function containsKnownStandaloneLocationPhrase(value, phrases) { + for (const phrase of phrases) { + if (containsDelimitedPhrase(value, phrase)) { + return true; + } + } + + return false; +} + +export function containsDelimitedPhrase(value, phrase) { + if (phrase.length === 0) { + return false; + } + + let searchIndex = 0; + + while (searchIndex <= value.length) { + const index = value.indexOf(phrase, searchIndex); + + if (index < 0) { + return false; + } + + const before = value[index - 1]; + const after = value[index + phrase.length]; + + if ( + isStandaloneLocationDelimiter(before) && + isStandaloneLocationDelimiter(after) + ) { + return true; + } + + searchIndex = index + phrase.length; + } + + return false; +} + +function isStandaloneLocationDelimiter(value) { + return value === undefined || !/[\p{L}\p{N}]/u.test(value); +} + +function hasContextualStandaloneRegionCode({ + hasKnownPlace, + lookupWords, + value, +}) { + const regionCodeWords = standaloneRegionCodeCandidates(lookupWords).filter( + word => standaloneLocationRegionCodes.has(word) + ); + + if (regionCodeWords.length === 0) { + return false; + } + + const hasUnambiguousRegionCode = regionCodeWords.some( + word => !ambiguousStandaloneLocationRegionCodes.has(word) + ); + const hasStandaloneUpperRegionToken = + hasUnambiguousRegionCode && + /^\s*(?:[A-Z]{2,3}|(?:[A-Z]\.){2,})\s*$/u.test(value); + + if ( + hasStandaloneUpperRegionToken || + (hasKnownPlace && hasUnambiguousRegionCode) + ) { + return true; + } + + const commaSegments = value + .split(',') + .map(part => normalizeLocationLookupText(part)) + .filter(Boolean); + + if (commaSegments.length < 2) { + return false; + } + + // Require segment-level location evidence to avoid promoting full-text matches + // or ambiguous region codes across comma-separated segments. + return commaSegments.some(segment => { + const segmentWords = segment.split(/\s+/u).filter(Boolean); + const hasKnownPlaceSegment = + hasKnownPlace && + containsKnownStandaloneLocationPhrase( + segment, + standaloneLocationPlaceNames + ); + const hasUnambiguousRegionCodeSegment = standaloneRegionCodeCandidates( + segmentWords + ).some( + word => + standaloneLocationRegionCodes.has(word) && + !ambiguousStandaloneLocationRegionCodes.has(word) + ); + + return hasKnownPlaceSegment || hasUnambiguousRegionCodeSegment; + }); +} + +function standaloneRegionCodeCandidates(words) { + const candidates = [...words]; + + for (let index = 0; index < words.length - 1; index += 1) { + const firstWord = words[index]; + const secondWord = words[index + 1]; + + if ( + firstWord !== undefined && + secondWord !== undefined && + firstWord.length === 1 && + secondWord.length === 1 + ) { + candidates.push(`${firstWord}${secondWord}`); + } + } + + return candidates; +} + +function hasCommaSeparatedStandaloneRegionEvidence(value) { + const parts = value + .split(',') + .map(part => normalizeLocationLookupText(part)) + .filter(Boolean); + + if (parts.length < 2 || parts.length > 3) { + return false; + } + + return parts + .slice(1) + .some( + part => + (standaloneLocationRegionCodes.has(part) && + !ambiguousStandaloneLocationRegionCodes.has(part)) || + standaloneLocationCountryRegions.has(part) || + standaloneLocationAdminRegions.has(part) + ); +} + +function startsWithSentenceVerb(value) { + return /^(?:built|created|developed|drove|enabled|founded|grew|helped|implemented|improved|led|managed|owned|provided|served|supported|worked)\b/iu.test( + value.trim() + ); +} + +function inferMainColumnStart(rawLines) { + const startColumns = rawLines + .flatMap(rawLine => splitLayoutSegments(rawLine)) + .filter(segment => { + const normalizedText = normalizeText(segment.text); + + return ( + segment.startColumn > 12 && + normalizedText.length > 2 && + !ignoredSegment(normalizedText) + ); + }) + .map(segment => segment.startColumn) + .sort((left, right) => left - right); + + if (startColumns.length === 0) { + return defaultMainColumnStart; + } + + return startColumns[Math.floor(startColumns.length * 0.1)]; +} + +function ignoredSegment(normalizedText) { + return ignoredSegmentPatterns.some(pattern => pattern.test(normalizedText)); +} + +function sectionFromHeading(normalizedText) { + return headingSections.find(([, pattern]) => + pattern.test(normalizedText) + )?.[0]; +} + +function inferSegmentSection({ column, pageIndex, sectionState }) { + if (column === 'main') { + return sectionState.main ?? 'identity'; + } + + if ( + pageIndex > 0 && + sectionState.main !== undefined && + sectionState.sidebar === 'contact' + ) { + return sectionState.main; + } + + return sectionState.sidebar ?? sectionState.main ?? 'unknown'; +} + +function collectOutputValuesAtPath(value, path, entries) { + if (typeof value === 'string') { + const normalizedValue = normalizeText(value); + const section = sectionFromPath(path); + + if ( + normalizedValue.length > 0 && + section !== undefined && + !derivedOutputPath(path) && + !defaultOutputValue({ normalizedValue, path }) + ) { + entries.push({ + path, + section, + value, + }); + } + + return; + } + + if (Array.isArray(value)) { + value.forEach((item, index) => { + collectOutputValuesAtPath(item, `${path}[${index}]`, entries); + }); + return; + } + + if (value !== null && typeof value === 'object') { + for (const [key, childValue] of Object.entries(value)) { + collectOutputValuesAtPath( + childValue, + path.length === 0 ? key : `${path}.${key}`, + entries + ); + } + } +} + +function derivedOutputPath(path) { + return derivedOutputPathPatterns.some(pattern => pattern.test(path)); +} + +function defaultOutputValue({ normalizedValue, path }) { + return ( + /^profile\.languages\[\d+\]\.proficiency$/.test(path) && + normalizedValue === 'unknown' + ); +} + +function sectionFromPath(path) { + const profilePathMatch = /^profile\.([a-z_]+)/.exec(path); + + if (profilePathMatch === null) { + return undefined; + } + + const profileKey = profilePathMatch[1]; + + if (identityProfileKeys.has(profileKey)) { + return 'identity'; + } + + return profileSectionByKey.get(profileKey); +} + +function groupBySection(items) { + const groups = new Map(); + + for (const item of items) { + const groupedItems = groups.get(item.section) ?? []; + + groupedItems.push(item); + groups.set(item.section, groupedItems); + } + + return groups; +} + +function combineSourceTextBySection(sourceSegmentsBySection) { + const combinedSourceTextBySection = new Map(); + + for (const [section, sourceSegments] of sourceSegmentsBySection) { + combinedSourceTextBySection.set( + section, + sourceSegments.map(segment => segment.text).join(' ') + ); + } + + return combinedSourceTextBySection; +} + +function bestSourceTextMatch(sourceTexts, candidateValues) { + let looseMatch; + + for (const sourceText of sourceTexts) { + const match = bestTextMatch(sourceText, candidateValues); + + if (match.kind === 'exact') { + return match; + } + + if (match.kind === 'loose' && looseMatch === undefined) { + looseMatch = match; + } + } + + return looseMatch ?? { kind: 'none' }; +} + +function sourceTextCandidatesForSegment(segments, index) { + const segment = segments[index]; + const candidates = [segment.text]; + const previousSegment = adjacentSourceSegment({ + direction: -1, + index, + segments, + }); + const nextSegment = adjacentSourceSegment({ + direction: 1, + index, + segments, + }); + const previousText = previousSegment?.text; + const nextText = nextSegment?.text; + + if (previousText !== undefined) { + candidates.push(`${previousText} ${segment.text}`); + } + + if (nextText !== undefined) { + candidates.push(`${segment.text} ${nextText}`); + } + + if (previousText !== undefined && nextText !== undefined) { + candidates.push(`${previousText} ${segment.text} ${nextText}`); + } + + return candidates; +} + +function adjacentSourceSegment({ direction, index, segments }) { + const segment = segments[index]; + + for ( + let candidateIndex = index + direction; + candidateIndex >= 0 && candidateIndex < segments.length; + candidateIndex += direction + ) { + const candidate = segments[candidateIndex]; + + if (candidate.pageIndex !== segment.pageIndex) { + return undefined; + } + + if (Math.abs(candidate.lineNumber - segment.lineNumber) > 2) { + return undefined; + } + + if (candidate.column !== segment.column) { + continue; + } + + return candidate.section === segment.section ? candidate : undefined; + } + + return undefined; +} + +function crossSectionOutputMatch({ combinedSourceTextBySection, outputValue }) { + for (const [section, combinedSourceText] of combinedSourceTextBySection) { + if (section === outputValue.section) { + continue; + } + + const match = bestTextMatch(outputValue.value, [combinedSourceText]); + + if (match.kind !== 'none') { + return { + ...outputValue, + matchKind: match.kind, + matchedSection: section, + }; + } + } + + return undefined; +} + +function createFieldMismatchOutputMatches({ + outputValues, + sourceSegmentsBySection, +}) { + const mismatches = []; + const seenMismatches = new Set(); + + for (const outputValue of outputValues) { + const outputFieldRole = outputFieldRoleFromPath(outputValue.path); + + if (outputFieldRole === undefined) { + continue; + } + + const matchCandidates = []; + + for (const segment of sourceSegmentsBySection.get(outputValue.section) ?? + []) { + if ( + segment.fieldRole === undefined || + segment.fieldRole === outputFieldRole || + !fieldMismatchIsHighConfidence({ + outputFieldRole, + outputValue, + segment, + }) + ) { + continue; + } + + const match = bestTextMatch(segment.text, [outputValue.value]); + + if (match.kind !== 'none') { + matchCandidates.push({ match, segment }); + } + } + + const ordinalMatchCandidates = matchCandidates.filter(({ segment }) => + sourceSegmentMatchesOutputPath({ + outputPath: outputValue.path, + segment, + }) + ); + const selectedMatchCandidates = + ordinalMatchCandidates.length > 0 + ? ordinalMatchCandidates + : matchCandidates; + + for (const { match, segment } of selectedMatchCandidates) { + const mismatchKey = [ + outputValue.path, + outputFieldRole, + segment.fieldRole, + normalizeText(segment.text), + ].join('\0'); + + if (seenMismatches.has(mismatchKey)) { + continue; + } + + seenMismatches.add(mismatchKey); + mismatches.push({ + path: outputValue.path, + section: outputValue.section, + value: outputValue.value, + outputFieldRole, + sourceFieldRole: segment.fieldRole, + sourceText: segment.text, + sourceLineNumber: segment.lineNumber, + sourcePageIndex: segment.pageIndex, + matchKind: match.kind, + }); + } + } + + return mismatches; +} + +function sourceSegmentMatchesOutputPath({ outputPath, segment }) { + const outputExperienceIndex = outputExperienceIndexFromPath(outputPath); + + if (outputExperienceIndex === undefined) { + return true; + } + + if ( + outputExperienceIndex.kind === 'group' && + segment.experienceGroupIndex !== undefined + ) { + return segment.experienceGroupIndex === outputExperienceIndex.index; + } + + if ( + outputExperienceIndex.kind === 'position' && + segment.experiencePositionIndex !== undefined + ) { + return segment.experiencePositionIndex === outputExperienceIndex.index; + } + + return true; +} + +function outputExperienceIndexFromPath(path) { + const experiencePathMatch = /^profile\.experience\[(\d+)]/.exec(path); + + if (experiencePathMatch !== null) { + return { + index: Number(experiencePathMatch[1]), + kind: 'position', + }; + } + + const groupPathMatch = /^profile\.experience_groups\[(\d+)]/.exec(path); + + if (groupPathMatch !== null) { + return { + index: Number(groupPathMatch[1]), + kind: 'group', + }; + } + + return undefined; +} + +function outputFieldRoleFromPath(path) { + if ( + !/^profile\.(?:experience|experience_groups|education)(?:\.|\[)/u.test(path) + ) { + return undefined; + } + + if (/(?:^|\.)description$/u.test(path)) { + return 'description'; + } + + if (/(?:^|\.)location$/u.test(path)) { + return 'location'; + } + + if ( + /(?:^|\.)(?:duration|totalDuration|year)$/u.test(path) || + /\.dates\.(?:originalText|durationText|start\.text|end\.text)$/u.test(path) + ) { + return 'duration'; + } + + if (/(?:^|\.)(?:company|institution)$/u.test(path)) { + return 'organization'; + } + + if (/(?:^|\.)(?:title|degree)$/u.test(path)) { + return 'title'; + } + + return undefined; +} + +function fieldMismatchIsHighConfidence({ + outputFieldRole, + outputValue, + segment, +}) { + if (outputFieldRole !== 'description') { + return false; + } + + return ( + sourceMetadataFieldRoles.has(segment.fieldRole) && + sourceTextTouchesOutputBoundary({ + outputValue: outputValue.value, + sourceText: segment.text, + }) + ); +} + +function sourceTextTouchesOutputBoundary({ outputValue, sourceText }) { + return textVariants(outputValue).some(outputVariant => + textVariants(sourceText).some( + sourceVariant => + outputVariant === sourceVariant || + outputVariant.startsWith(`${sourceVariant} `) || + outputVariant.endsWith(` ${sourceVariant}`) + ) + ); +} + +function bestTextMatch(sourceText, candidateValues) { + const sourceVariants = textVariants(sourceText); + const sourceTokens = meaningfulTokens(sourceText); + let looseValue; + + for (const candidateValue of candidateValues) { + const candidateVariants = textVariants(candidateValue); + + if ( + sourceVariants.some(sourceVariant => + candidateVariants.some( + candidateVariant => + sourceVariant === candidateVariant || + sourceVariant.includes(candidateVariant) || + candidateVariant.includes(sourceVariant) + ) + ) + ) { + return { + kind: 'exact', + value: candidateValue, + }; + } + + const candidateText = normalizeText(candidateVariants.join(' ')); + const hasEnoughTokens = sourceTokens.length >= 2 || sourceText.length >= 12; + + if ( + hasEnoughTokens && + sourceTokens.length > 0 && + sourceTokens.every(token => candidateText.includes(token)) + ) { + looseValue = candidateValue; + } + + if ( + sourceTokens.length === 1 && + languageProficiencyTokens.has(sourceTokens[0]) && + candidateText.includes(sourceTokens[0]) + ) { + looseValue = candidateValue; + } + } + + if (looseValue !== undefined) { + return { + kind: 'loose', + value: looseValue, + }; + } + + return { + kind: 'none', + }; +} + +function textVariants(value) { + const variants = new Set(); + const normalizedValue = normalizeText(value); + const withoutBullet = normalizedValue.replace(/^[-*]\s+/, ''); + const withoutTrailingKind = withoutBullet.replace( + /\s+\((?:blog|company|linkedin|mobile|home|other|work)\)$/, + '' + ); + const withoutLabel = withoutTrailingKind.replace( + /^[a-z][a-z0-9 /&.'-]{1,32}:\s+/, + '' + ); + const withoutWrappedHyphenSpaces = withoutLabel.replace(/-\s+/g, '-'); + const withoutUrlSeparatorSpaces = withoutWrappedHyphenSpaces.replace( + /\s*([/:~._-])\s*/g, + '$1' + ); + const withoutScheme = withoutWrappedHyphenSpaces.replace(/^https?:\/\//, ''); + const withoutSchemeAndUrlSpaces = withoutUrlSeparatorSpaces.replace( + /^https?:\/\//, + '' + ); + const withoutWww = withoutScheme.replace(/^www\./, ''); + const withoutWwwAndUrlSpaces = withoutSchemeAndUrlSpaces.replace( + /^www\./, + '' + ); + const baseVariants = [ + normalizedValue, + withoutBullet, + withoutTrailingKind, + withoutLabel, + withoutWrappedHyphenSpaces, + withoutUrlSeparatorSpaces, + withoutScheme, + withoutSchemeAndUrlSpaces, + withoutWww, + withoutWwwAndUrlSpaces, + ]; + const urlVariants = [ + withoutScheme.length > 0 ? `https://${withoutScheme}` : '', + withoutSchemeAndUrlSpaces.length > 0 + ? `https://${withoutSchemeAndUrlSpaces}` + : '', + withoutWww.length > 0 ? `https://www.${withoutWww}` : '', + withoutWwwAndUrlSpaces.length > 0 + ? `https://www.${withoutWwwAndUrlSpaces}` + : '', + ]; + + for (const variant of [...baseVariants, ...urlVariants]) { + if (variant.length > 0) { + variants.add(variant); + } + } + + return [...variants]; +} + +function meaningfulTokens(value) { + return normalizeText(value) + .split(/[^a-z0-9+/#]+/) + .filter(token => token.length >= 2) + .filter(token => !/^\d+$/.test(token)); +} + +function createSectionReports({ + fieldMismatchOutputMatches, + outputValuesBySection, + sourceSegmentsBySection, + crossSectionOutputMatches, + unmatchedSourceSegments, + untracedOutputValues, +}) { + const sectionNames = new Set([ + ...sourceCoverageSections, + ...sourceSegmentsBySection.keys(), + ...outputValuesBySection.keys(), + ]); + + return [...sectionNames].map(section => { + const sourceSegments = sourceSegmentsBySection.get(section) ?? []; + const outputValues = outputValuesBySection.get(section) ?? []; + + return { + section, + sourceSegmentCount: sourceSegments.length, + unmatchedSourceSegmentCount: unmatchedSourceSegments.filter( + segment => segment.section === section + ).length, + outputValueCount: outputValues.length, + crossSectionOutputMatchCount: crossSectionOutputMatches.filter( + outputValue => outputValue.section === section + ).length, + fieldMismatchOutputMatchCount: fieldMismatchOutputMatches.filter( + outputValue => outputValue.section === section + ).length, + untracedOutputValueCount: untracedOutputValues.filter( + outputValue => outputValue.section === section + ).length, + }; + }); +} diff --git a/scripts/lib/verification-helpers.mjs b/scripts/lib/verification-helpers.mjs new file mode 100644 index 0000000..d62de68 --- /dev/null +++ b/scripts/lib/verification-helpers.mjs @@ -0,0 +1,94 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const repoRoot = resolve( + dirname(fileURLToPath(import.meta.url)), + '..', + '..' +); + +export function repoPath(...segments) { + return resolve(repoRoot, ...segments); +} + +export function assertCondition(condition, message) { + if (!condition) { + throw new Error(message); + } +} + +export function readJsonFile(filePath) { + return JSON.parse(readFileSync(filePath, 'utf8')); +} + +export function ensureRegularFile(relativePath) { + const absolutePath = repoPath(relativePath); + + assertCondition( + existsSync(absolutePath), + `Missing expected file: ${relativePath}` + ); + + const stats = statSync(absolutePath); + assertCondition(stats.isFile(), `Expected a file at: ${relativePath}`); + assertCondition( + stats.size > 0, + `Expected a non-empty file at: ${relativePath}` + ); + + return { + absolutePath, + relativePath, + size: stats.size, + }; +} + +export function formatBytes(byteCount) { + if (byteCount < 1024) { + return `${byteCount} B`; + } + + return `${(byteCount / 1024).toFixed(2)} KiB`; +} + +export function runCommand({ command, args, cwd = repoRoot, env = {} }) { + const result = spawnSync(command, args, { + cwd, + encoding: 'utf8', + env: { + ...process.env, + ...env, + }, + }); + + if (result.error) { + throw result.error; + } + + if (result.status !== 0) { + const renderedCommand = [command, ...args].join(' '); + throw new Error( + [ + `Command failed: ${renderedCommand}`, + `Exit status: ${result.status ?? 'unknown'}`, + result.stdout ? `stdout:\n${result.stdout}` : undefined, + result.stderr ? `stderr:\n${result.stderr}` : undefined, + ] + .filter(Boolean) + .join('\n\n') + ); + } + + return { + stderr: result.stderr, + stdout: result.stdout, + }; +} + +export function executablePath(packageBinPath) { + return process.platform === 'win32' + ? `${packageBinPath}.cmd` + : packageBinPath; +} diff --git a/scripts/sample-completeness-audit.mjs b/scripts/sample-completeness-audit.mjs new file mode 100644 index 0000000..604a7d9 --- /dev/null +++ b/scripts/sample-completeness-audit.mjs @@ -0,0 +1,188 @@ +#!/usr/bin/env node +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { + defaultSamplesDir, + execFileAsync, + hasFlag, + optionValue, + readSortedPdfFileNames, + repoRoot, + sectionParseWarnings, +} from './lib/sample-script-helpers.mjs'; +import { createSourceCoverageReport } from './lib/source-coverage-helpers.mjs'; + +const defaultLayoutDir = path.join( + repoRoot, + '.debug-dist', + 'sample-layout-text' +); +const defaultReportPath = path.join( + repoRoot, + '.debug-dist', + 'sample-completeness-audit.json' +); +const samplesDir = path.resolve( + repoRoot, + optionValue('--samples') ?? defaultSamplesDir +); +const layoutDir = path.resolve( + repoRoot, + optionValue('--layouts') ?? defaultLayoutDir +); +const reportPath = path.resolve( + repoRoot, + optionValue('--report') ?? defaultReportPath +); +const failOnUnmatched = hasFlag('--fail-on-unmatched') || hasFlag('--strict'); +const failOnSectionWarnings = + hasFlag('--fail-on-section-warnings') || hasFlag('--strict'); +const failOnLooseMatches = hasFlag('--fail-on-loose') || hasFlag('--strict'); +const failOnUntracedOutput = + hasFlag('--fail-on-untraced-output') || hasFlag('--strict'); +const failOnFieldMismatches = + hasFlag('--fail-on-field-mismatches') || hasFlag('--strict'); + +function layoutTextName(pdfFileName) { + return `${path.basename(pdfFileName, path.extname(pdfFileName))}.layout.txt`; +} + +function jsonFileName(pdfFileName) { + return `${path.basename(pdfFileName, path.extname(pdfFileName))}.json`; +} + +async function ensureLayoutText(pdfFileName) { + await fs.mkdir(layoutDir, { recursive: true }); + + const layoutPath = path.join(layoutDir, layoutTextName(pdfFileName)); + + try { + return await fs.readFile(layoutPath, 'utf8'); + } catch (error) { + if ( + error === null || + typeof error !== 'object' || + !('code' in error) || + error.code !== 'ENOENT' + ) { + throw error; + } + } + + await execFileAsync('pdftotext', [ + '-layout', + path.join(samplesDir, pdfFileName), + layoutPath, + ]); + + return fs.readFile(layoutPath, 'utf8'); +} + +const pdfFileNames = await readSortedPdfFileNames( + samplesDir, + `No PDF files found in ${samplesDir}` +); + +const fileReports = []; + +for (const pdfFileName of pdfFileNames) { + const layoutText = await ensureLayoutText(pdfFileName); + const jsonPath = path.join(samplesDir, jsonFileName(pdfFileName)); + const parsedJson = JSON.parse(await fs.readFile(jsonPath, 'utf8')); + const coverageReport = createSourceCoverageReport({ + layoutText, + parsedJson, + pdfFileName, + }); + + fileReports.push({ + ...coverageReport, + pdfFileName, + rawLineCount: coverageReport.rawSegmentCount, + unmatchedLineCount: coverageReport.unmatchedSourceSegmentCount, + unmatchedLines: coverageReport.unmatchedSourceSegments.map( + segment => segment.text + ), + sectionWarnings: sectionParseWarnings(parsedJson), + }); +} + +const totalRawLineCount = fileReports.reduce( + (total, fileReport) => total + fileReport.rawLineCount, + 0 +); +const totalUnmatchedLineCount = fileReports.reduce( + (total, fileReport) => total + fileReport.unmatchedLineCount, + 0 +); +const totalLooseSourceMatchCount = fileReports.reduce( + (total, fileReport) => total + fileReport.looseSourceMatchCount, + 0 +); +const totalUntracedOutputValueCount = fileReports.reduce( + (total, fileReport) => total + fileReport.untracedOutputValueCount, + 0 +); +const totalCrossSectionOutputMatchCount = fileReports.reduce( + (total, fileReport) => total + fileReport.crossSectionOutputMatchCount, + 0 +); +const totalFieldMismatchOutputMatchCount = fileReports.reduce( + (total, fileReport) => total + fileReport.fieldMismatchOutputMatchCount, + 0 +); +const totalSectionWarningCount = fileReports.reduce( + (total, fileReport) => total + fileReport.sectionWarnings.length, + 0 +); +const report = { + generatedAt: new Date().toISOString(), + samplesDir: path.relative(repoRoot, samplesDir), + layoutDir: path.relative(repoRoot, layoutDir), + totalPdfCount: fileReports.length, + totalRawLineCount, + totalUnmatchedLineCount, + totalLooseSourceMatchCount, + totalCrossSectionOutputMatchCount, + totalFieldMismatchOutputMatchCount, + totalUntracedOutputValueCount, + totalSectionWarningCount, + files: fileReports, +}; + +await fs.mkdir(path.dirname(reportPath), { recursive: true }); +await fs.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); + +console.log( + [ + `Audited ${fileReports.length} sample PDF/JSON pair(s).`, + `Source segments: ${totalRawLineCount}.`, + `Unmatched source segments: ${totalUnmatchedLineCount}.`, + `Loose source matches: ${totalLooseSourceMatchCount}.`, + `Cross-section output matches: ${totalCrossSectionOutputMatchCount}.`, + `Field-mismatch output matches: ${totalFieldMismatchOutputMatchCount}.`, + `Untraced output values: ${totalUntracedOutputValueCount}.`, + `section_parse_warning count: ${totalSectionWarningCount}.`, + `Report: ${path.relative(repoRoot, reportPath)}.`, + ].join('\n') +); + +if (failOnSectionWarnings && totalSectionWarningCount > 0) { + process.exitCode = 1; +} + +if (failOnUnmatched && totalUnmatchedLineCount > 0) { + process.exitCode = 1; +} + +if (failOnLooseMatches && totalLooseSourceMatchCount > 0) { + process.exitCode = 1; +} + +if (failOnFieldMismatches && totalFieldMismatchOutputMatchCount > 0) { + process.exitCode = 1; +} + +if (failOnUntracedOutput && totalUntracedOutputValueCount > 0) { + process.exitCode = 1; +} diff --git a/scripts/verify-artifacts.mjs b/scripts/verify-artifacts.mjs new file mode 100644 index 0000000..17460ca --- /dev/null +++ b/scripts/verify-artifacts.mjs @@ -0,0 +1,182 @@ +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; +import { + assertCondition, + ensureRegularFile, + readJsonFile, + repoPath, + runCommand, +} from './lib/verification-helpers.mjs'; + +const expectedFiles = [ + 'bin/cli.js', + 'dist/cli.d.ts', + 'dist/cli.js', + 'dist/cli.js.map', + 'dist/index.cjs', + 'dist/index.cjs.map', + 'dist/index.d.cts', + 'dist/index.d.ts', + 'dist/index.js', + 'dist/index.js.map', + 'dist/index.min.js', + 'dist/index.min.js.map', +]; + +const externalDependencyPatterns = [ + { + file: 'dist/index.js', + patterns: [ + /from\s*['"]chrono-node['"]/, + /from\s*['"]unpdf['"]/, + /from\s*['"]zod['"]/, + ], + }, + { + file: 'dist/index.cjs', + patterns: [ + /require\(['"]chrono-node['"]\)/, + /require\(['"]unpdf['"]\)/, + /require\(['"]zod['"]\)/, + ], + }, + { + file: 'dist/index.min.js', + patterns: [ + /from\s*['"]chrono-node['"]/, + /from\s*['"]unpdf['"]/, + /from\s*['"]zod['"]/, + ], + }, +]; + +const sampleProfileText = ` +Artifact User +artifact.user@example.com +Software Engineer +New York, New York, United States + +Experience +Developer at Artifact Co +January 2020 - Present +`; + +async function main() { + expectedFiles.forEach(ensureRegularFile); + verifyPackageManifest(); + verifyExternalDependencies(); + await verifyBundleExports(); + verifyCliEntrypoint(); + + console.log('Verified build artifacts, bundle exports, externals, and CLI.'); +} + +function verifyPackageManifest() { + const manifest = readJsonFile(repoPath('package.json')); + + assertCondition( + manifest.main === 'dist/index.cjs', + 'package.json main must point to dist/index.cjs' + ); + assertCondition( + manifest.module === 'dist/index.js', + 'package.json module must point to dist/index.js' + ); + assertCondition( + manifest.types === 'dist/index.d.ts', + 'package.json types must point to dist/index.d.ts' + ); + assertCondition( + manifest.exports?.['.']?.import?.default === './dist/index.js', + 'package.json ESM export must point to ./dist/index.js' + ); + assertCondition( + manifest.exports?.['.']?.require?.default === './dist/index.cjs', + 'package.json CJS export must point to ./dist/index.cjs' + ); + assertCondition( + manifest.bin?.['linkedin-pdf-parser'] === './bin/cli.js', + 'package.json bin must point to ./bin/cli.js' + ); +} + +function verifyExternalDependencies() { + for (const externalCheck of externalDependencyPatterns) { + const source = readFileSync(repoPath(externalCheck.file), 'utf8'); + + for (const pattern of externalCheck.patterns) { + assertCondition( + pattern.test(source), + `${externalCheck.file} does not preserve external dependency pattern ${pattern}` + ); + } + } +} + +async function verifyBundleExports() { + const cacheBust = `?verified=${Date.now()}`; + const esmBundle = await import( + `${pathToFileURL(repoPath('dist/index.js')).href}${cacheBust}` + ); + const minifiedBundle = await import( + `${pathToFileURL(repoPath('dist/index.min.js')).href}${cacheBust}` + ); + const cjsBundle = createRequire(import.meta.url)(repoPath('dist/index.cjs')); + + await verifyParserExport('dist/index.js', esmBundle.parseLinkedInPDF); + await verifyParserExport( + 'dist/index.min.js', + minifiedBundle.parseLinkedInPDF + ); + await verifyParserExport('dist/index.cjs', cjsBundle.parseLinkedInPDF); +} + +async function verifyParserExport(bundleName, parseLinkedInPDF) { + assertCondition( + typeof parseLinkedInPDF === 'function', + `${bundleName} must export parseLinkedInPDF` + ); + + const result = await parseLinkedInPDF(sampleProfileText); + + assertCondition( + result.profile.name === 'Artifact User', + `${bundleName} did not parse the sample profile name` + ); + assertCondition( + result.profile.contact.email === 'artifact.user@example.com', + `${bundleName} did not parse the sample profile email` + ); +} + +function verifyCliEntrypoint() { + const helpResult = runCommand({ + command: process.execPath, + args: [repoPath('bin/cli.js'), '--help'], + }); + assertCondition( + helpResult.stdout.includes('linkedin-pdf-parser verify-json '), + 'CLI help output did not include expected usage text' + ); + + const parseResult = runCommand({ + command: process.execPath, + args: [ + repoPath('bin/cli.js'), + repoPath('tests/fixtures/Profile.pdf'), + '--compact', + ], + }); + const parsedOutput = JSON.parse(parseResult.stdout); + + assertCondition( + parsedOutput.profile?.name === 'Harold Martin', + 'CLI did not parse the profile fixture through the built artifact' + ); +} + +main().catch(error => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/scripts/verify-packed-package.mjs b/scripts/verify-packed-package.mjs new file mode 100644 index 0000000..dd6d41a --- /dev/null +++ b/scripts/verify-packed-package.mjs @@ -0,0 +1,227 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { isAbsolute, join, resolve } from 'node:path'; +import { + assertCondition, + executablePath, + readJsonFile, + repoPath, + runCommand, +} from './lib/verification-helpers.mjs'; + +const sampleProfileText = ` +Packed Consumer +packed.consumer@example.com +Software Engineer +Austin, Texas, United States + +Experience +Developer at Packed Co +January 2020 - Present +`; + +function main() { + const manifest = readJsonFile(repoPath('package.json')); + const packDirectory = mkdtempSync(join(tmpdir(), 'linkedin-parser-pack-')); + const consumerDirectory = mkdtempSync( + join(tmpdir(), 'linkedin-parser-consumer-') + ); + + try { + const packageArchivePath = packPackage(packDirectory); + installPackedPackage({ consumerDirectory, packageArchivePath }); + verifyInstalledEsmConsumer({ + consumerDirectory, + packageName: manifest.name, + }); + verifyInstalledCjsConsumer({ + consumerDirectory, + packageName: manifest.name, + }); + verifyInstalledTypes({ consumerDirectory, packageName: manifest.name }); + verifyInstalledCli({ consumerDirectory }); + + console.log( + 'Verified npm pack contents, installed package imports, types, and CLI.' + ); + } finally { + if (process.env.KEEP_VERIFY_PACKAGE_TMP !== '1') { + rmSync(packDirectory, { force: true, recursive: true }); + rmSync(consumerDirectory, { force: true, recursive: true }); + } + } +} + +function packPackage(packDirectory) { + const packResult = runCommand({ + command: 'npm', + args: ['pack', '--json', '--pack-destination', packDirectory], + }); + const packEntries = JSON.parse(packResult.stdout); + const packageEntry = packEntries[0]; + + assertCondition( + packageEntry !== undefined, + 'npm pack did not return a package' + ); + + const packedFiles = new Set(packageEntry.files.map(file => file.path)); + const requiredPackedFiles = [ + 'bin/cli.js', + 'dist/cli.js', + 'dist/index.cjs', + 'dist/index.d.cts', + 'dist/index.d.ts', + 'dist/index.js', + 'dist/index.min.js', + 'package.json', + ]; + + for (const requiredFile of requiredPackedFiles) { + assertCondition( + packedFiles.has(requiredFile), + `Packed package is missing ${requiredFile}` + ); + } + + return isAbsolute(packageEntry.filename) + ? packageEntry.filename + : resolve(packDirectory, packageEntry.filename); +} + +function installPackedPackage({ consumerDirectory, packageArchivePath }) { + runCommand({ + command: 'npm', + args: ['init', '-y'], + cwd: consumerDirectory, + }); + runCommand({ + command: 'npm', + args: [ + 'install', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--package-lock=false', + packageArchivePath, + ], + cwd: consumerDirectory, + }); +} + +function verifyInstalledEsmConsumer({ consumerDirectory, packageName }) { + writeFileSync( + join(consumerDirectory, 'esm-consumer.mjs'), + ` +import { parseLinkedInPDF } from ${JSON.stringify(packageName)}; + +const result = await parseLinkedInPDF(${JSON.stringify(sampleProfileText)}); +if (result.profile.name !== 'Packed Consumer') { + throw new Error('ESM import did not parse the expected profile name'); +} +if (result.profile.contact.email !== 'packed.consumer@example.com') { + throw new Error('ESM import did not parse the expected profile email'); +} +` + ); + + runCommand({ + command: process.execPath, + args: ['esm-consumer.mjs'], + cwd: consumerDirectory, + }); +} + +function verifyInstalledCjsConsumer({ consumerDirectory, packageName }) { + writeFileSync( + join(consumerDirectory, 'cjs-consumer.cjs'), + ` +const { parseLinkedInPDF } = require(${JSON.stringify(packageName)}); + +parseLinkedInPDF(${JSON.stringify(sampleProfileText)}).then(result => { + if (result.profile.name !== 'Packed Consumer') { + throw new Error('CJS require did not parse the expected profile name'); + } + if (result.profile.contact.email !== 'packed.consumer@example.com') { + throw new Error('CJS require did not parse the expected profile email'); + } +}).catch(error => { + console.error(error); + process.exit(1); +}); +` + ); + + runCommand({ + command: process.execPath, + args: ['cjs-consumer.cjs'], + cwd: consumerDirectory, + }); +} + +function verifyInstalledTypes({ consumerDirectory, packageName }) { + writeFileSync( + join(consumerDirectory, 'typecheck.ts'), + ` +import { parseLinkedInPDF, type ParseResult } from ${JSON.stringify(packageName)}; + +async function parseProfile(): Promise { + return parseLinkedInPDF(${JSON.stringify(sampleProfileText)}); +} + +void parseProfile(); +` + ); + + runCommand({ + command: executablePath(repoPath('node_modules/.bin/tsc')), + args: [ + '--strict', + '--target', + 'ES2022', + '--module', + 'NodeNext', + '--moduleResolution', + 'NodeNext', + '--noEmit', + '--skipLibCheck', + 'typecheck.ts', + ], + cwd: consumerDirectory, + }); +} + +function verifyInstalledCli({ consumerDirectory }) { + const cliPath = executablePath( + join(consumerDirectory, 'node_modules/.bin/linkedin-pdf-parser') + ); + const helpResult = runCommand({ + command: cliPath, + args: ['--help'], + cwd: consumerDirectory, + }); + + assertCondition( + helpResult.stdout.includes('linkedin-pdf-parser verify-json '), + 'Installed CLI help output did not include expected usage text' + ); + + const parseResult = runCommand({ + command: cliPath, + args: [repoPath('tests/fixtures/Profile.pdf'), '--compact'], + cwd: consumerDirectory, + }); + const parsedOutput = JSON.parse(parseResult.stdout); + + assertCondition( + parsedOutput.profile?.name === 'Harold Martin', + 'Installed CLI did not parse the profile fixture' + ); +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +} diff --git a/scripts/verify-samples.mjs b/scripts/verify-samples.mjs new file mode 100644 index 0000000..bee17dd --- /dev/null +++ b/scripts/verify-samples.mjs @@ -0,0 +1,336 @@ +#!/usr/bin/env node +import { execFile } from 'node:child_process'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { promisify } from 'node:util'; +import { + defaultSamplesDir, + optionValue, + repoRoot, + unknownErrorMessage, +} from './lib/sample-script-helpers.mjs'; + +const execFileAsync = promisify(execFile); +const commandMaxBuffer = 64 * 1024 * 1024; + +const nodeSampleVerificationDependencies = { + async directoryExists(directoryPath) { + try { + return (await fs.stat(directoryPath)).isDirectory(); + } catch { + return false; + } + }, + async listDirectory(directoryPath) { + return (await fs.readdir(directoryPath, { withFileTypes: true })).map( + entry => ({ + kind: entry.isFile() + ? 'file' + : entry.isDirectory() + ? 'directory' + : 'other', + name: entry.name, + }) + ); + }, + async runCommand({ args, command }) { + try { + const { stderr, stdout } = await execFileAsync(command, args, { + cwd: repoRoot, + maxBuffer: commandMaxBuffer, + }); + + return { + exitCode: 0, + stderr, + stdout, + }; + } catch (error) { + return { + exitCode: commandExitCode(error), + stderr: commandErrorOutput(error), + stdout: commandStdout(error), + }; + } + }, +}; + +if (isCliEntrypoint()) { + const samplesOption = optionValue('--samples'); + const result = await verifySamples({ + samplesDir: samplesOption ?? defaultSamplesDir, + }); + + if (result.stdout) { + process.stdout.write(result.stdout); + } + + if (result.stderr) { + process.stderr.write(result.stderr); + } + + process.exitCode = result.exitCode; +} + +export async function verifySamples({ + dependencies = nodeSampleVerificationDependencies, + samplesDir = defaultSamplesDir, +} = {}) { + const resolvedSamplesDir = path.resolve(repoRoot, samplesDir); + const sampleCorpus = await resolveSampleCorpus({ + dependencies, + samplesDir: resolvedSamplesDir, + }); + + if (sampleCorpus.kind === 'invalid') { + return sampleCorpus.result; + } + + const samplePathArg = path.relative(repoRoot, resolvedSamplesDir) || '.'; + const stepResults = []; + const failures = []; + + for (const step of sampleVerificationSteps(samplePathArg, sampleCorpus)) { + const commandResult = await dependencies.runCommand({ + args: step.args, + command: step.command, + }); + const stepResult = { + ...step, + result: commandResult, + }; + + stepResults.push(stepResult); + + if (commandResult.exitCode !== 0) { + failures.push(stepResult); + + if (step.stopOnFailure) { + break; + } + } + } + + return { + exitCode: failures.length === 0 ? 0 : 1, + stderr: formatFailureSummary(failures), + stdout: formatStepResults({ + pairCount: sampleCorpus.pairCount, + samplePathArg, + stepResults, + }), + }; +} + +export function sampleVerificationSteps( + samplePathArg, + { shouldGenerateJson = false } = {} +) { + const steps = [ + { + args: ['run', 'build'], + command: 'pnpm', + label: 'Build package', + stopOnFailure: true, + }, + ]; + + if (shouldGenerateJson) { + steps.push({ + args: ['bin/cli.js', 'write-json', samplePathArg], + command: 'node', + label: 'Generate suspect sample JSON baselines', + stopOnFailure: true, + }); + } + + steps.push( + { + args: ['bin/cli.js', 'verify-json', samplePathArg], + command: 'node', + label: 'Verify sample JSON baselines', + stopOnFailure: false, + }, + { + args: ['scripts/check-sample-warnings.mjs', '--samples', samplePathArg], + command: 'node', + label: 'Check sample section warnings', + stopOnFailure: false, + }, + { + args: [ + 'scripts/sample-completeness-audit.mjs', + '--samples', + samplePathArg, + '--strict', + ], + command: 'node', + label: 'Audit sample source coverage', + stopOnFailure: false, + } + ); + + return steps; +} + +async function resolveSampleCorpus({ dependencies, samplesDir }) { + if (!(await dependencies.directoryExists(samplesDir))) { + return { + kind: 'invalid', + result: { + exitCode: 1, + stderr: + [ + `Error: Sample directory not found: ${samplesDir}`, + 'The samples directory is local and gitignored; add PDF/JSON sample pairs or pass --samples .', + ].join('\n') + '\n', + stdout: '', + }, + }; + } + + const entries = await dependencies.listDirectory(samplesDir); + const pdfNames = fileNamesByExtension(entries, '.pdf'); + const jsonNames = fileNamesByExtension(entries, '.json'); + + if (pdfNames.length > 0 && jsonNames.length === 0) { + return { + kind: 'valid', + pairCount: pdfNames.length, + shouldGenerateJson: true, + }; + } + + const jsonStems = new Set(jsonNames.map(name => fileStem(name))); + const pairCount = pdfNames.filter(name => + jsonStems.has(fileStem(name)) + ).length; + + if (pairCount === 0) { + return { + kind: 'invalid', + result: { + exitCode: 1, + stderr: + [ + `Error: No matching PDF/JSON sample pairs found in ${samplesDir}`, + `Found ${pdfNames.length} PDF file(s) and ${jsonNames.length} JSON file(s).`, + 'The samples directory is local and gitignored; add matching top-level files such as Profile.pdf and Profile.json.', + ].join('\n') + '\n', + stdout: '', + }, + }; + } + + return { + kind: 'valid', + pairCount, + shouldGenerateJson: false, + }; +} + +function fileNamesByExtension(entries, extension) { + return entries + .filter( + entry => + entry.kind === 'file' && entry.name.toLowerCase().endsWith(extension) + ) + .map(entry => entry.name); +} + +function fileStem(fileName) { + const extensionIndex = fileName.lastIndexOf('.'); + + return extensionIndex === -1 + ? fileName.toLowerCase() + : fileName.slice(0, extensionIndex).toLowerCase(); +} + +function formatStepResults({ pairCount, samplePathArg, stepResults }) { + const lines = [ + `Verifying ${pairCount} local sample pair(s) in ${samplePathArg}.`, + ]; + + for (const stepResult of stepResults) { + lines.push( + '', + `[samples:verify] ${stepResult.label}: ${commandLine(stepResult)}` + ); + + if (stepResult.result.stdout) { + lines.push(stepResult.result.stdout.trimEnd()); + } + + if (stepResult.result.stderr) { + lines.push(stepResult.result.stderr.trimEnd()); + } + } + + if ( + stepResults.length > 0 && + stepResults.every(stepResult => stepResult.result.exitCode === 0) + ) { + lines.push('', 'Local sample verification passed.'); + } + + return `${lines.join('\n')}\n`; +} + +function formatFailureSummary(failures) { + if (failures.length === 0) { + return ''; + } + + return `${[ + 'Sample verification failed:', + ...failures.map( + failure => + `- ${failure.label} exited with code ${failure.result.exitCode}: ${commandLine( + failure + )}` + ), + ].join('\n')}\n`; +} + +function commandLine({ args, command }) { + return [command, ...args].join(' '); +} + +function commandExitCode(error) { + return error !== null && + typeof error === 'object' && + 'code' in error && + typeof error.code === 'number' + ? error.code + : 1; +} + +function commandStdout(error) { + return error !== null && + typeof error === 'object' && + 'stdout' in error && + typeof error.stdout === 'string' + ? error.stdout + : ''; +} + +function commandErrorOutput(error) { + const stderr = + error !== null && + typeof error === 'object' && + 'stderr' in error && + typeof error.stderr === 'string' + ? error.stderr + : ''; + const message = unknownErrorMessage(error); + + return stderr.length > 0 ? stderr : `${message}\n`; +} + +function isCliEntrypoint() { + return ( + process.argv[1] !== undefined && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) + ); +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..8153f48 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,377 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { parseLinkedInPDF, type ParseResult } from './index.js'; +import { + formatErrorMessage, + formatJson, + hasFileExtension, + verifyJsonFixtures, + writeJsonFixtures, + type JsonDiffOutputFormat, + type JsonFixtureDependencies, + type JsonFixtureDirectoryEntry, + type JsonOutputFormat, +} from './json-fixtures.js'; +import { getNodeDirectoryEntryKind } from './node-directory-entry.js'; + +type CliExitCode = 0 | 1; + +export type CliDirectoryEntry = JsonFixtureDirectoryEntry; + +interface ParseCommand { + kind: 'parse'; + pdfPath: string; + includeRawText: boolean; + outputFormat: JsonOutputFormat; +} + +interface HelpCommand { + kind: 'help'; +} + +interface InvalidCommand { + kind: 'invalid'; + message: string; +} + +interface WriteJsonCommand { + kind: 'write-json'; + folderPath: string; + includeRawText: boolean; + outputFormat: JsonOutputFormat; + overwriteExisting: boolean; +} + +interface VerifyJsonCommand { + kind: 'verify-json'; + diffOutputFormat: JsonDiffOutputFormat; + folderPath: string; + includeRawText: boolean; +} + +type CliCommand = + | HelpCommand + | InvalidCommand + | ParseCommand + | VerifyJsonCommand + | WriteJsonCommand; + +export interface CliDependencies extends JsonFixtureDependencies {} + +export interface RunCliParams { + args: string[]; + dependencies?: CliDependencies; +} + +export interface CliResult { + exitCode: CliExitCode; + stderr: string; + stdout: string; +} + +const usageText = ` +Usage: + linkedin-pdf-parser [options] + linkedin-pdf-parser write-json [--raw-text] [--compact] [--force] + linkedin-pdf-parser verify-json [--raw-text] [--json-paths] + +Arguments: + Path to the LinkedIn PDF file to parse + Folder containing top-level PDF/JSON baseline files + +Options: + --raw-text Include raw extracted text in output + --pretty Pretty-print JSON output (default: true) + --compact Compact JSON output (no formatting) + --force Overwrite existing JSON files in write-json mode + --json-paths Print semantic JSON keypath changes in verify-json mode + --help, -h Show this help message + +Examples: + linkedin-pdf-parser ./resume.pdf + linkedin-pdf-parser /path/to/linkedin-resume.pdf --raw-text + linkedin-pdf-parser resume.pdf --compact + linkedin-pdf-parser write-json ./fixtures --force + linkedin-pdf-parser verify-json ./fixtures + linkedin-pdf-parser verify-json ./fixtures --json-paths + +Output: + Outputs structured JSON to stdout with parsed LinkedIn profile data + Folder modes print summaries and use exit code 1 for any failed file +`; + +const nodeCliDependencies: CliDependencies = { + directoryExists: directoryPath => + fs.existsSync(directoryPath) && fs.statSync(directoryPath).isDirectory(), + fileExists: fs.existsSync, + listDirectory: directoryPath => + fs.readdirSync(directoryPath, { withFileTypes: true }).map(entry => ({ + kind: getNodeDirectoryEntryKind(directoryPath, entry), + name: entry.name, + })), + parsePdf: parseLinkedInPDF, + readFile: fs.readFileSync, + readTextFile: filePath => fs.readFileSync(filePath, 'utf8'), + resolvePath: path.resolve, + writeTextFile: fs.writeFileSync, +}; + +export async function runCli({ + args, + dependencies = nodeCliDependencies, +}: RunCliParams): Promise { + const command = parseCliCommand(args); + + if (command.kind === 'help') { + return { + exitCode: 0, + stderr: '', + stdout: usageText, + }; + } + + if (command.kind === 'invalid') { + return { + exitCode: 1, + stderr: `Error: ${command.message}\n${usageText}`, + stdout: '', + }; + } + + try { + if (command.kind === 'parse') { + return await runParseCommand(command, dependencies); + } + + if (command.kind === 'write-json') { + return await runWriteJsonCommand(command, dependencies); + } + + return await runVerifyJsonCommand(command, dependencies); + } catch (error) { + return { + exitCode: 1, + stderr: `Error: ${formatErrorMessage(error)}\n`, + stdout: '', + }; + } +} + +export async function main( + args: string[] = process.argv.slice(2) +): Promise { + const result = await runCli({ args }); + + if (result.stdout) { + process.stdout.write(result.stdout); + } + + if (result.stderr) { + process.stderr.write(result.stderr); + } + + return result.exitCode; +} + +function parseCliCommand(args: string[]): CliCommand { + if (args.includes('--help') || args.includes('-h') || args.length === 0) { + return { kind: 'help' }; + } + + if (args[0] === 'write-json') { + return parseWriteJsonCommand(args.slice(1)); + } + + if (args[0] === 'verify-json') { + return parseVerifyJsonCommand(args.slice(1)); + } + + const pdfPath = args.find(arg => !arg.startsWith('--')); + if (!pdfPath) { + return { + kind: 'invalid', + message: 'No PDF file path provided', + }; + } + + return { + kind: 'parse', + pdfPath, + includeRawText: args.includes('--raw-text'), + outputFormat: args.includes('--compact') ? 'compact' : 'pretty', + }; +} + +function parseWriteJsonCommand(args: string[]): CliCommand { + const unsupportedFlag = getUnsupportedFlagCommand(args, [ + '--compact', + '--force', + '--raw-text', + ]); + + if (unsupportedFlag) { + return unsupportedFlag; + } + + const folderPath = getSinglePositionalArg(args, 'write-json'); + + if (folderPath.kind === 'invalid') { + return folderPath; + } + + return { + kind: 'write-json', + folderPath: folderPath.value, + includeRawText: args.includes('--raw-text'), + outputFormat: args.includes('--compact') ? 'compact' : 'pretty', + overwriteExisting: args.includes('--force'), + }; +} + +function parseVerifyJsonCommand(args: string[]): CliCommand { + const unsupportedFlag = getUnsupportedFlagCommand(args, [ + '--json-paths', + '--raw-text', + ]); + + if (unsupportedFlag) { + return unsupportedFlag; + } + + const folderPath = getSinglePositionalArg(args, 'verify-json'); + + if (folderPath.kind === 'invalid') { + return folderPath; + } + + return { + kind: 'verify-json', + diffOutputFormat: args.includes('--json-paths') ? 'json-paths' : 'context', + folderPath: folderPath.value, + includeRawText: args.includes('--raw-text'), + }; +} + +function getUnsupportedFlagCommand( + args: string[], + supportedFlags: string[] +): InvalidCommand | undefined { + const unsupportedFlag = args.find( + arg => isCliFlagArg(arg) && !supportedFlags.includes(arg) + ); + + if (!unsupportedFlag) { + return undefined; + } + + return { + kind: 'invalid', + message: `Unknown option: ${unsupportedFlag}`, + }; +} + +function getSinglePositionalArg( + args: string[], + commandName: string +): InvalidCommand | { kind: 'valid'; value: string } { + const positionalArgs = args.filter(arg => !isCliFlagArg(arg)); + + if (positionalArgs.length === 0) { + return { + kind: 'invalid', + message: `No folder path provided for ${commandName}`, + }; + } + + if (positionalArgs.length > 1) { + return { + kind: 'invalid', + message: `Only one folder path may be provided for ${commandName}`, + }; + } + + return { + kind: 'valid', + value: positionalArgs[0], + }; +} + +function isCliFlagArg(arg: string): boolean { + return arg.startsWith('-'); +} + +async function runParseCommand( + command: ParseCommand, + dependencies: CliDependencies +): Promise { + const resolvedPath = dependencies.resolvePath(command.pdfPath); + + if (!dependencies.fileExists(resolvedPath)) { + return { + exitCode: 1, + stderr: `Error: File not found: ${resolvedPath}\n`, + stdout: '', + }; + } + + if (!hasFileExtension(resolvedPath, '.pdf')) { + return { + exitCode: 1, + stderr: `Error: File must be a PDF: ${resolvedPath}\n`, + stdout: '', + }; + } + + const result = await parsePdfFile({ + dependencies, + includeRawText: command.includeRawText, + pdfPath: resolvedPath, + }); + + return { + exitCode: 0, + stderr: '', + stdout: `${formatJson(result, command.outputFormat)}\n`, + }; +} + +async function runWriteJsonCommand( + command: WriteJsonCommand, + dependencies: CliDependencies +): Promise { + return writeJsonFixtures({ + dependencies, + folderPath: command.folderPath, + includeRawText: command.includeRawText, + outputFormat: command.outputFormat, + overwriteExisting: command.overwriteExisting, + }); +} + +async function runVerifyJsonCommand( + command: VerifyJsonCommand, + dependencies: CliDependencies +): Promise { + return verifyJsonFixtures({ + dependencies, + diffOutputFormat: command.diffOutputFormat, + folderPath: command.folderPath, + includeRawText: command.includeRawText, + }); +} + +async function parsePdfFile({ + dependencies, + includeRawText, + pdfPath, +}: ParsePdfFileParams): Promise { + return dependencies.parsePdf(dependencies.readFile(pdfPath), { + includeRawText, + }); +} + +interface ParsePdfFileParams { + dependencies: CliDependencies; + includeRawText: boolean; + pdfPath: string; +} diff --git a/src/diagnostics.ts b/src/diagnostics.ts new file mode 100644 index 0000000..39b4d62 --- /dev/null +++ b/src/diagnostics.ts @@ -0,0 +1,222 @@ +import { PROFILE_SECTION_HEADER_ENTRIES } from './utils/profile-section-headers.js'; +import type { + LinkedInProfile, + ParseDiagnostics, + ParseWarning, + WarningSection, +} from './types/profile.js'; + +interface CreateParseDiagnosticsParams { + profile: LinkedInProfile; + text: string; + warnings: ParseWarning[]; +} + +const SECTION_HEADER_BY_TEXT = new Map( + PROFILE_SECTION_HEADER_ENTRIES.map(([text, section]) => [ + normalizeSectionHeaderText(text), + section, + ]) +); + +export function createParseDiagnostics({ + profile, + text, + warnings, +}: CreateParseDiagnosticsParams): ParseDiagnostics { + const sectionsFound = dedupeSections([ + ...detectSectionsFromText(text), + ...detectSectionsFromWarnings(warnings), + ]); + const isEmpty = isEmptyProfile(profile); + const confidence = calculateConfidence({ + isEmpty, + profile, + sectionsFound, + text, + warnings, + }); + + return { + confidence, + isEmpty, + isLikelyLinkedInExport: + !isEmpty && + (hasLinkedInSignal({ profile, text }) || + sectionsFound.length >= 2 || + (sectionsFound.length > 0 && confidence >= 0.5)), + sectionsFound, + }; +} + +function detectSectionsFromText(text: string): WarningSection[] { + return text + .split(/\r?\n/) + .map(line => sectionFromLine(line)) + .filter((section): section is WarningSection => section !== undefined); +} + +function detectSectionsFromWarnings( + warnings: ParseWarning[] +): WarningSection[] { + return warnings.flatMap(warning => + warning.code === 'section_parse_warning' ? [warning.section] : [] + ); +} + +function sectionFromLine(line: string): WarningSection | undefined { + const normalizedLine = normalizeSectionHeaderText(line); + const ampersandAsAndLine = normalizedLine.replace(/\s*&\s*/g, ' and '); + + return ( + SECTION_HEADER_BY_TEXT.get(normalizedLine) ?? + SECTION_HEADER_BY_TEXT.get(ampersandAsAndLine) + ); +} + +function dedupeSections(sections: WarningSection[]): WarningSection[] { + return Array.from(new Set(sections)); +} + +function calculateConfidence({ + isEmpty, + profile, + sectionsFound, + text, + warnings, +}: { + isEmpty: boolean; + profile: LinkedInProfile; + sectionsFound: WarningSection[]; + text: string; + warnings: ParseWarning[]; +}): number { + if (isEmpty) { + return 0; + } + + const baseScore = + linkedInSignalScore({ profile, text }) + + sectionScore(sectionsFound) + + identityScore(profile) + + contactScore(profile) + + structuredContentScore(profile); + const warningPenalty = Math.min(warnings.length * 0.02, 0.15); + + return roundConfidence(clampConfidence(baseScore - warningPenalty)); +} + +function linkedInSignalScore({ + profile, + text, +}: { + profile: LinkedInProfile; + text: string; +}): number { + return hasLinkedInSignal({ profile, text }) ? 0.3 : 0; +} + +function sectionScore(sectionsFound: WarningSection[]): number { + return Math.min(sectionsFound.length * 0.06, 0.25); +} + +function identityScore(profile: LinkedInProfile): number { + return ( + (cleanValue(profile.name) ? 0.12 : 0) + + (cleanValue(profile.headline) ? 0.05 : 0) + + (cleanValue(profile.location) ? 0.05 : 0) + ); +} + +function contactScore(profile: LinkedInProfile): number { + return hasContact(profile) ? 0.1 : 0; +} + +function structuredContentScore(profile: LinkedInProfile): number { + return ( + (profile.experience.length > 0 || profile.experience_groups.length > 0 + ? 0.15 + : 0) + + (profile.education.length > 0 ? 0.1 : 0) + + (hasListContent(profile) ? 0.1 : 0) + ); +} + +function hasLinkedInSignal({ + profile, + text, +}: { + profile: LinkedInProfile; + text: string; +}): boolean { + return ( + cleanValue(profile.contact.linkedin_url) !== undefined || + /\blinkedin(?:\.com| profile|\b)/i.test(text) + ); +} + +function isEmptyProfile(profile: LinkedInProfile): boolean { + return ( + !cleanValue(profile.name) && + !cleanValue(profile.headline) && + !cleanValue(profile.location) && + !cleanValue(profile.summary) && + !hasContact(profile) && + profile.top_skills.length === 0 && + profile.languages.length === 0 && + profile.certifications.length === 0 && + profile.volunteer_work.length === 0 && + profile.projects.length === 0 && + profile.publications.length === 0 && + profile.honors_awards.length === 0 && + profile.experience_groups.length === 0 && + profile.experience.length === 0 && + profile.education.length === 0 + ); +} + +function hasContact(profile: LinkedInProfile): boolean { + return ( + cleanValue(profile.contact.email) !== undefined || + cleanValue(profile.contact.phone) !== undefined || + cleanValue(profile.contact.linkedin_url) !== undefined || + cleanValue(profile.contact.location) !== undefined || + (profile.contact.links?.length ?? 0) > 0 + ); +} + +function hasListContent(profile: LinkedInProfile): boolean { + return ( + profile.top_skills.length > 0 || + profile.languages.length > 0 || + profile.certifications.length > 0 || + profile.volunteer_work.length > 0 || + profile.projects.length > 0 || + profile.publications.length > 0 || + profile.honors_awards.length > 0 + ); +} + +function normalizeSectionHeaderText(text: string): string { + return text + .normalize('NFKC') + .replace(/[\uE000-\uF8FF]/g, ' ') + .replace(/[^\p{L}\p{M}\p{N}&]+/gu, ' ') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); +} + +function cleanValue(value: string | undefined): string | undefined { + const cleanedValue = value?.replace(/\s+/g, ' ').trim(); + + return cleanedValue && cleanedValue.length > 0 ? cleanedValue : undefined; +} + +function clampConfidence(value: number): number { + return Math.min(Math.max(value, 0), 1); +} + +function roundConfidence(value: number): number { + return Math.round(value * 100) / 100; +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..329ee61 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,111 @@ +export type LinkedInProfileParseErrorCode = + | 'encrypted_pdf' + | 'invalid_pdf' + | 'not_linkedin_profile' + | 'schema_validation_failed' + | 'text_extraction_failed' + | 'unsupported_pdf'; + +interface LinkedInProfileParseErrorParams { + cause?: unknown; + code: LinkedInProfileParseErrorCode; + message?: string; +} + +interface CreateLinkedInProfileParseErrorParams { + cause?: unknown; + code: LinkedInProfileParseErrorCode; + message?: string; +} + +const DEFAULT_ERROR_MESSAGES: Record = { + encrypted_pdf: 'PDF is encrypted and cannot be parsed without a password', + invalid_pdf: 'PDF appears to be invalid or unreadable', + not_linkedin_profile: 'Input does not look like a LinkedIn profile export', + schema_validation_failed: 'Parsed profile result failed schema validation', + text_extraction_failed: 'PDF appears to be empty or unreadable', + unsupported_pdf: 'PDF uses unsupported features and cannot be parsed', +}; +const TEXT_EXTRACTION_FAILED_MESSAGE = 'Input text could not be parsed'; + +export class LinkedInProfileParseError extends Error { + readonly code: LinkedInProfileParseErrorCode; + override readonly cause?: unknown; + + constructor({ + cause, + code, + message = DEFAULT_ERROR_MESSAGES[code], + }: LinkedInProfileParseErrorParams) { + super(message, { cause }); + this.name = 'LinkedInProfileParseError'; + this.code = code; + this.cause = cause; + } +} + +export function createLinkedInProfileParseError({ + cause, + code, + message, +}: CreateLinkedInProfileParseErrorParams): LinkedInProfileParseError { + return new LinkedInProfileParseError({ + cause, + code, + message, + }); +} + +export function normalizeLinkedInProfileParseError({ + cause, + inputKind, +}: { + cause: unknown; + inputKind: 'pdf' | 'text'; +}): LinkedInProfileParseError { + if (cause instanceof LinkedInProfileParseError) { + return cause; + } + + if (inputKind === 'pdf') { + return createLinkedInProfileParseError({ + cause, + code: classifyPdfErrorCode(cause), + }); + } + + return createLinkedInProfileParseError({ + cause, + code: 'text_extraction_failed', + message: TEXT_EXTRACTION_FAILED_MESSAGE, + }); +} + +function classifyPdfErrorCode(cause: unknown): LinkedInProfileParseErrorCode { + const errorText = formatUnknownError(cause).toLowerCase(); + + if ( + errorText.includes('password') || + errorText.includes('encrypted') || + errorText.includes('needspassword') + ) { + return 'encrypted_pdf'; + } + + if ( + errorText.includes('unsupported') || + errorText.includes('not implemented') + ) { + return 'unsupported_pdf'; + } + + return 'invalid_pdf'; +} + +function formatUnknownError(error: unknown): string { + if (error instanceof Error) { + return `${error.name} ${error.message}`; + } + + return String(error); +} diff --git a/src/formatter.ts b/src/formatter.ts new file mode 100644 index 0000000..c8a94ca --- /dev/null +++ b/src/formatter.ts @@ -0,0 +1,299 @@ +import type { + Contact, + Education, + Experience, + Language, + LinkedInProfile, +} from './types/profile.js'; + +export interface FormatLinkedInProfileOptions { + includeContact?: boolean; + outputFormat?: LinkedInProfileOutputFormat; +} + +export type LinkedInProfileOutputFormat = 'plainText' | 'markdown'; + +type SectionDraft = IdentitySectionDraft | TitledSectionDraft; + +interface IdentitySectionDraft { + hasProfileName: boolean; + kind: 'identity'; + lines: string[]; +} + +interface TitledSectionDraft { + kind: 'titled'; + lines: string[]; + title: string; +} + +export function formatLinkedInProfile( + profile: LinkedInProfile, + options: FormatLinkedInProfileOptions = {} +): string { + const outputFormat = options.outputFormat ?? 'plainText'; + const sections = [ + createIdentitySection(profile), + options.includeContact ? createContactSection(profile.contact) : undefined, + createSingleValueSection('Summary', profile.summary), + createExperienceSection(profile.experience), + createEducationSection(profile.education), + createListSection('Top Skills', profile.top_skills), + createLanguageSection(profile.languages), + createListSection('Certifications', profile.certifications), + createListSection('Volunteer Work', profile.volunteer_work), + createListSection('Projects', profile.projects), + createListSection('Publications', profile.publications), + createListSection('Honors & Awards', profile.honors_awards), + ].filter((section): section is SectionDraft => section !== undefined); + + return sections + .map(section => + outputFormat === 'markdown' + ? formatMarkdownSection(section) + : formatPlainTextSection(section) + ) + .join('\n\n') + .trim(); +} + +function createIdentitySection( + profile: LinkedInProfile +): SectionDraft | undefined { + const name = cleanValue(profile.name); + const lines = cleanValues([name, profile.headline, profile.location]); + + if (lines.length === 0) { + return undefined; + } + + return { + hasProfileName: name !== undefined, + kind: 'identity', + lines, + }; +} + +function createContactSection(contact: Contact): SectionDraft | undefined { + const linkedinUrl = cleanValue(contact.linkedin_url); + const normalizedLinkedInUrl = + linkedinUrl === undefined + ? undefined + : normalizeContactUrlForDedupe(linkedinUrl); + const linkLines = + contact.links?.map(link => { + if (!link) { + return undefined; + } + + const label = cleanValue(link.label); + const url = cleanValue(link.url); + + // Treat an empty url or a url matching linkedinUrl as absent, dropping malformed rows and duplicate LinkedIn lines. + if ( + !url || + (normalizedLinkedInUrl !== undefined && + normalizeContactUrlForDedupe(url) === normalizedLinkedInUrl) + ) { + return undefined; + } + + return label ? `${label}: ${url}` : url; + }) ?? []; + const lines = cleanValues([ + contact.email ? `Email: ${contact.email}` : undefined, + contact.phone ? `Phone: ${contact.phone}` : undefined, + linkedinUrl ? `LinkedIn: ${linkedinUrl}` : undefined, + contact.location ? `Location: ${contact.location}` : undefined, + ...linkLines, + ]); + + if (lines.length === 0) { + return undefined; + } + + return { + kind: 'titled', + lines, + title: 'Contact', + }; +} + +function normalizeContactUrlForDedupe(url: string): string { + return url + .trim() + .toLowerCase() + .replace(/^(?:https?:\/\/)?(?:www\.)?/u, '') + .replace(/\/+$/u, ''); +} + +function createSingleValueSection( + title: string, + value: string | undefined +): SectionDraft | undefined { + const cleanedValue = cleanValue(value); + + if (!cleanedValue) { + return undefined; + } + + return { + kind: 'titled', + lines: [cleanedValue], + title, + }; +} + +function createExperienceSection( + experience: Experience[] +): SectionDraft | undefined { + const lines = separateEntryLines(experience.map(formatExperience)); + + if (lines.length === 0) { + return undefined; + } + + return { + kind: 'titled', + lines, + title: 'Experience', + }; +} + +function createEducationSection( + education: Education[] +): SectionDraft | undefined { + const lines = separateEntryLines(education.map(formatEducation)); + + if (lines.length === 0) { + return undefined; + } + + return { + kind: 'titled', + lines, + title: 'Education', + }; +} + +function createListSection( + title: string, + values: string[] +): SectionDraft | undefined { + const lines = cleanValues(values).map(value => `- ${value}`); + + if (lines.length === 0) { + return undefined; + } + + return { + kind: 'titled', + lines, + title, + }; +} + +function createLanguageSection( + languages: Language[] +): SectionDraft | undefined { + const lines = languages + .map(language => { + const languageName = cleanValue(language.language); + const proficiency = cleanValue(language.proficiency); + + if (!languageName) { + return undefined; + } + + return proficiency && proficiency !== 'Unknown' + ? `- ${languageName} (${proficiency})` + : `- ${languageName}`; + }) + .filter((line): line is string => line !== undefined); + + if (lines.length === 0) { + return undefined; + } + + return { + kind: 'titled', + lines, + title: 'Languages', + }; +} + +function formatExperience(experience: Experience): string[] { + const title = cleanValue(experience.title); + const company = cleanValue(experience.company); + const headline = + title && company + ? `${title} at ${company}` + : (title ?? company ?? undefined); + const detailLines = cleanValues([ + experience.duration, + experience.location, + experience.description, + ]); + + return cleanValues([headline, ...detailLines]); +} + +function formatEducation(education: Education): string[] { + const degree = cleanValue(education.degree); + const institution = cleanValue(education.institution); + const headline = + degree && institution + ? `${degree}, ${institution}` + : (degree ?? institution ?? undefined); + const detailLines = cleanValues([ + education.year, + education.location, + education.description, + ]); + + return cleanValues([headline, ...detailLines]); +} + +function formatPlainTextSection(section: SectionDraft): string { + return section.kind === 'titled' + ? [section.title, ...section.lines].join('\n') + : section.lines.join('\n'); +} + +function formatMarkdownSection(section: SectionDraft): string { + if (section.kind === 'titled') { + return [`## ${section.title}`, ...section.lines].join('\n'); + } + + if (!section.hasProfileName) { + return section.lines.join('\n'); + } + + const [name, ...details] = section.lines; + + return [`# ${name}`, ...details].join('\n'); +} + +function cleanValues(values: Array): string[] { + return values + .map(value => cleanValue(value)) + .filter((value): value is string => value !== undefined); +} + +function separateEntryLines(entries: string[][]): string[] { + return entries + .filter(entryLines => entryLines.length > 0) + .flatMap((entryLines, index) => + index > 0 ? ['', ...entryLines] : entryLines + ); +} + +function cleanValue(value: string | undefined): string | undefined { + const cleanedValue = value + ?.replace(/[\uE000-\uF8FF]/g, ' ') + .replace(/\u00a0/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + return cleanedValue && cleanedValue.length > 0 ? cleanedValue : undefined; +} diff --git a/src/index.ts b/src/index.ts index 9edc65b..99437c6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,110 +1,249 @@ -import { StructuralParser } from './parsers/structural-parser.js'; import { ExperienceStructuralParser } from './parsers/experience-structural.js'; import { BasicInfoParser } from './parsers/basic-info.js'; import { ListParser } from './parsers/lists.js'; import { EducationParser } from './parsers/education.js'; +import { ExtraSectionParser } from './parsers/extra-sections.js'; +import { IdentityStructuralParser } from './parsers/identity-structural.js'; +import { extractLinkedInPDFSourceDebug } from './pdf-source-debug.js'; +import { createParseDiagnostics } from './diagnostics.js'; +import { + LinkedInProfileParseError, + createLinkedInProfileParseError, + normalizeLinkedInProfileParseError, +} from './errors.js'; +import { formatLinkedInProfile } from './formatter.js'; +import { ParseResultSchema } from './schemas.js'; import { cleanPDFText } from './utils/text-utils.js'; +import type { LayoutInfo, TextItem } from './types/structural.js'; +import type { + Contact, + Experience, + ExperienceGroup, + LinkedInProfile, + ParseOptions, + ParseResult, + ParseWarning, + SectionParseWarning, +} from './types/profile.js'; +import type { StructuralLine } from './utils/structural-lines.js'; -export interface Contact { - email: string; - phone?: string; - linkedin_url?: string; - location?: string; -} +export type { + Contact, + ContactLink, + Education, + Experience, + ExperienceGroup, + ExperienceGroupPosition, + Language, + LinkedInProfile, + MissingProfileFieldWarning, + ParseOptions, + ParseResult, + ParseWarning, + ParsedDateRange, + ParsedProfileDate, + ParsedProfileDatePrecision, + ParseDiagnostics, + SectionParseWarning, + WarningSection, +} from './types/profile.js'; +export type { + FormatLinkedInProfileOptions, + LinkedInProfileOutputFormat, +} from './formatter.js'; +export type { LinkedInProfileParseErrorCode } from './errors.js'; +export { + LinkedInProfileParseError, + createLinkedInProfileParseError, + formatLinkedInProfile, +}; +export type { LinkedInPDFSourceDebugArtifacts } from './pdf-source-debug.js'; +export { + ContactSchema, + ContactLinkSchema, + EducationSchema, + ExperienceSchema, + ExperienceGroupPositionSchema, + ExperienceGroupSchema, + LanguageSchema, + LinkedInProfileSchema, + ParseDiagnosticsSchema, + ParseResultSchema, + ParseWarningSchema, + ParsedDateRangeSchema, + ParsedProfileDateSchema, + WarningSectionSchema, +} from './schemas.js'; +export { extractLinkedInPDFSourceDebug } from './pdf-source-debug.js'; -export interface Language { - language: string; - proficiency: string; -} +export type SafeParseLinkedInPDFResult = + | { + data: ParseResult; + success: true; + } + | { + error: LinkedInProfileParseError; + success: false; + }; -export interface Experience { - title: string; - company: string; - duration: string; - location?: string; - description?: string; +/** + * Parses a LinkedIn PDF resume and extracts structured profile data + * @param input - PDF binary data or extracted text string + * @param options - Optional parsing configuration + * @returns Promise resolving to structured LinkedIn profile data + */ +export async function parseLinkedInPDF( + input: ArrayBuffer | Uint8Array | string, + options: ParseOptions = {} +): Promise { + try { + return await parseLinkedInPDFInternal(input, options); + } catch (cause) { + throw normalizeLinkedInProfileParseError({ + cause, + inputKind: typeof input === 'string' ? 'text' : 'pdf', + }); + } } -export interface Education { - degree: string; - institution: string; - year?: string; - location?: string; - description?: string; -} +export async function parseLinkedInPDFStrict( + input: ArrayBuffer | Uint8Array | string, + options: ParseOptions = {} +): Promise { + const result = await parseLinkedInPDF(input, options); + const parsedResult = ParseResultSchema.safeParse(result); -export interface LinkedInProfile { - name: string; - headline: string; - location: string; - contact: Contact; - top_skills: string[]; - languages: Language[]; - summary?: string; - experience: Experience[]; - education: Education[]; -} + if (!parsedResult.success) { + throw createLinkedInProfileParseError({ + cause: parsedResult.error, + code: 'schema_validation_failed', + }); + } -export interface ParseOptions { - includeRawText?: boolean; + return parsedResult.data; } -export interface ParseResult { - profile: LinkedInProfile; - rawText?: string; +export async function safeParseLinkedInPDF( + input: ArrayBuffer | Uint8Array | string, + options: ParseOptions = {} +): Promise { + try { + return { + data: await parseLinkedInPDFStrict(input, options), + success: true, + }; + } catch (cause) { + return { + error: normalizeLinkedInProfileParseError({ + cause, + inputKind: typeof input === 'string' ? 'text' : 'pdf', + }), + success: false, + }; + } } -/** - * Parses a LinkedIn PDF resume and extracts structured profile data - * @param input - PDF Buffer or extracted text string - * @param options - Optional parsing configuration - * @returns Promise resolving to structured LinkedIn profile data - */ -export async function parseLinkedInPDF( - input: Buffer | string, - options: ParseOptions = {} +async function parseLinkedInPDFInternal( + input: ArrayBuffer | Uint8Array | string, + options: ParseOptions ): Promise { let text: string; - let structuralData: { textItems: any[]; layout: any } | null = null; + let structuralData: { + layout: LayoutInfo; + structuralLines: StructuralLine[]; + textItems: TextItem[]; + } | null = null; - // Handle both Buffer and string inputs - if (Buffer.isBuffer(input)) { + // Handle both binary PDF data and extracted text inputs + if (typeof input !== 'string') { try { - // Use new structural parser for PDF buffers - structuralData = await StructuralParser.extractStructuredText(input); - - // Create fallback text from structural data - const groups = StructuralParser.groupTextByProximity(structuralData.textItems); - const lines = StructuralParser.combineGroupedText(groups); - text = lines.join('\n'); + const debugArtifacts = await extractLinkedInPDFSourceDebug(input); - } catch (error) { - throw new Error('PDF appears to be empty or unreadable'); + structuralData = { + layout: debugArtifacts.layout, + structuralLines: debugArtifacts.structuralLines, + textItems: debugArtifacts.textItems, + }; + text = debugArtifacts.rawText; + } catch (cause) { + throw normalizeLinkedInProfileParseError({ + cause, + inputKind: 'pdf', + }); } } else { text = input; } if (!text || text.length < 50) { - throw new Error('PDF appears to be empty or unreadable'); + throw createLinkedInProfileParseError({ + code: 'text_extraction_failed', + message: + typeof input === 'string' + ? 'Input text is empty or too short' + : undefined, + }); } // Clean and parse the text const cleanedText = cleanPDFText(text); + const sectionWarnings: SectionParseWarning[] = []; + const structuralLines = structuralData?.structuralLines; // Parse all sections using specialized parsers - const basicInfo = BasicInfoParser.parse(cleanedText); - const topSkills = ListParser.parseSkills(cleanedText); - const languages = ListParser.parseLanguages(cleanedText); + const basicInfoResult = structuralLines + ? BasicInfoParser.parseStructuralWithWarnings(cleanedText, structuralLines) + : BasicInfoParser.parseWithWarnings(cleanedText); + const basicInfo = basicInfoResult.value; + sectionWarnings.push(...basicInfoResult.warnings); + + const structuralIdentityResult = structuralLines + ? IdentityStructuralParser.parseWithWarnings(structuralLines) + : undefined; + const structuralIdentity = structuralIdentityResult?.value; + + if (structuralIdentityResult) { + sectionWarnings.push(...structuralIdentityResult.warnings); + } + + const topSkillsResult = structuralIdentity?.topSkills.length + ? undefined + : ListParser.parseSkillsWithWarnings(cleanedText); + const topSkills = structuralIdentity?.topSkills.length + ? structuralIdentity.topSkills + : (topSkillsResult?.value ?? []); + + if (topSkillsResult) { + sectionWarnings.push(...topSkillsResult.warnings); + } + + const languagesResult = structuralLines + ? ListParser.parseStructuralLanguagesWithWarnings(structuralLines) + : ListParser.parseLanguagesWithWarnings(cleanedText); + const languages = languagesResult.value; + sectionWarnings.push(...languagesResult.warnings); + + const extraSectionsResult = structuralLines + ? ExtraSectionParser.parseStructuralWithWarnings(structuralLines) + : ExtraSectionParser.parseTextWithWarnings(cleanedText); + const extraSections = extraSectionsResult.value; + sectionWarnings.push(...extraSectionsResult.warnings); // Use structural parser for experience if available, otherwise fallback let experience: Experience[]; + let experienceGroups: ExperienceGroup[]; if (structuralData) { - const workExperiences = ExperienceStructuralParser.parseExperience(structuralData.textItems); + const workExperienceResult = + ExperienceStructuralParser.parseExperienceWithWarnings( + structuralData.textItems + ); + const workExperiences = workExperienceResult.value; + sectionWarnings.push(...workExperienceResult.warnings); // Convert WorkExperience[] to Experience[] for compatibility experience = workExperiences.flatMap(workExp => workExp.positions.map(position => ({ + ...(position.dates ? { dates: position.dates } : {}), title: position.title, company: workExp.organization, duration: position.duration, @@ -112,35 +251,96 @@ export async function parseLinkedInPDF( description: position.description, })) ); + + experienceGroups = workExperiences.map(workExp => ({ + company: workExp.organization, + positions: workExp.positions.map(position => ({ + ...(position.dates ? { dates: position.dates } : {}), + title: position.title, + duration: position.duration, + location: position.location, + description: position.description, + })), + ...(workExp.totalDuration + ? { totalDuration: workExp.totalDuration } + : {}), + })); } else { // Fallback to old parser for string inputs const { ExperienceParser } = await import('./parsers/experience.js'); - experience = ExperienceParser.parse(cleanedText); + const experienceResult = ExperienceParser.parseWithWarnings(cleanedText); + experience = experienceResult.value; + sectionWarnings.push(...experienceResult.warnings); + experienceGroups = groupFlatExperiences(experience); + } + + const structuralEducationResult = structuralLines + ? EducationParser.parseStructuralWithWarnings(structuralLines) + : undefined; + const fallbackEducationResult = + !structuralEducationResult || structuralEducationResult.value.length === 0 + ? EducationParser.parseWithWarnings(cleanedText) + : undefined; + const education = + structuralEducationResult && structuralEducationResult.value.length > 0 + ? structuralEducationResult.value + : (fallbackEducationResult?.value ?? []); + + if (structuralEducationResult) { + sectionWarnings.push(...structuralEducationResult.warnings); + } + + if (fallbackEducationResult) { + sectionWarnings.push(...fallbackEducationResult.warnings); } - const education = EducationParser.parse(cleanedText); + const contact: Contact = { + ...basicInfo.contact, + }; + + if (structuralIdentity?.linkedinUrl) { + contact.linkedin_url = structuralIdentity.linkedinUrl; + } + + if ( + contact.phone && + contact.linkedin_url?.includes(contact.phone.replace(/\D/g, '')) + ) { + delete contact.phone; + } // Combine into final profile const profile: LinkedInProfile = { - name: basicInfo.name, - headline: basicInfo.headline, - location: basicInfo.location, - contact: basicInfo.contact, + name: structuralIdentity?.name ?? basicInfo.name, + headline: structuralIdentity?.headline ?? basicInfo.headline, + location: structuralIdentity?.location ?? basicInfo.location, + contact, top_skills: topSkills, languages, + certifications: extraSections.certifications, + volunteer_work: extraSections.volunteer_work, + projects: extraSections.projects, + publications: extraSections.publications, + honors_awards: extraSections.honors_awards, summary: basicInfo.summary, + experience_groups: experienceGroups, experience, education, }; - // Basic validation - if (!profile.name || !profile.contact.email) { - throw new Error( - 'Could not extract basic profile information (name or email missing)' - ); - } - - const result: ParseResult = { profile }; + const warnings = [ + ...createParseWarnings(profile), + ...filterResolvedSectionWarnings(sectionWarnings, contact), + ]; + const result: ParseResult = { + profile, + warnings, + diagnostics: createParseDiagnostics({ + profile, + text: cleanedText, + warnings, + }), + }; if (options.includeRawText) { result.rawText = text; @@ -149,4 +349,68 @@ export async function parseLinkedInPDF( return result; } -// All types are already exported above +function groupFlatExperiences(experience: Experience[]): ExperienceGroup[] { + const groups: ExperienceGroup[] = []; + + for (const entry of experience) { + const currentGroup = groups.at(-1); + const position = { + ...(entry.dates ? { dates: entry.dates } : {}), + title: entry.title, + duration: entry.duration, + location: entry.location, + description: entry.description, + }; + + if (currentGroup?.company === entry.company) { + currentGroup.positions.push(position); + continue; + } + + groups.push({ + company: entry.company, + positions: [position], + }); + } + + return groups; +} + +function filterResolvedSectionWarnings( + warnings: SectionParseWarning[], + contact: Contact +): SectionParseWarning[] { + return warnings.filter(warning => { + if ( + warning.section === 'contact' && + warning.field === 'contact' && + (contact.email || contact.phone || contact.linkedin_url) + ) { + return false; + } + + return true; + }); +} + +function createParseWarnings(profile: LinkedInProfile): ParseWarning[] { + const warnings: ParseWarning[] = []; + + if (!profile.name) { + warnings.push({ + code: 'missing_profile_field', + field: 'profile.name', + message: 'Could not extract profile name', + }); + } + + if (!profile.contact.email) { + warnings.push({ + code: 'missing_profile_field', + field: 'profile.contact.email', + message: 'Could not extract contact email', + }); + } + + return warnings; +} diff --git a/src/json-fixtures.ts b/src/json-fixtures.ts new file mode 100644 index 0000000..893c507 --- /dev/null +++ b/src/json-fixtures.ts @@ -0,0 +1,1009 @@ +import * as path from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; +import type { ParseOptions, ParseResult } from './index.js'; + +export type JsonOutputFormat = 'pretty' | 'compact'; +export type JsonDiffOutputFormat = 'context' | 'json-paths'; +type JsonFixtureExitCode = 0 | 1; +const JSON_DIFF_CONTEXT_LINE_COUNT = 3; +const JSON_DIFF_LCS_MAX_CELLS = 250_000; + +export interface JsonFixtureDirectoryEntry { + kind: 'directory' | 'file' | 'other'; + name: string; +} + +export interface JsonFixtureDependencies { + directoryExists: (directoryPath: string) => boolean; + fileExists: (filePath: string) => boolean; + listDirectory: (directoryPath: string) => JsonFixtureDirectoryEntry[]; + parsePdf: (input: Uint8Array, options: ParseOptions) => Promise; + readFile: (filePath: string) => Uint8Array; + readTextFile: (filePath: string) => string; + resolvePath: (filePath: string) => string; + writeTextFile: (filePath: string, content: string) => void; +} + +export interface JsonFixtureResult { + exitCode: JsonFixtureExitCode; + stderr: string; + stdout: string; +} + +export interface WriteJsonFixturesParams { + dependencies: JsonFixtureDependencies; + folderPath: string; + includeRawText: boolean; + outputFormat: JsonOutputFormat; + overwriteExisting: boolean; +} + +export interface VerifyJsonFixturesParams { + dependencies: JsonFixtureDependencies; + diffOutputFormat?: JsonDiffOutputFormat; + folderPath: string; + includeRawText: boolean; +} + +interface BatchFailure { + details?: string; + filePath: string; + message: string; +} + +interface MatchedPair { + jsonPath: string; + pdfPath: string; +} + +interface MatchedPairs { + missingJsonFailures: BatchFailure[]; + missingPdfFailures: BatchFailure[]; + pairs: MatchedPair[]; +} + +interface ParsePdfFileParams { + dependencies: JsonFixtureDependencies; + includeRawText: boolean; + pdfPath: string; +} + +interface ResolvedDirectory { + kind: 'valid'; + path: string; +} + +interface InvalidDirectory { + kind: 'invalid'; + result: JsonFixtureResult; +} + +interface ResolvedFolderFiles { + folderPath: string; + jsonEntries: JsonFixtureDirectoryEntry[]; + kind: 'valid'; + pdfEntries: JsonFixtureDirectoryEntry[]; +} + +interface ContextDiffEntry { + generatedLineNumber?: number; + kind: 'context' | 'generated' | 'expected'; + line: string; + expectedLineNumber?: number; +} + +interface ContextDiffHunk { + endIndex: number; + startIndex: number; +} + +interface AddedJsonValueChange { + kind: 'added'; + path: JsonPathSegment[]; + value: unknown; +} + +interface ChangedJsonValueChange { + generatedValue: unknown; + expectedValue: unknown; + kind: 'changed'; + path: JsonPathSegment[]; +} + +interface RemovedJsonValueChange { + kind: 'removed'; + path: JsonPathSegment[]; + value: unknown; +} + +type JsonValueChange = + | AddedJsonValueChange + | ChangedJsonValueChange + | RemovedJsonValueChange; + +type JsonPresenceChange = AddedJsonValueChange | RemovedJsonValueChange; +type JsonPresenceChangeKind = JsonPresenceChange['kind']; + +type JsonPathSegment = + | { + index: number; + kind: 'array-index'; + } + | { + key: string; + kind: 'object-key'; + }; + +interface JsonRecord { + [key: string]: unknown; +} + +export async function writeJsonFixtures({ + dependencies, + folderPath, + includeRawText, + outputFormat, + overwriteExisting, +}: WriteJsonFixturesParams): Promise { + const folderFiles = resolveFolderFiles(folderPath, dependencies); + + if (folderFiles.kind === 'invalid') { + return folderFiles.result; + } + + const failures: BatchFailure[] = []; + const writtenFiles: string[] = []; + + for (const pdfEntry of folderFiles.pdfEntries) { + const pdfPath = path.join(folderFiles.folderPath, pdfEntry.name); + const existingJsonEntry = findMatchingStemEntry( + pdfEntry.name, + folderFiles.jsonEntries + ); + const outputJsonName = + existingJsonEntry?.name ?? replaceExtension(pdfEntry.name, '.json'); + const outputJsonPath = path.join(folderFiles.folderPath, outputJsonName); + + if (existingJsonEntry && !overwriteExisting) { + failures.push({ + filePath: pdfPath, + message: `JSON already exists: ${outputJsonPath}`, + }); + continue; + } + + try { + const result = await parsePdfFile({ + dependencies, + includeRawText, + pdfPath, + }); + + dependencies.writeTextFile( + outputJsonPath, + `${formatJson(result, outputFormat)}\n` + ); + writtenFiles.push(outputJsonPath); + } catch (error) { + failures.push({ + filePath: pdfPath, + message: formatErrorMessage(error), + }); + } + } + + return { + exitCode: failures.length === 0 ? 0 : 1, + stderr: formatBatchFailures('Failed to write JSON for files', failures), + stdout: formatWrittenFiles(folderFiles.folderPath, writtenFiles), + }; +} + +export async function verifyJsonFixtures({ + dependencies, + diffOutputFormat = 'context', + folderPath, + includeRawText, +}: VerifyJsonFixturesParams): Promise { + const folderFiles = resolveFolderFiles(folderPath, dependencies); + + if (folderFiles.kind === 'invalid') { + return folderFiles.result; + } + + const matchedPairs = createMatchedPairs( + folderFiles.folderPath, + folderFiles.pdfEntries, + folderFiles.jsonEntries + ); + const failures = [ + ...matchedPairs.missingJsonFailures, + ...matchedPairs.missingPdfFailures, + ]; + const passedFiles: string[] = []; + + if (matchedPairs.pairs.length === 0 && failures.length === 0) { + return { + exitCode: 1, + stderr: `Error: No matching PDF/JSON pairs found in ${folderFiles.folderPath}\n`, + stdout: '', + }; + } + + for (const pair of matchedPairs.pairs) { + let expectedJson: unknown; + + try { + expectedJson = normalizeJsonValue( + JSON.parse(dependencies.readTextFile(pair.jsonPath)) + ); + } catch (error) { + failures.push({ + filePath: pair.jsonPath, + message: `Invalid JSON baseline: ${formatErrorMessage(error)}`, + }); + continue; + } + + try { + const generatedJson = normalizeJsonValue( + await parsePdfFile({ + dependencies, + includeRawText, + pdfPath: pair.pdfPath, + }) + ); + + if (isDeepStrictEqual(expectedJson, generatedJson)) { + passedFiles.push(pair.pdfPath); + continue; + } + + failures.push({ + details: formatJsonDiff(expectedJson, generatedJson, diffOutputFormat), + filePath: pair.pdfPath, + message: `Generated JSON differs from ${pair.jsonPath}`, + }); + } catch (error) { + failures.push({ + filePath: pair.pdfPath, + message: formatErrorMessage(error), + }); + } + } + + return { + exitCode: failures.length === 0 ? 0 : 1, + stderr: formatBatchFailures('Verification failed for files', failures), + stdout: formatVerifiedFiles(folderFiles.folderPath, passedFiles), + }; +} + +export function formatJson( + result: ParseResult, + outputFormat: JsonOutputFormat +): string { + return outputFormat === 'pretty' + ? JSON.stringify(result, null, 2) + : JSON.stringify(result); +} + +export function hasFileExtension(filePath: string, extension: string): boolean { + return filePath.toLowerCase().endsWith(extension); +} + +export function formatErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function parsePdfFile({ + dependencies, + includeRawText, + pdfPath, +}: ParsePdfFileParams): Promise { + return dependencies.parsePdf(dependencies.readFile(pdfPath), { + includeRawText, + }); +} + +function resolveDirectory( + folderPath: string, + dependencies: JsonFixtureDependencies +): InvalidDirectory | ResolvedDirectory { + const resolvedPath = dependencies.resolvePath(folderPath); + + if (dependencies.directoryExists(resolvedPath)) { + return { + kind: 'valid', + path: resolvedPath, + }; + } + + if (dependencies.fileExists(resolvedPath)) { + return { + kind: 'invalid', + result: { + exitCode: 1, + stderr: `Error: Path must be a directory: ${resolvedPath}\n`, + stdout: '', + }, + }; + } + + return { + kind: 'invalid', + result: { + exitCode: 1, + stderr: `Error: Directory not found: ${resolvedPath}\n`, + stdout: '', + }, + }; +} + +function resolveFolderFiles( + folderPath: string, + dependencies: JsonFixtureDependencies +): InvalidDirectory | ResolvedFolderFiles { + const folder = resolveDirectory(folderPath, dependencies); + + if (folder.kind === 'invalid') { + return folder; + } + + const entries = dependencies.listDirectory(folder.path); + + return { + folderPath: folder.path, + jsonEntries: listFilesByExtension(entries, '.json'), + kind: 'valid', + pdfEntries: listFilesByExtension(entries, '.pdf'), + }; +} + +function listFilesByExtension( + entries: JsonFixtureDirectoryEntry[], + extension: string +): JsonFixtureDirectoryEntry[] { + return entries + .filter( + entry => entry.kind === 'file' && hasFileExtension(entry.name, extension) + ) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +function createMatchedPairs( + folderPath: string, + pdfEntries: JsonFixtureDirectoryEntry[], + jsonEntries: JsonFixtureDirectoryEntry[] +): MatchedPairs { + const pairs: MatchedPair[] = []; + const missingJsonFailures: BatchFailure[] = []; + const matchedJsonNames = new Set(); + + for (const pdfEntry of pdfEntries) { + const jsonEntry = findMatchingStemEntry(pdfEntry.name, jsonEntries); + const pdfPath = path.join(folderPath, pdfEntry.name); + + if (!jsonEntry) { + missingJsonFailures.push({ + filePath: pdfPath, + message: `Missing JSON baseline: ${path.join( + folderPath, + replaceExtension(pdfEntry.name, '.json') + )}`, + }); + continue; + } + + matchedJsonNames.add(jsonEntry.name); + pairs.push({ + jsonPath: path.join(folderPath, jsonEntry.name), + pdfPath, + }); + } + + const missingPdfFailures = jsonEntries + .filter(jsonEntry => !matchedJsonNames.has(jsonEntry.name)) + .map(jsonEntry => ({ + filePath: path.join(folderPath, jsonEntry.name), + message: `Missing PDF source: ${path.join( + folderPath, + replaceExtension(jsonEntry.name, '.pdf') + )}`, + })); + + return { + missingJsonFailures, + missingPdfFailures, + pairs, + }; +} + +function findMatchingStemEntry( + fileName: string, + entries: JsonFixtureDirectoryEntry[] +): JsonFixtureDirectoryEntry | undefined { + const stem = getFileStem(fileName).toLowerCase(); + + return entries.find(entry => getFileStem(entry.name).toLowerCase() === stem); +} + +function formatWrittenFiles( + folderPath: string, + writtenFiles: string[] +): string { + const lines = [ + `Wrote ${writtenFiles.length} JSON file(s) in ${folderPath}.`, + ...writtenFiles.map(filePath => `- ${filePath}`), + ]; + + return `${lines.join('\n')}\n`; +} + +function formatVerifiedFiles( + folderPath: string, + passedFiles: string[] +): string { + const lines = [ + `Verified ${passedFiles.length} PDF/JSON pair(s) in ${folderPath}.`, + ...passedFiles.map(filePath => `- ${filePath}`), + ]; + + return `${lines.join('\n')}\n`; +} + +function formatBatchFailures(header: string, failures: BatchFailure[]): string { + if (failures.length === 0) { + return ''; + } + + return `${[ + `${header}:`, + ...failures.flatMap(failure => [ + `- ${failure.filePath}: ${failure.message}`, + ...(failure.details ? [failure.details] : []), + ]), + ].join('\n')}\n`; +} + +function formatJsonDiff( + expectedJson: unknown, + generatedJson: unknown, + diffOutputFormat: JsonDiffOutputFormat +): string { + return diffOutputFormat === 'json-paths' + ? formatJsonPathDiff(expectedJson, generatedJson) + : formatContextJsonDiff(expectedJson, generatedJson); +} + +// Compare canonical JSON text so CLI mismatches stay stable and dependency-light. +function formatContextJsonDiff( + expectedJson: unknown, + generatedJson: unknown +): string { + const expectedLines = formatUnknownJson(expectedJson).split('\n'); + const generatedLines = formatUnknownJson(generatedJson).split('\n'); + const diffEntries = createContextDiffEntries(expectedLines, generatedLines); + const hunks = createContextDiffHunks(diffEntries); + const diffLines = ['--- expected', '+++ generated']; + + for (const hunk of hunks) { + const hunkEntries = diffEntries.slice(hunk.startIndex, hunk.endIndex + 1); + diffLines.push(formatContextDiffHunkHeader(hunkEntries)); + + for (const entry of hunkEntries) { + diffLines.push(formatContextDiffEntry(entry)); + } + } + + return diffLines.join('\n'); +} + +function createContextDiffEntries( + expectedLines: string[], + generatedLines: string[] +): ContextDiffEntry[] { + if (!canBuildLongestCommonSubsequenceTable(expectedLines, generatedLines)) { + return createLinearContextDiffEntries(expectedLines, generatedLines); + } + + const lcsTable = createLongestCommonSubsequenceTable( + expectedLines, + generatedLines + ); + const entries: ContextDiffEntry[] = []; + let expectedIndex = 0; + let generatedIndex = 0; + + while ( + expectedIndex < expectedLines.length && + generatedIndex < generatedLines.length + ) { + const expectedLine = expectedLines[expectedIndex]; + const generatedLine = generatedLines[generatedIndex]; + + if (expectedLine === generatedLine) { + entries.push({ + generatedLineNumber: generatedIndex + 1, + kind: 'context', + line: expectedLine, + expectedLineNumber: expectedIndex + 1, + }); + expectedIndex += 1; + generatedIndex += 1; + continue; + } + + if ( + lcsTable[expectedIndex + 1][generatedIndex] >= + lcsTable[expectedIndex][generatedIndex + 1] + ) { + entries.push({ + kind: 'expected', + line: expectedLine, + expectedLineNumber: expectedIndex + 1, + }); + expectedIndex += 1; + continue; + } + + entries.push({ + generatedLineNumber: generatedIndex + 1, + kind: 'generated', + line: generatedLine, + }); + generatedIndex += 1; + } + + while (expectedIndex < expectedLines.length) { + entries.push({ + kind: 'expected', + line: expectedLines[expectedIndex], + expectedLineNumber: expectedIndex + 1, + }); + expectedIndex += 1; + } + + while (generatedIndex < generatedLines.length) { + entries.push({ + generatedLineNumber: generatedIndex + 1, + kind: 'generated', + line: generatedLines[generatedIndex], + }); + generatedIndex += 1; + } + + return entries; +} + +function canBuildLongestCommonSubsequenceTable( + expectedLines: string[], + generatedLines: string[] +): boolean { + return ( + (expectedLines.length + 1) * (generatedLines.length + 1) <= + JSON_DIFF_LCS_MAX_CELLS + ); +} + +function createLinearContextDiffEntries( + expectedLines: string[], + generatedLines: string[] +): ContextDiffEntry[] { + const entries: ContextDiffEntry[] = []; + const sharedLength = Math.min(expectedLines.length, generatedLines.length); + + for (let index = 0; index < sharedLength; index += 1) { + const expectedLine = expectedLines[index]; + const generatedLine = generatedLines[index]; + + if (expectedLine === generatedLine) { + entries.push({ + generatedLineNumber: index + 1, + kind: 'context', + line: expectedLine, + expectedLineNumber: index + 1, + }); + continue; + } + + entries.push({ + kind: 'expected', + line: expectedLine, + expectedLineNumber: index + 1, + }); + entries.push({ + generatedLineNumber: index + 1, + kind: 'generated', + line: generatedLine, + }); + } + + for (let index = sharedLength; index < expectedLines.length; index += 1) { + entries.push({ + kind: 'expected', + line: expectedLines[index], + expectedLineNumber: index + 1, + }); + } + + for (let index = sharedLength; index < generatedLines.length; index += 1) { + entries.push({ + generatedLineNumber: index + 1, + kind: 'generated', + line: generatedLines[index], + }); + } + + return entries; +} + +function createLongestCommonSubsequenceTable( + expectedLines: string[], + generatedLines: string[] +): number[][] { + const table = Array.from({ length: expectedLines.length + 1 }, () => + Array(generatedLines.length + 1).fill(0) + ); + + for ( + let expectedIndex = expectedLines.length - 1; + expectedIndex >= 0; + expectedIndex -= 1 + ) { + for ( + let generatedIndex = generatedLines.length - 1; + generatedIndex >= 0; + generatedIndex -= 1 + ) { + table[expectedIndex][generatedIndex] = + expectedLines[expectedIndex] === generatedLines[generatedIndex] + ? table[expectedIndex + 1][generatedIndex + 1] + 1 + : Math.max( + table[expectedIndex + 1][generatedIndex], + table[expectedIndex][generatedIndex + 1] + ); + } + } + + return table; +} + +function createContextDiffHunks( + diffEntries: ContextDiffEntry[] +): ContextDiffHunk[] { + const changeIndexes = diffEntries + .map((entry, index) => (entry.kind === 'context' ? -1 : index)) + .filter(index => index !== -1); + const hunks: ContextDiffHunk[] = []; + + for (const changeIndex of changeIndexes) { + const startIndex = Math.max(changeIndex - JSON_DIFF_CONTEXT_LINE_COUNT, 0); + const endIndex = Math.min( + changeIndex + JSON_DIFF_CONTEXT_LINE_COUNT, + diffEntries.length - 1 + ); + const previousHunk = hunks[hunks.length - 1]; + + if (previousHunk && startIndex <= previousHunk.endIndex + 1) { + previousHunk.endIndex = Math.max(previousHunk.endIndex, endIndex); + continue; + } + + hunks.push({ + endIndex, + startIndex, + }); + } + + return hunks; +} + +function formatContextDiffHunkHeader(entries: ContextDiffEntry[]): string { + const expectedLineNumbers = entries.flatMap(entry => + entry.expectedLineNumber === undefined ? [] : [entry.expectedLineNumber] + ); + const generatedLineNumbers = entries.flatMap(entry => + entry.generatedLineNumber === undefined ? [] : [entry.generatedLineNumber] + ); + const expectedStartLine = expectedLineNumbers[0] ?? 0; + const generatedStartLine = generatedLineNumbers[0] ?? 0; + + return `@@ -${formatContextDiffRange(expectedStartLine, expectedLineNumbers.length)} +${formatContextDiffRange(generatedStartLine, generatedLineNumbers.length)} @@`; +} + +function formatContextDiffRange(startLine: number, lineCount: number): string { + return lineCount === 1 ? String(startLine) : `${startLine},${lineCount}`; +} + +function formatContextDiffEntry(entry: ContextDiffEntry): string { + if (entry.kind === 'expected') { + return `-${entry.line}`; + } + + if (entry.kind === 'generated') { + return `+${entry.line}`; + } + + return ` ${entry.line}`; +} + +function formatJsonPathDiff( + expectedJson: unknown, + generatedJson: unknown +): string { + const changes = collectJsonValueChanges(expectedJson, generatedJson, []); + + return changes.map(formatJsonValueChange).join('\n'); +} + +function collectJsonValueChanges( + expectedValue: unknown, + generatedValue: unknown, + pathSegments: JsonPathSegment[] +): JsonValueChange[] { + if (isDeepStrictEqual(expectedValue, generatedValue)) { + return []; + } + + if (isUnknownArray(expectedValue) && isUnknownArray(generatedValue)) { + return collectArrayValueChanges( + expectedValue, + generatedValue, + pathSegments + ); + } + + if (isJsonRecord(expectedValue) && isJsonRecord(generatedValue)) { + return collectRecordValueChanges( + expectedValue, + generatedValue, + pathSegments + ); + } + + return [ + { + expectedValue, + generatedValue, + kind: 'changed', + path: pathSegments, + }, + ]; +} + +function collectArrayValueChanges( + expectedValues: ReadonlyArray, + generatedValues: ReadonlyArray, + pathSegments: JsonPathSegment[] +): JsonValueChange[] { + const changes: JsonValueChange[] = []; + const sharedLength = Math.min(expectedValues.length, generatedValues.length); + + for (let index = 0; index < sharedLength; index += 1) { + changes.push( + ...collectJsonValueChanges( + expectedValues[index], + generatedValues[index], + appendArrayIndexPathSegment(pathSegments, index) + ) + ); + } + + for (let index = sharedLength; index < expectedValues.length; index += 1) { + changes.push( + ...collectRemovedJsonValueChanges( + expectedValues[index], + appendArrayIndexPathSegment(pathSegments, index) + ) + ); + } + + for (let index = sharedLength; index < generatedValues.length; index += 1) { + changes.push( + ...collectAddedJsonValueChanges( + generatedValues[index], + appendArrayIndexPathSegment(pathSegments, index) + ) + ); + } + + return changes; +} + +function collectRecordValueChanges( + expectedRecord: JsonRecord, + generatedRecord: JsonRecord, + pathSegments: JsonPathSegment[] +): JsonValueChange[] { + const changes: JsonValueChange[] = []; + const expectedKeys = Object.keys(expectedRecord); + const generatedKeys = Object.keys(generatedRecord); + const orderedKeys = [ + ...expectedKeys, + ...generatedKeys.filter(key => !Object.hasOwn(expectedRecord, key)), + ]; + + for (const key of orderedKeys) { + const childPathSegments = appendObjectKeyPathSegment(pathSegments, key); + + if (!Object.hasOwn(generatedRecord, key)) { + changes.push( + ...collectRemovedJsonValueChanges( + expectedRecord[key], + childPathSegments + ) + ); + continue; + } + + if (!Object.hasOwn(expectedRecord, key)) { + changes.push( + ...collectAddedJsonValueChanges(generatedRecord[key], childPathSegments) + ); + continue; + } + + changes.push( + ...collectJsonValueChanges( + expectedRecord[key], + generatedRecord[key], + childPathSegments + ) + ); + } + + return changes; +} + +function collectAddedJsonValueChanges( + value: unknown, + pathSegments: JsonPathSegment[] +): JsonValueChange[] { + return collectJsonPresenceChanges(value, pathSegments, 'added'); +} + +function collectRemovedJsonValueChanges( + value: unknown, + pathSegments: JsonPathSegment[] +): JsonValueChange[] { + return collectJsonPresenceChanges(value, pathSegments, 'removed'); +} + +function collectJsonPresenceChanges( + value: unknown, + pathSegments: JsonPathSegment[], + kind: JsonPresenceChangeKind +): JsonPresenceChange[] { + if (isUnknownArray(value) && value.length > 0) { + return value.flatMap((childValue, index) => + collectJsonPresenceChanges( + childValue, + appendArrayIndexPathSegment(pathSegments, index), + kind + ) + ); + } + + if (isJsonRecord(value)) { + const keys = Object.keys(value); + + if (keys.length > 0) { + return keys.flatMap(key => + collectJsonPresenceChanges( + value[key], + appendObjectKeyPathSegment(pathSegments, key), + kind + ) + ); + } + } + + return [ + { + kind, + path: pathSegments, + value, + }, + ]; +} + +function appendArrayIndexPathSegment( + pathSegments: JsonPathSegment[], + index: number +): JsonPathSegment[] { + return [ + ...pathSegments, + { + index, + kind: 'array-index', + }, + ]; +} + +function appendObjectKeyPathSegment( + pathSegments: JsonPathSegment[], + key: string +): JsonPathSegment[] { + return [ + ...pathSegments, + { + key, + kind: 'object-key', + }, + ]; +} + +function formatJsonValueChange(change: JsonValueChange): string { + const path = formatJsonPath(change.path); + + if (change.kind === 'added') { + return `+ ${path}: ${formatInlineJson(change.value)}`; + } + + if (change.kind === 'removed') { + return `- ${path}: ${formatInlineJson(change.value)}`; + } + + return `~ ${path}: ${formatInlineJson(change.expectedValue)} -> ${formatInlineJson(change.generatedValue)}`; +} + +function formatJsonPath(pathSegments: JsonPathSegment[]): string { + if (pathSegments.length === 0) { + return '$'; + } + + return pathSegments + .map((segment, index) => { + if (segment.kind === 'array-index') { + return `[${segment.index}]`; + } + + if (!canUseDotNotationForJsonKey(segment.key)) { + return `[${JSON.stringify(segment.key)}]`; + } + + return index === 0 ? segment.key : `.${segment.key}`; + }) + .join(''); +} + +function canUseDotNotationForJsonKey(key: string): boolean { + return /^[A-Za-z_][A-Za-z0-9_]*$/.test(key); +} + +function formatInlineJson(value: unknown): string { + const formattedJson = JSON.stringify(value); + + return typeof formattedJson === 'string' ? formattedJson : String(value); +} + +function isJsonRecord(value: unknown): value is JsonRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isUnknownArray(value: unknown): value is ReadonlyArray { + return Array.isArray(value); +} + +// Use the same two-space JSON form as fixture files for readable comparisons. +function formatUnknownJson(value: unknown): string { + const formattedJson = JSON.stringify(value, null, 2); + + return typeof formattedJson === 'string' ? formattedJson : String(value); +} + +// Round-trip values into plain JSON shapes before comparing baselines. +function normalizeJsonValue(value: unknown): unknown { + return JSON.parse(JSON.stringify(value)); +} + +function replaceExtension(fileName: string, extension: string): string { + return `${getFileStem(fileName)}${extension}`; +} + +function getFileStem(fileName: string): string { + const extensionIndex = fileName.lastIndexOf('.'); + + return extensionIndex === -1 ? fileName : fileName.slice(0, extensionIndex); +} diff --git a/src/node-directory-entry.ts b/src/node-directory-entry.ts new file mode 100644 index 0000000..d1faa56 --- /dev/null +++ b/src/node-directory-entry.ts @@ -0,0 +1,36 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { JsonFixtureDirectoryEntry } from './json-fixtures.js'; + +export function getNodeDirectoryEntryKind( + directoryPath: string, + entry: fs.Dirent +): JsonFixtureDirectoryEntry['kind'] { + if (entry.isFile()) { + return 'file'; + } + + if (entry.isDirectory()) { + return 'directory'; + } + + if (!entry.isSymbolicLink()) { + return 'other'; + } + + try { + const stats = fs.statSync(path.join(directoryPath, entry.name)); + + if (stats.isFile()) { + return 'file'; + } + + if (stats.isDirectory()) { + return 'directory'; + } + } catch { + return 'other'; + } + + return 'other'; +} diff --git a/src/parsers/basic-info.ts b/src/parsers/basic-info.ts index beddaee..e811b83 100644 --- a/src/parsers/basic-info.ts +++ b/src/parsers/basic-info.ts @@ -1,195 +1,225 @@ import { REGEX_PATTERNS } from '../utils/regex-patterns.js'; import { - extractFirstMatch, extractSection, splitLines, normalizeWhitespace, } from '../utils/text-utils.js'; +import { + isLikelyLocationText, + isSectionHeaderText, + looksLikeOrganizationNameText, + looksLikePersonNameText, + looksLikePositionTitleText, +} from '../utils/profile-text.js'; +import type { + Contact as ProfileContact, + ContactLink, + ParsedSectionResult, + SectionParseWarning, +} from '../types/profile.js'; +import { + createTextParserLines, + getParserLineSectionHeader, + type NormalizedParserLine, +} from '../utils/parser-lines.js'; +import { extractStructuralSectionLines } from '../utils/structural-sections.js'; +import type { StructuralLine } from '../utils/structural-lines.js'; -export interface Contact { - email: string; - phone?: string; - linkedin_url?: string; - location?: string; -} +export type Contact = ProfileContact; export interface BasicInfo { - name: string; - headline: string; - location: string; - summary: string; + name?: string; + headline?: string; + location?: string; + summary?: string; contact: Contact; } +type BasicInfoState = + | 'seeking_name' + | 'seeking_headline' + | 'seeking_location' + | 'in_summary'; + +const LOWERCASE_NAME_CONNECTORS = new Set([ + 'al', + 'bin', + 'binti', + 'da', + 'das', + 'de', + 'del', + 'della', + 'den', + 'der', + 'di', + 'do', + 'dos', + 'du', + 'e', + 'el', + 'la', + 'le', + 'van', + 'von', + 'y', +]); + +const EMAIL_SEARCH_LINE_PATTERN = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,63}$/i; +const LABELED_EMAIL_SEARCH_LINE_PATTERN = + /^(?:e-?mail|mail)(?:\s*[:-]\s*|\s+)[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,63}$/i; +const WRAPPED_EMAIL_START_PATTERN = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.$/i; +const EMAIL_TLD_CONTINUATION_PATTERN = /^[A-Z]{2,63}$/i; + +interface ContactLinkDraft { + label?: string; + parts: string[]; + rawLines: string[]; +} + +interface ContactSearchLines { + emailSearchLines: string[]; + linkSearchLines: string[]; +} + export class BasicInfoParser { static parse(text: string): BasicInfo { - return { + return this.parseWithWarnings(text).value; + } + + static parseWithWarnings(text: string): ParsedSectionResult { + const value: BasicInfo = { name: this.extractName(text), headline: this.extractHeadline(text), location: this.extractLocation(text), summary: this.extractSummary(text), contact: this.extractContact(text), }; + + return { + value, + warnings: this.createBasicInfoWarnings(text, value), + }; } - private static extractName(text: string): string { - // Strategy: Look for the pattern that appears in all LinkedIn PDFs - // The name always appears as a large text item (font size 26) in the main content + static parseStructuralWithWarnings( + text: string, + structuralLines: StructuralLine[] + ): ParsedSectionResult { + const value: BasicInfo = { + name: this.extractName(text), + headline: this.extractHeadline(text), + location: this.extractLocation(text), + summary: this.extractStructuralSummary(structuralLines), + contact: this.extractStructuralContact(text, structuralLines), + }; + + return { + value, + warnings: this.createBasicInfoWarnings(text, value), + }; + } + + private static extractName(text: string): string | undefined { + const lines = splitLines(text); - // First try to find specific known patterns - const knownNamePatterns = [ - /Arkady\s+Zalkowitsch/i, - /Thamiris\s+Zalkowitsch/i, - /Daniel\s+Braga/i, - ]; + for (let i = 0; i < Math.min(20, lines.length); i++) { + const name = this.extractNameFromLine(lines[i]); - for (const pattern of knownNamePatterns) { - const match = text.match(pattern); - if (match) { - return match[0].trim(); + if (name) { + return name; } } - // General approach: Look for two-word names that appear early in text - // and are likely to be the main person's name - const lines = splitLines(text); + return undefined; + } - for (let i = 0; i < Math.min(20, lines.length); i++) { - const line = lines[i].trim(); + private static extractNameFromLine(line: string): string | undefined { + const normalizedLine = normalizeWhitespace(line); + + if (!this.isNameSearchLine(normalizedLine)) { + return undefined; + } + + const words = normalizedLine.split(/\s+/).filter(Boolean); + const maxCandidateLength = Math.min(6, words.length); + + for (let length = maxCandidateLength; length >= 2; length--) { + const candidateWords = words.slice(0, length); + const hasConnector = candidateWords.some(word => + LOWERCASE_NAME_CONNECTORS.has(word.toLowerCase()) + ); - // Skip obvious non-name content if ( - line.includes('@') || - line.includes('http') || - line.includes('www.') || - line.includes('(') || - line.includes(')') || - line.includes('|') || - line.length < 5 || - line.length > 50 || - line.toLowerCase().includes('contact') || - line.toLowerCase().includes('skills') || - line.toLowerCase().includes('linkedin') || - line.toLowerCase().includes('page') || - line.toLowerCase().includes('summary') || - line.toLowerCase().includes('experience') || - line.toLowerCase().includes('strategic') || - line.toLowerCase().includes('roadmap') || - line.toLowerCase().includes('engineering') || - line.toLowerCase().includes('project') || - line.toLowerCase().includes('planning') || - line.toLowerCase().includes('languages') || - line.toLowerCase().includes('competências') || - line.toLowerCase().includes('contato') || - line.toLowerCase().includes('principais') + (length > 3 && !hasConnector) || + (words.length > length && length > 2 && !hasConnector) ) { continue; } - // Look for clean two-word name pattern (First Last) - const nameMatch = line.match(/^([A-Z][a-z]{2,}\s+[A-Z][a-z]{2,})\s*$/); - if (nameMatch) { - const potentialName = nameMatch[1]; + const candidate = candidateWords.join(' '); - // Additional validation: exclude common false positives - const excludeWords = ['top skills', 'main content', 'work experience', 'contact info']; - if (!excludeWords.some(exclude => potentialName.toLowerCase().includes(exclude))) { - return potentialName; - } - } - - // Also try to match names that might have more complex patterns - const complexNameMatch = line.match(/^([A-Z][a-z]{2,}\s+[A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)\s*$/); - if (complexNameMatch && line.split(' ').length <= 3) { - const potentialName = complexNameMatch[1]; - - // Make sure it's not a skill or section header - if (!potentialName.toLowerCase().includes('strategic') && - !potentialName.toLowerCase().includes('top') && - !potentialName.toLowerCase().includes('electronic') && - !potentialName.toLowerCase().includes('project')) { - return potentialName; - } + if (looksLikePersonNameText(candidate)) { + return candidate; } } - return ''; - } - - private static extractLocation(text: string): string { - const locationPatterns = [ - // Full location with United States - /([A-Z][a-z]+,\s*[A-Z][a-z]+,?\s*United States)/, - // City, State, Country - /([A-Z][a-z]+,\s*[A-Z][a-z]+,?\s*[A-Z]{2,}?)(?:\s|$)/, - // City, State abbreviation - /([A-Z][a-z]+,\s*[A-Z]{2})(?:\s|$)/, - // Common cities - /(New York|San Francisco|Los Angeles|Chicago|Boston|Austin|Seattle|London|Toronto|Sunnyvale|Santa Clara)/i, - ]; - - for (const pattern of locationPatterns) { - const match = text.match(pattern); - if (match) { - let location = match[1]; - // Clean up common issues - if (location.includes('United States')) { - return location; - } - return location; - } - } + return undefined; + } - // Look in specific lines that might contain location after headline + private static extractLocation(text: string): string | undefined { const lines = splitLines(text); - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (line.includes(',') && - (line.toLowerCase().includes('california') || - line.toLowerCase().includes('united states') || - line.includes('CA'))) { - // Check if this line looks like a location - const locationMatch = line.match(/([A-Z][a-z]+.*(?:California|United States|CA))/); - if (locationMatch) { - return locationMatch[1].trim(); - } - } - } - - return ''; + const firstSectionIndex = lines.findIndex(line => + isSectionHeaderText(line) + ); + const searchableLines = lines.slice( + 0, + firstSectionIndex === -1 ? Math.min(30, lines.length) : firstSectionIndex + ); + + return searchableLines + .map(line => normalizeWhitespace(line)) + .find(line => this.isLocationSearchLine(line)); } - private static extractHeadline(text: string): string { + private static extractHeadline(text: string): string | undefined { const lines = splitLines(text); - // Look for headline patterns with pipe separators for (let i = 0; i < Math.min(25, lines.length); i++) { const line = lines[i].trim(); const lowerLine = line.toLowerCase(); + const isLikelyEmail = + /^[A-Za-z0-9._%+-]+\s*@\s*[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/i.test(line); + const isShortCompanyHeadline = + !isLikelyEmail && + /^[A-Za-z][A-Za-z\s./+-]{1,40}\s+@\s+[A-Za-z0-9][A-Za-z0-9\s.&-]{1,40}$/.test( + line + ); - // Skip URLs, contact info, and other non-headline content if ( line.includes('http') || line.includes('www.') || - line.includes('@') || + (line.includes('@') && !isShortCompanyHeadline) || lowerLine.includes('contact') || lowerLine.includes('page') || lowerLine.includes('skills') || lowerLine.includes('languages') || - line.length < 15 + (!isShortCompanyHeadline && line.length < 15) ) { continue; } - // Look for lines with multiple pipe separators (typical headline format) + if (isShortCompanyHeadline) { + return normalizeWhitespace(line); + } + if (line.includes('|')) { const parts = line.split('|'); - if (parts.length >= 3) { // At least 3 parts suggest a detailed headline + if (parts.length >= 3) { return normalizeWhitespace(line); } } - // Look for job title patterns in longer lines const titlePatterns = [ /^(Senior|Lead|Principal|Chief|Director|VP|President|Software|Full[Ss]tack|Python|TypeScript).*(Engineer|Manager|Developer|Specialist)/i, /(Engineering|Software|Product|Data|Marketing|Sales|Business).+(Manager|Engineer|Analyst|Director)/i, @@ -202,25 +232,28 @@ export class BasicInfoParser { } } - // Fallback: Look for specific headline pattern from first PDF - const specificPattern = /Engineering\s+Manager\s+@\s+[A-Za-z]+\s*\|\s*[^|\n]*(?:\n[^|\n]*)?/i; + const specificPattern = + /Engineering\s+Manager\s+@\s+[A-Za-z]+\s*\|\s*[^|\n]*(?:\n[^|\n]*)?/i; const specificMatch = text.match(specificPattern); if (specificMatch) { return normalizeWhitespace(specificMatch[0].trim()); } - return ''; + return undefined; } - private static extractSummary(text: string): string { + private static extractSummary(text: string): string | undefined { const summarySection = extractSection(text, REGEX_PATTERNS.SUMMARY); if (summarySection) { - return normalizeWhitespace(summarySection) + const summary = normalizeWhitespace(summarySection) .split('\n') - .filter(line => line.trim().length > 10) - .join(' ') - .slice(0, 500); + .filter( + line => line.trim().length > 10 && !isPageFooterLine(line.trim()) + ) + .join(' '); + + return summary || undefined; } const lines = splitLines(text); @@ -245,110 +278,642 @@ export class BasicInfoParser { } } - return potentialSummaryLines.join(' ').slice(0, 500); + const summary = potentialSummaryLines.join(' '); + + return summary || undefined; } - private static extractContact(text: string): Contact { - const contact: Contact = { - email: '', - }; + private static extractStructuralSummary( + structuralLines: StructuralLine[] + ): string | undefined { + const mainLines = structuralLines.filter( + line => line.column === 'right' || line.column === 'single' + ); + const summaryStartIndex = mainLines.findIndex(line => { + const header = getParserLineSectionHeader(line.text); + + return header?.kind === 'target' && header.section === 'summary'; + }); + + if (summaryStartIndex === -1) { + return undefined; + } + + const summaryLines = mainLines.slice(summaryStartIndex + 1); + const nextSectionIndex = summaryLines.findIndex(line => { + const header = getParserLineSectionHeader(line.text); + + return header !== undefined && header.section !== 'summary'; + }); + const sectionLines = + nextSectionIndex === -1 + ? summaryLines + : summaryLines.slice(0, nextSectionIndex); + + if (sectionLines.length === 0) { + return undefined; + } + + const summaryParts: string[] = []; - // Extract email - use more robust approach - contact.email = this.extractEmail(text); - - // Extract LinkedIn URL - handle both complete URLs and broken ones - const linkedinPatterns = [ - /www\.linkedin\.com\/in\/([a-zA-Z0-9-]+)/i, - /linkedin\.com\/in\/([a-zA-Z0-9-]+)/i, - REGEX_PATTERNS.LINKEDIN - ]; - - for (const pattern of linkedinPatterns) { - const linkedinMatch = text.match(pattern); - if (linkedinMatch) { - const username = linkedinMatch[1]; - contact.linkedin_url = `https://linkedin.com/in/${username}`; - break; + for (const line of sectionLines.map(line => line.text)) { + const trimmedLine = line.trim(); + + // Skip short leading fragments as noise, but keep short continuation + // lines once summary capture has started. + if ( + !trimmedLine || + isPageFooterLine(trimmedLine) || + (trimmedLine.length <= 10 && summaryParts.length === 0) + ) { + continue; } + + summaryParts.push(trimmedLine); } - // Handle multi-line LinkedIn URLs (like "www.linkedin.com/in/thamiris-\nzalkowitsch") - const multiLineLinkedIn = /www\.linkedin\.com\/in\/([a-zA-Z0-9-]+)[\s\n]*([a-zA-Z0-9-]*)/i; - const multiMatch = text.match(multiLineLinkedIn); - if (multiMatch && !contact.linkedin_url) { - const username = multiMatch[1] + (multiMatch[2] || ''); - contact.linkedin_url = `https://linkedin.com/in/${username}`; + const summary = normalizeWhitespace(summaryParts.join(' ')); + + return summary || undefined; + } + + private static extractContact(text: string): Contact { + const parserLines = createTextParserLines(text); + const textContactLines = this.extractTextContactLines(parserLines); + const searchableLines = + textContactLines.length > 0 + ? textContactLines + : this.extractHeaderContactLines(parserLines); + + return this.extractContactFromLines(searchableLines); + } + + private static extractStructuralContact( + text: string, + structuralLines: StructuralLine[] + ): Contact { + const contactSection = extractStructuralSectionLines({ + section: 'contact', + structuralLines, + }); + const sectionLines = contactSection.lines.map(line => line.text); + + if (!contactSection.hasSection || sectionLines.length === 0) { + return this.extractContact(text); + } + + return this.extractContactFromLines(sectionLines); + } + + private static extractContactFromLines(lines: string[]): Contact { + const contact: Contact = {}; + const contactSearchLines = this.createContactSearchLines(lines); + const contactText = contactSearchLines.emailSearchLines.join('\n'); + const email = this.extractEmail(contactText); + const links = this.extractContactLinks(contactSearchLines.linkSearchLines); + const linkedInUrl = links.find(link => + /linkedin\.com\/in\//i.test(link.url) + )?.url; + const phone = this.extractPhoneFromLines(lines); + + if (email) { + contact.email = email; + } + + if (linkedInUrl) { + contact.linkedin_url = linkedInUrl; + } + + if (links.length > 0) { + contact.links = links; } - // Extract phone number - const phoneMatch = extractFirstMatch(text, REGEX_PATTERNS.PHONE); - if (phoneMatch) { - contact.phone = phoneMatch; + if (phone) { + contact.phone = phone; } return contact; } - private static extractEmail(text: string): string { - // Common email domains to validate against - const validDomains = [ - 'gmail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', - 'email.com', 'mail.com', 'aol.com', 'icloud.com', - 'protonmail.com', 'zoho.com', 'yandex.com' - ]; + private static createContactSearchLines(lines: string[]): ContactSearchLines { + const emailSearchLines: string[] = []; + const linkSearchLines: string[] = []; + const normalizedLines = lines.map(line => normalizeWhitespace(line)); + + for (let index = 0; index < normalizedLines.length; index += 1) { + const line = normalizedLines[index]; + const nextLine = normalizedLines[index + 1]; - // Find all @ symbols and extract context - const atIndices: number[] = []; - for (let i = 0; i < text.length; i++) { - if (text[i] === '@') { - atIndices.push(i); + // Walk normalizedLines once: when isWrappedEmailStartLine and + // isEmailTldContinuationLine match, emailSearchLines gets + // "user@example." + "com" stitched while linkSearchLines skips those + // fragments and index advances past the consumed continuation. + if ( + nextLine !== undefined && + this.isWrappedEmailStartLine(line) && + this.isEmailTldContinuationLine(nextLine) + ) { + emailSearchLines.push(`${line}${nextLine}`); + index += 1; + continue; } + + emailSearchLines.push(line); + linkSearchLines.push(line); } - for (const atIndex of atIndices) { - // Extract context around @ symbol - const before = text.substring(Math.max(0, atIndex - 50), atIndex); - const after = text.substring(atIndex + 1, Math.min(text.length, atIndex + 50)); + return { emailSearchLines, linkSearchLines }; + } + + private static extractTextContactLines( + parserLines: NormalizedParserLine[] + ): string[] { + return parserLines + .filter(line => line.section === 'contact') + .map(line => line.text) + .filter(line => line.length > 0); + } - // Get username part (before @) - const usernameMatch = before.match(/([A-Za-z0-9._%+-]+)$/); - if (!usernameMatch) { + private static extractHeaderContactLines( + parserLines: NormalizedParserLine[] + ): string[] { + const headerEndIndex = Math.min(parserLines.length, 50); + const headerLines = parserLines + .slice(0, headerEndIndex) + .filter(line => line.section === 'identity') + .map(line => line.text) + .filter(line => line.length > 0); + const contactLines: string[] = []; + + for (let index = 0; index < headerLines.length; index += 1) { + const line = headerLines[index]; + + if ( + !this.isHeaderContactSearchLine(line) && + !this.isWrappedEmailStartLine(line) + ) { continue; } - let username = usernameMatch[1]; + contactLines.push(line); + + const nextLine = headerLines[index + 1]; + if ( + nextLine !== undefined && + this.isWrappedEmailStartLine(line) && + this.isEmailTldContinuationLine(nextLine) + ) { + contactLines.push(nextLine); + index += 1; + } + } + + return contactLines; + } + + private static extractContactLinks(lines: string[]): ContactLink[] { + const links: ContactLink[] = []; + let draft: ContactLinkDraft | undefined; - // Clean username by removing common prefixes - const cleanedUsername = username - .replace(/^Contact/i, '') // Remove "Contact" - .replace(/^Email/i, '') // Remove "Email" - .replace(/^Mail/i, '') // Remove "Mail" - .replace(/^Send/i, '') // Remove "Send" - .trim(); + for (const rawLine of lines) { + const line = normalizeWhitespace(rawLine); - // Use cleaned username if it's still valid - if (cleanedUsername.length > 0 && /^[A-Za-z0-9._%+-]+$/.test(cleanedUsername)) { - username = cleanedUsername; + if (!line || this.isContactNonLinkLine(line)) { + continue; } - // Get domain part (after @), looking for valid domains - for (const domain of validDomains) { - if (after.toLowerCase().startsWith(domain.toLowerCase())) { - return `${username}@${domain}`; - } + const label = this.extractContactLinkLabel(line); + const lineWithoutLabel = this.removeContactLinkLabel(line); + const startsLink = this.looksLikeContactLinkStart(lineWithoutLabel); + const continuesLink = + draft !== undefined && + !startsLink && + this.looksLikeContactLinkContinuation(lineWithoutLabel); + + if (!draft && !startsLink) { + continue; } - // If no known domain matched, try to extract a reasonable domain - const domainMatch = after.match(/^([A-Za-z0-9.-]+\.[A-Za-z]{2,4})/); - if (domainMatch) { - const domain = domainMatch[1]; - // Check if it's a reasonable domain (not too long, doesn't contain obvious non-domain text) - if (domain.length < 30 && !domain.includes('linkedin') && !domain.includes('www')) { - return `${username}@${domain}`; + if (!draft) { + // Start a draft for a link that may be split across adjacent PDF lines. + draft = { + label, + parts: lineWithoutLabel ? [lineWithoutLabel] : [], + rawLines: [line], + }; + } else if (!startsLink && (continuesLink || label)) { + // Non-link-start lines may continue a draft; a trailing label closes it. + if (lineWithoutLabel) { + draft.parts.push(lineWithoutLabel); } + draft.rawLines.push(line); + draft.label = draft.label ?? label; + } else { + // A fresh link-looking line closes the previous draft before starting. + this.pushContactLink(links, draft); + draft = startsLink + ? { + label, + parts: lineWithoutLabel ? [lineWithoutLabel] : [], + rawLines: [line], + } + : undefined; + } + + if (draft && label) { + this.pushContactLink(links, draft); + draft = undefined; } } - return ''; + if (draft) { + this.pushContactLink(links, draft); + } + + return dedupeContactLinks(links); } + + private static pushContactLink( + links: ContactLink[], + draft: ContactLinkDraft + ): void { + const rawUrl = joinContactLinkParts(draft.parts); + const url = normalizeContactUrl(rawUrl); + + if (!url) { + return; + } + + links.push({ + ...(draft.label ? { label: draft.label } : {}), + rawText: draft.rawLines.join(' '), + url, + }); + } + + private static extractPhoneFromLines(lines: string[]): string | undefined { + for (const line of lines) { + const normalizedLine = normalizeWhitespace(line); + + if (!this.isPhoneSearchLine(normalizedLine)) { + continue; + } + + const phoneMatch = + normalizedLine.match( + /(?:\+\d{1,3}[\s.-]*)?(?:\(?\d{2,3}\)?[\s.-]*)?\d{3,5}[\s.-]?\d{4}/ + )?.[0] ?? normalizedLine.match(REGEX_PATTERNS.PHONE)?.[0]; + + if (phoneMatch && phoneMatch.replace(/\D/g, '').length >= 8) { + return phoneMatch; + } + } + + return undefined; + } + + private static isContactNonLinkLine(line: string): boolean { + return ( + /^[A-Z0-9._%+-]+\s*@\s*[A-Z0-9.-]+\.[A-Z]{2,63}$/i.test(line) || + this.isPhoneSearchLine(line) + ); + } + + private static extractContactLinkLabel(line: string): string | undefined { + const match = line.match(/\((LinkedIn|Company|Other|Blog)\)\s*$/i); + + if (!match) { + return undefined; + } + + return match[1]; + } + + private static removeContactLinkLabel(line: string): string { + return normalizeWhitespace( + line.replace(/\s*\((?:LinkedIn|Company|Other|Blog)\)\s*$/i, '') + ); + } + + private static looksLikeContactLinkStart(line: string): boolean { + return ( + /(?:^|[\s/])(?:www\.)?[a-z0-9][a-z0-9-]*(?:\.[a-z0-9][a-z0-9-]*)+(?:[/:/?#]|$)/i.test( + line + ) || /linkedin\.com\/in\//i.test(line) + ); + } + + private static looksLikeContactLinkContinuation(line: string): boolean { + return ( + line.length > 0 && + line.length <= 120 && + !isSectionHeaderText(line) && + !/^[A-Z0-9._%+-]+\s*@\s*[A-Z0-9.-]+\.[A-Z]{2,63}$/i.test(line) && + !this.isPhoneSearchLine(line) && + /^[A-Za-z0-9@_~./?:#=&%+-]+$/u.test(line) + ); + } + + private static isPhoneSearchLine(line: string): boolean { + const normalizedLine = line.trim(); + + return ( + normalizedLine.length <= 40 && + !normalizedLine.includes('/') && + !/(?:^|\s)www\./i.test(normalizedLine) && + !/https?:\/\//i.test(normalizedLine) && + !/[A-Za-z0-9.-]+\.[A-Za-z]{2,}/.test(normalizedLine) && + !/^\(?\s*(?:19|20)\d{2}\s*[-–—]\s*(?:(?:19|20)\d{2}|present)\s*\)?$/i.test( + normalizedLine + ) && + (/\b(?:mobile|phone|tel)\b/i.test(normalizedLine) || + /^[+\d\s().-]+$/.test(normalizedLine)) + ); + } + + private static isHeaderContactSearchLine(line: string): boolean { + return ( + this.isEmailSearchLine(line) || + this.isPhoneSearchLine(line) || + this.looksLikeContactLinkStart(line) + ); + } + + private static isEmailSearchLine(line: string): boolean { + const normalizedLine = line.trim().replace(/\s*@\s*/g, '@'); + + return ( + normalizedLine.length <= 120 && + (EMAIL_SEARCH_LINE_PATTERN.test(normalizedLine) || + LABELED_EMAIL_SEARCH_LINE_PATTERN.test(normalizedLine)) + ); + } + + private static isWrappedEmailStartLine(line: string): boolean { + const normalizedLine = line.trim().replace(/\s*@\s*/g, '@'); + + return ( + normalizedLine.length <= 120 && + WRAPPED_EMAIL_START_PATTERN.test(normalizedLine) + ); + } + + private static isEmailTldContinuationLine(line: string): boolean { + return EMAIL_TLD_CONTINUATION_PATTERN.test(line.trim()); + } + + private static extractEmail(text: string): string | undefined { + const normalizedText = text.replace(/\s*@\s*/g, '@'); + const match = normalizedText.match( + /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,63}/i + ); + + return match?.[0]; + } + + private static isNameSearchLine(line: string): boolean { + return ( + line.length >= 5 && + line.length <= 120 && + !/[0-9]/.test(line) && + !/[|()]/.test(line) && + !/[A-Z0-9._%+-]+\s*@\s*[A-Z0-9.-]+\.[A-Z]{2,63}/i.test(line) && + !/https?:\/\//i.test(line) && + !/(?:^|\s)www\./i.test(line) && + !/^page\s+\d+/i.test(line) && + !isSectionHeaderText(line) && + !isLikelyLocationText(line) && + !looksLikePositionTitleText(line) && + !looksLikeOrganizationNameText(line) + ); + } + + private static isLocationSearchLine(line: string): boolean { + return ( + line.length >= 3 && + line.length <= 120 && + !/[A-Z0-9._%+-]+\s*@\s*[A-Z0-9.-]+\.[A-Z]{2,63}/i.test(line) && + !/https?:\/\//i.test(line) && + !/(?:^|\s)www\./i.test(line) && + !/^page\s+\d+/i.test(line) && + !isSectionHeaderText(line) && + isLikelyLocationText(line) + ); + } + + private static createBasicInfoWarnings( + text: string, + basicInfo: BasicInfo + ): SectionParseWarning[] { + const parserLines = createTextParserLines(text); + const warnings: SectionParseWarning[] = []; + const headerLines = parserLines.slice( + 0, + findBasicInfoHeaderEndIndex(parserLines) + ); + + const hasContactSection = headerLines.some(line => { + const header = getParserLineSectionHeader(line.text); + + return header?.kind === 'target' && header.section === 'contact'; + }); + const hasSummarySection = headerLines.some(line => { + const header = getParserLineSectionHeader(line.text); + + return header?.kind === 'target' && header.section === 'summary'; + }); + + if ( + hasContactSection && + !basicInfo.contact.email && + !basicInfo.contact.phone && + !basicInfo.contact.linkedin_url + ) { + warnings.push({ + code: 'section_parse_warning', + field: 'contact', + message: + 'Detected a contact section but could not extract contact fields', + section: 'contact', + }); + } + + if (hasSummarySection && !basicInfo.summary) { + warnings.push({ + code: 'section_parse_warning', + field: 'summary', + message: + 'Detected a summary section but could not extract summary text', + section: 'summary', + }); + } + + return warnings; + } +} + +function findBasicInfoHeaderEndIndex( + parserLines: NormalizedParserLine[] +): number { + let state: BasicInfoState = 'seeking_name'; + + for (let index = 0; index < parserLines.length; index++) { + const line = parserLines[index]; + + if (!line.text) { + continue; + } + + const header = getParserLineSectionHeader(line.text); + + if (header?.kind === 'target') { + return isBasicInfoWarningSection(header.section) + ? findBasicInfoWarningHeaderEndIndex(parserLines, index) + : index; + } + + if (line.section !== 'identity') { + return index; + } + + state = nextBasicInfoState(state, line.text); + + if (state === 'in_summary') { + return index + 1; + } + } + + return parserLines.length; +} + +function joinContactLinkParts(parts: string[]): string { + return parts.reduce((combined, part) => { + const normalizedPart = part.trim(); + + if (!combined) { + return normalizedPart; + } + + if ( + combined.endsWith('-') || + combined.endsWith('/') || + normalizedPart.startsWith('/') || + normalizedPart.startsWith('?') || + normalizedPart.startsWith('#') + ) { + return `${combined}${normalizedPart}`; + } + + return `${combined}/${normalizedPart}`; + }, ''); +} + +function normalizeContactUrl(rawUrl: string): string | undefined { + const compactUrl = rawUrl.replace(/\s+/g, '').replace(/^\.+|\.+$/g, ''); + + if (!compactUrl || !/[A-Za-z0-9]\.[A-Za-z]{2,}/.test(compactUrl)) { + return undefined; + } + + const linkedInMatch = compactUrl.match( + /(?:https?:\/\/)?(?:www\.)?linkedin\.com\/in\/([a-zA-Z0-9-]+)/ + ); + + if (linkedInMatch) { + return `https://linkedin.com/in/${linkedInMatch[1]}`; + } + + return /^https?:\/\//i.test(compactUrl) + ? compactUrl + : `https://${compactUrl}`; +} + +function dedupeContactLinks(links: ContactLink[]): ContactLink[] { + const seenUrls = new Set(); + const dedupedLinks: ContactLink[] = []; + + for (const link of links) { + if (seenUrls.has(link.url)) { + continue; + } + + seenUrls.add(link.url); + dedupedLinks.push(link); + } + + return dedupedLinks; +} + +function isPageFooterLine(line: string): boolean { + return /^page\s+\d+\s+of\s+\d+$/i.test(line.trim()); +} + +function findBasicInfoWarningHeaderEndIndex( + parserLines: NormalizedParserLine[], + startIndex: number +): number { + let endIndex = startIndex; + + while (endIndex < parserLines.length) { + const line = parserLines[endIndex]; + + // Ignore spacing inside warning sections while looking for the next real boundary. + if (!line.text) { + endIndex++; + continue; + } + + const header = getParserLineSectionHeader(line.text); + + // A non-warning target header starts the next parser section. + if ( + header?.kind === 'target' && + !isBasicInfoWarningSection(header.section) + ) { + return endIndex; + } + + // A hard boundary header always closes the warning header block. + if (header?.kind === 'boundary') { + return endIndex; + } + + // Non-header content in another section means the parser has advanced. + if ( + !header && + line.section !== 'identity' && + !isBasicInfoWarningSection(line.section) + ) { + return endIndex; + } + + endIndex++; + } + + return endIndex; +} + +function isBasicInfoWarningSection( + section: NormalizedParserLine['section'] | undefined +): boolean { + return section === 'contact' || section === 'summary'; +} + +function nextBasicInfoState( + state: BasicInfoState, + line: string +): BasicInfoState { + if (state === 'seeking_name' && line.length >= 2) { + return 'seeking_headline'; + } + + if (state === 'seeking_headline' && line.length >= 15) { + return 'seeking_location'; + } + + if (state === 'seeking_location' && isLikelyLocationText(line)) { + return 'in_summary'; + } + + return state; } diff --git a/src/parsers/education.ts b/src/parsers/education.ts index 15383d6..336c12e 100644 --- a/src/parsers/education.ts +++ b/src/parsers/education.ts @@ -1,30 +1,114 @@ -import { REGEX_PATTERNS } from '../utils/regex-patterns.js'; +import { normalizeWhitespace } from '../utils/text-utils.js'; +import type { StructuralLine } from '../utils/structural-lines.js'; +import type { + Education, + ParsedSectionResult, + SectionParseWarning, +} from '../types/profile.js'; import { - extractSection, - splitLines, - normalizeWhitespace, -} from '../utils/text-utils.js'; - -export interface Education { - degree: string; - institution: string; - year?: string; - location?: string; - description?: string; + looksLikeDateRangeText, + parseProfileDateRange, +} from '../utils/date-parser.js'; +import { createTextParserLines } from '../utils/parser-lines.js'; +import { + isEducationSectionHeaderText, + isLikelyLocationText, + isSectionHeaderText, +} from '../utils/profile-text.js'; + +type EducationLineState = + | 'seeking_institution' + | 'seeking_degree' + | 'in_details'; + +interface AppendDegreeTextParams { + existingDegree: string; + degreePart: string; +} + +interface ShouldAppendStructuralDegreePartParams { + existingDegree?: string; + degreePart: string; + line: string; + year: string; +} + +interface StructuralDegreeContinuationParams { + existingDegree: string; + degreePart: string; } export class EducationParser { + private static readonly MONTH_NAMES_PATTERN = + 'January|February|March|April|May|June|July|August|September|October|November|December'; + private static readonly OPTIONAL_MONTH_PREFIX = `(?:(?:${EducationParser.MONTH_NAMES_PATTERN})\\s+)?`; + private static readonly YEAR_PATTERN = '(?:19|20)\\d{2}'; + private static readonly YEAR_RANGE_REGEXP: RegExp = new RegExp( + `\\(${EducationParser.OPTIONAL_MONTH_PREFIX}\\d{4}\\s*-\\s*${EducationParser.OPTIONAL_MONTH_PREFIX}\\d{4}\\)`, + 'i' + ); + private static readonly MONTH_YEAR_REGEXP: RegExp = new RegExp( + `\\((?:${EducationParser.MONTH_NAMES_PATTERN})\\s+\\d{4}\\)`, + 'i' + ); + private static readonly PARENTHESIZED_YEAR_RANGE_PATTERN: RegExp = new RegExp( + `\\s*[·-]?\\s*\\(${EducationParser.OPTIONAL_MONTH_PREFIX}${EducationParser.YEAR_PATTERN}\\s*-\\s*${EducationParser.OPTIONAL_MONTH_PREFIX}${EducationParser.YEAR_PATTERN}\\)\\s*`, + 'gi' + ); + private static readonly PARENTHESIZED_MONTH_YEAR_PATTERN: RegExp = new RegExp( + `\\s*[·-]?\\s*\\((?:${EducationParser.MONTH_NAMES_PATTERN})\\s+${EducationParser.YEAR_PATTERN}\\)\\s*`, + 'gi' + ); + private static readonly LOCATION_PATTERN: RegExp = + /^[\p{Lu}][\p{L}\p{M}\s.-]+(?:,\s*[\p{Lu}][\p{L}\p{M}\s.-]*)*$/u; + private static readonly STRUCTURAL_DEGREE_BOUNDARY_PATTERN: RegExp = + /[,/&-]\s*$/u; + private static readonly STRUCTURAL_DEGREE_COMMA_CONNECTOR_PATTERN: RegExp = + /,\s*(?:and|for|in|of)\s*$/iu; + private static readonly STRUCTURAL_DEGREE_WORD_CONNECTOR_PATTERN: RegExp = + /\b(?:and|for|in|of)\s*$/iu; + private static readonly STRUCTURAL_ACADEMIC_FRAGMENT_PATTERN: RegExp = + /\b(?:administration|analytics|arts|baccalaureate|business|communication|communications|data|design|economics|education|engineering|finance|law|management|marketing|mathematics|minor|policy|product|program|science|sciences|software|studies|systems|technician|technology)\b/iu; + static parse(text: string): Education[] { - const educationSection = extractSection(text, REGEX_PATTERNS.EDUCATION); + return this.parseWithWarnings(text).value; + } - if (!educationSection) { - return []; - } + static parseWithWarnings(text: string): ParsedSectionResult { + const lines = createTextParserLines(text) + .filter(line => line.section === 'education') + .map(line => line.text); + const value = this.parseEducationLines(lines); - const educations: Education[] = []; - const lines = splitLines(educationSection); + return { + value, + warnings: this.createEducationWarnings(value, lines), + }; + } + + static parseStructural(lines: StructuralLine[]): Education[] { + return this.parseStructuralWithWarnings(lines).value; + } + + static parseStructuralWithWarnings( + lines: StructuralLine[] + ): ParsedSectionResult { + const educationLines = this.extractStructuralEducationLines(lines); + const value = this.parseStructuralEducationLines(educationLines); + + return { + value, + warnings: this.createEducationWarnings( + value, + educationLines.map(line => line.text) + ), + }; + } + private static parseEducationLines(lines: string[]): Education[] { + const educations: Education[] = []; let currentEducation: Partial | null = null; + let state: EducationLineState = 'seeking_institution'; for (const line of lines) { const normalizedLine = normalizeWhitespace(line); @@ -48,34 +132,39 @@ export class EducationParser { year: '', location: '', }; + state = 'seeking_degree'; } // Check if this looks like a degree (could contain year info) else if (currentEducation && this.looksLikeDegree(normalizedLine)) { // Check if the degree line also contains year info const yearInDegree = this.extractYearFromLine(normalizedLine); if (yearInDegree) { - currentEducation.degree = normalizedLine.replace(yearInDegree, '').trim(); + currentEducation.degree = this.removeYearFromDegree(normalizedLine); currentEducation.year = yearInDegree; } else { currentEducation.degree = normalizedLine; } + state = 'in_details'; } // Check if this looks like a year else if (currentEducation && this.looksLikeYear(normalizedLine)) { currentEducation.year = normalizedLine; + state = 'in_details'; } // Check if this looks like a location else if (currentEducation && this.looksLikeLocation(normalizedLine)) { currentEducation.location = normalizedLine; + state = 'in_details'; } // If we don't have an institution yet, maybe this line is it - else if (!currentEducation) { + else if (!currentEducation || state === 'seeking_institution') { currentEducation = { institution: normalizedLine, degree: '', year: '', location: '', }; + state = 'seeking_degree'; } } @@ -87,27 +176,99 @@ export class EducationParser { return educations; } - private static looksLikeInstitution(line: string): boolean { - const lower = line.toLowerCase(); + private static parseStructuralEducationLines( + educationLines: StructuralLine[] + ): Education[] { + if (educationLines.length === 0) { + return []; + } + const institutionFontSize = Math.max( + ...educationLines.map(line => line.fontSize) + ); + const institutionThreshold = institutionFontSize - 0.5; + const educations: Education[] = []; + let currentEducation: Partial | null = null; + + for (const line of educationLines) { + const normalizedLine = normalizeWhitespace(line.text); + + if ( + !normalizedLine || + normalizedLine.length < 2 || + /^page\s+\d+\s+of\s+\d+$/i.test(normalizedLine) + ) { + continue; + } + + const isInstitutionLine = + line.fontSize >= institutionThreshold && + (this.looksLikeInstitutionHeading(normalizedLine) || + !this.looksLikeDegree(normalizedLine)) && + !this.looksLikeYear(normalizedLine); + + if (isInstitutionLine) { + if (currentEducation?.institution) { + educations.push(this.fillDefaults(currentEducation)); + } + + currentEducation = { + degree: '', + institution: normalizedLine, + location: '', + year: '', + }; + continue; + } + + if (!currentEducation) { + currentEducation = { + degree: '', + institution: normalizedLine, + location: '', + year: '', + }; + continue; + } + + this.addStructuralEducationDetail({ + education: currentEducation, + line: normalizedLine, + }); + } + + if (currentEducation?.institution) { + educations.push(this.fillDefaults(currentEducation)); + } + + return educations; + } + + private static looksLikeInstitution(line: string): boolean { return ( line.length > 5 && line.length < 100 && - (/university|college|school|institute/.test(lower) || - /^[A-Z][a-z]+(?:\s+[A-Z][a-z]*)*$/.test(line)) && + (this.looksLikeInstitutionHeading(line) || + /^[\p{Lu}][\p{L}\p{M}]+(?:\s+[\p{Lu}][\p{L}\p{M}]*)*$/u.test(line)) && !this.looksLikeDegree(line) && !this.looksLikeYear(line) ); } + private static looksLikeInstitutionHeading(line: string): boolean { + return /university|college|school|institute/i.test(line); + } + private static looksLikeDegree(line: string): boolean { const lower = line.toLowerCase(); return ( line.length > 3 && line.length < 80 && - /bachelor|master|phd|mba|engineering|science|business/.test(lower) && - !this.looksLikeYear(line) + /\b(?:a\.?b\.?|b\.?a\.?|b\.?s\.?|s\.?m\.?|bachelor|master|phd|mba|associate|diploma|certificate|engineering|executive program|science|business|baccalaureate|bacharelado|bacharel|licenciatura|mestrado|mestre|doutorado|doutor|p[oó]s[-\s]?gradua[cç][aã]o|tecn[oó]logo|tecnologia|certifica[cç][aã]o)\b/.test( + lower + ) && + !/^\s*[()·-]?\s*(19|20)\d{2}/.test(line) ); } @@ -117,18 +278,19 @@ export class EducationParser { /\b(19|20)\d{2}\b/.test(line) || /\d{4}\s*-\s*\d{4}/.test(line) || /\d{4}\s*-\s*present/i.test(line) || - /\(\d{4}\s*-\s*\d{4}\)/.test(line) + /\(\d{4}\s*-\s*\d{4}\)/.test(line) || + looksLikeDateRangeText(line) ); } private static extractYearFromLine(line: string): string { // Extract year patterns from lines that might contain both degree and year info const yearPatterns = [ - /\(\d{4}\s*-\s*\d{4}\)/, // (2017 - 2018) - /·\s*\(\d{4}\s*-\s*\d{4}\)/, // · (2002 - 2005) + EducationParser.YEAR_RANGE_REGEXP, + EducationParser.MONTH_YEAR_REGEXP, /\b\d{4}\s*-\s*\d{4}\b/, // 2017 - 2018 - /\(\d{4}\)/, // (2016) - /\b\d{4}\b/, // 2016 + /\(\d{4}\)/, // (2016) + /\b\d{4}\b/, // 2016 ]; for (const pattern of yearPatterns) { @@ -141,22 +303,304 @@ export class EducationParser { return ''; } + private static removeYearFromDegree(line: string): string { + return normalizeWhitespace( + line + .replace(EducationParser.PARENTHESIZED_YEAR_RANGE_PATTERN, ' ') + .replace(EducationParser.PARENTHESIZED_MONTH_YEAR_PATTERN, ' ') + .replace(/\s*[·-]?\s*(?:19|20)\d{2}\s*-\s*(?:19|20)\d{2}\s*/g, ' ') + .replace(/\s*[·-]?\s*\((?:19|20)\d{2}\)\s*/g, ' ') + .replace(/\s*[·-]?\s*\b(?:19|20)\d{2}\b\s*/g, ' ') + .replace(/[·()]+$/g, '') + ); + } + private static looksLikeLocation(line: string): boolean { + const hasLocationShape = + isLikelyLocationText(line) || + (line.includes(',') && this.LOCATION_PATTERN.test(line)); + return ( line.length > 2 && line.length < 50 && - /^[A-Z][a-z]+(?:,\s*[A-Z][a-z]*)*$/i.test(line) && + hasLocationShape && !this.looksLikeYear(line) && !this.looksLikeDegree(line) ); } private static fillDefaults(education: Partial): Education { + const dateText = education.year || undefined; + const dates = dateText ? parseProfileDateRange(dateText) : undefined; + return { + ...(dates ? { dates } : {}), institution: education.institution || '', degree: education.degree || '', year: education.year || '', location: education.location || '', }; } + + private static extractStructuralEducationLines( + lines: StructuralLine[] + ): StructuralLine[] { + const mainLines = lines.filter( + line => line.column === 'right' || line.column === 'single' + ); + const educationStartIndex = mainLines.findIndex(line => + isEducationSectionHeaderText(line.text) + ); + + if (educationStartIndex === -1) { + return []; + } + + const followingLines = mainLines.slice(educationStartIndex + 1); + const nextSectionIndex = followingLines.findIndex(line => + isSectionHeaderText(line.text) + ); + + return nextSectionIndex === -1 + ? followingLines + : followingLines.slice(0, nextSectionIndex); + } + + private static addStructuralEducationDetail({ + education, + line, + }: { + education: Partial; + line: string; + }): void { + const year = this.extractYearFromLine(line); + const degree = year ? this.removeYearFromDegree(line) : line; + const existingDegree = education.degree || undefined; + + if ( + !existingDegree && + !year && + this.looksLikeInstitutionContinuation({ + institution: education.institution, + line, + }) + ) { + education.institution = normalizeWhitespace( + `${education.institution ?? ''} ${line}` + ); + return; + } + + if (year) { + education.year = year; + } + + if ( + this.shouldAppendStructuralDegreePart({ + degreePart: degree, + existingDegree, + line, + year, + }) + ) { + education.degree = existingDegree + ? this.appendDegreeText({ + degreePart: degree, + existingDegree, + }) + : degree; + return; + } + + if (year) { + if (!existingDegree && degree) { + education.degree = degree; + } + + return; + } + + if (this.looksLikeYear(line)) { + education.year = line; + return; + } + + if (!existingDegree && this.looksLikeDegreeDetail(line)) { + education.degree = degree; + return; + } + + if (this.looksLikeLocation(line)) { + education.location = line; + return; + } + + if (!existingDegree) { + education.degree = degree; + return; + } + } + + private static shouldAppendStructuralDegreePart({ + existingDegree, + degreePart, + line, + year, + }: ShouldAppendStructuralDegreePartParams): boolean { + if (!degreePart) { + return false; + } + + if (this.looksLikeDegree(line)) { + return true; + } + + if (existingDegree === undefined) { + return false; + } + + if (year.length > 0) { + return !this.looksLikeLocation(line); + } + + return this.looksLikeStructuralDegreeContinuation({ + degreePart, + existingDegree, + }); + } + + private static looksLikeInstitutionContinuation({ + institution, + line, + }: { + institution?: string; + line: string; + }): boolean { + const normalizedInstitution = institution?.trim() ?? ''; + const normalizedLine = line.trim(); + const hasInstitutionBoundary = /[-,/&]\s*$/u.test(normalizedInstitution); + const hasInstitutionConnector = /\b(?:and|at|for|in|of|the)\s*$/iu.test( + normalizedInstitution + ); + const hasSchoolOfContinuation = + /\b(?:school|college)\s+of(?:\s+\p{Lu}[\p{L}\p{M}]*)?$/iu.test( + normalizedInstitution + ) && + /^[\p{Lu}][\p{L}\p{M}]*(?:\s+[\p{Lu}][\p{L}\p{M}]*)*$/u.test( + normalizedLine + ); + + return ( + normalizedInstitution.length > 0 && + normalizedLine.length > 1 && + normalizedLine.length < 50 && + !this.looksLikeYear(normalizedLine) && + !this.looksLikeLocation(normalizedLine) && + (hasSchoolOfContinuation || + (!this.looksLikeDegree(normalizedLine) && + (hasInstitutionBoundary || hasInstitutionConnector))) + ); + } + + private static looksLikeDegreeDetail(line: string): boolean { + return ( + this.looksLikeDegree(line) || + /^(?:A\.?B\.?|B\.?A\.?|B\.?S\.?|S\.?M\.?)(?:\b|,)/iu.test(line) || + this.looksLikeShortAcademicFragment(line) + ); + } + + private static looksLikeStructuralDegreeContinuation({ + existingDegree, + degreePart, + }: StructuralDegreeContinuationParams): boolean { + const hasContinuationBoundary = + this.STRUCTURAL_DEGREE_BOUNDARY_PATTERN.test(existingDegree) || + this.STRUCTURAL_DEGREE_COMMA_CONNECTOR_PATTERN.test(existingDegree) || + this.STRUCTURAL_DEGREE_WORD_CONNECTOR_PATTERN.test(existingDegree); + const isShortAcademicFragment = + this.looksLikeShortAcademicFragment(degreePart); + const hasAcademicFragmentContinuation = + this.hasCommaDelimitedAcademicFragment(existingDegree) && + isShortAcademicFragment; + + return ( + (hasContinuationBoundary || hasAcademicFragmentContinuation) && + isShortAcademicFragment + ); + } + + private static hasCommaDelimitedAcademicFragment(degree: string): boolean { + const fragments = degree.split(','); + const finalFragment = fragments.length > 1 ? fragments.at(-1) : undefined; + + return finalFragment + ? this.looksLikeShortAcademicFragment(finalFragment) + : false; + } + + private static looksLikeShortAcademicFragment(fragment: string): boolean { + const normalizedFragment = fragment.trim(); + + return ( + normalizedFragment.split(/\s+/).length <= 4 && + this.STRUCTURAL_ACADEMIC_FRAGMENT_PATTERN.test(normalizedFragment) + ); + } + + private static appendDegreeText({ + existingDegree, + degreePart, + }: AppendDegreeTextParams): string { + // A trailing slash already joins compound degree labels, so separator stays empty. + const separator = existingDegree.trim().endsWith('/') ? '' : ' '; + + return normalizeWhitespace(`${existingDegree}${separator}${degreePart}`); + } + + private static createEducationWarnings( + educations: Education[], + lines: string[] + ): SectionParseWarning[] { + const warnings: SectionParseWarning[] = []; + const meaningfulLines = lines + .map(line => normalizeWhitespace(line)) + .filter(line => line.length > 0 && !/^page\s+\d+/i.test(line)); + + if (meaningfulLines.length > 0 && educations.length === 0) { + warnings.push({ + code: 'section_parse_warning', + field: 'entry', + message: 'Detected an education section but could not extract entries', + rawText: meaningfulLines.join(' '), + section: 'education', + }); + } + + educations.forEach((education, entry) => { + if (!education.institution) { + warnings.push({ + code: 'section_parse_warning', + entry, + field: 'institution', + message: 'Could not extract institution for education entry', + rawText: education.degree, + section: 'education', + }); + } + + if (education.year && !education.dates) { + warnings.push({ + code: 'section_parse_warning', + entry, + field: 'dates', + message: 'Could not parse education date range', + rawText: education.year, + section: 'education', + }); + } + }); + + return warnings; + } } diff --git a/src/parsers/experience-structural.ts b/src/parsers/experience-structural.ts index ce987d5..2de5c8d 100644 --- a/src/parsers/experience-structural.ts +++ b/src/parsers/experience-structural.ts @@ -1,253 +1,2056 @@ -import { TextItem, WorkExperience, Position, StructuralSection } from '../types/structural.js'; +import { + TextItem, + WorkExperience, + Position, + StructuralSection, +} from '../types/structural.js'; +import type { + ParsedSectionResult, + SectionParseWarning, +} from '../types/profile.js'; +import { + extractProfileDateRangeText, + looksLikeDateRangeText, + parseProfileDateRange, +} from '../utils/date-parser.js'; +import { classifyLocationText } from '../utils/location-classifier.js'; +import { + cleanOrganizationNameText, + isEducationSectionHeaderText, + isExperienceSectionHeaderText, + isLikelyLocationText, + isSectionHeaderText, + looksLikeOrganizationNameText, + looksLikePersonNameText, + looksLikePositionTitleText, +} from '../utils/profile-text.js'; +import { + createGroupedTextItemParserLines, + type NormalizedParserLine, +} from '../utils/parser-lines.js'; +import { + createStructuralLines, + type StructuralLine, +} from '../utils/structural-lines.js'; import { StructuralParser } from './structural-parser.js'; +type ExperienceLineState = + | 'seeking_company' + | 'seeking_title' + | 'seeking_dates' + | 'in_description'; + +interface WrappedParserLineMergeParams { + allLines: NormalizedParserLine[]; + combinedText: string; + index: number; + line: NormalizedParserLine; + nextLine: NormalizedParserLine; +} + +interface MergedParserLineParams { + combinedText: string; + index: number; + line: NormalizedParserLine; + nextLine: NormalizedParserLine; +} + +interface CombinedOrganizationTitleLineParams { + line: string; + nextLine?: string; +} + +interface CombinedOrganizationTitleLine { + organization: string; + title: string; +} + +interface DescriptionLineParams { + allLines: string[]; + index: number; + line: string; + previousLine?: string; +} + +interface ExperienceHeaderCandidate { + durationLine: NormalizedParserLine; + locationLine?: NormalizedParserLine; + organizationLine: NormalizedParserLine; + score: number; + titleLine: NormalizedParserLine; + totalDurationLine?: NormalizedParserLine; +} + export class ExperienceStructuralParser { - static parseExperience(textItems: TextItem[], experienceStartY?: number, experienceEndY?: number): WorkExperience[] { - // Filter items within experience section and focus on main content area (right column) - let relevantItems = textItems.filter(item => item.x >= 150); // Right column only + private static readonly EXPERIENCE_HEADER_ALIGNMENT_TOLERANCE = 12; + private static readonly EXPERIENCE_HEADER_ACCEPTANCE_SCORE = 4; + private static readonly EXPERIENCE_HEADER_DESCRIPTION_LOOKAHEAD = 3; + private static readonly MIN_DESCRIPTION_LINE_LENGTH = 30; + private static readonly MIN_DESCRIPTION_CONTINUATION_CONTEXT_LENGTH = 20; + private static readonly DESCRIPTION_CONTINUATION_CONNECTOR_PATTERN = + /\b(?:and|at|by|for|from|in|of|on|the|their|to|with)$/i; + private static readonly WRAPPED_TITLE_KEYWORD_PATTERN = + /\b(?:advisor|analyst|associate|board|ceo|chief|co[-\s]?founder|cofounder|director|engineer|executive|fellow|founder|manager|member|partner|president|producer|scientist|vp)\b/iu; + private static readonly DURATION_WORD_PATTERN = + /\b(?:yr|yrs|year|years|mo|mos|month|months|jahr|jahre|ano|anos|mes|mês|meses)\b/iu; + private static readonly TOTAL_DURATION_LINE_PATTERN = + /^(?:less than a year|\d+\s+(?:yr|yrs|year|years|mo|mos|month|months|ano|anos|mes|mês|meses|jahr|jahre)(?:\s+\d+\s+(?:yr|yrs|year|years|mo|mos|month|months|ano|anos|mes|mês|meses|jahr|jahre))?)$/iu; + private static readonly MEDIA_DESCRIPTION_LINE_PATTERN = + /^(?:(?:directed|executive\s+produced|produced|written)\s+by\s+.+|(?:documentary|feature|short|television|tv|web)\s+(?:film|series|show))$/iu; + private static readonly ORGANIZATION_CONNECTOR_WORD_PATTERN = + /^(?:a|an|and|at|by|da|de|di|do|du|for|in|la|le|of|on|or|than|the|to|van|von|with|à)$/iu; + private static readonly COMBINED_ORGANIZATION_TITLE_LINE_PATTERN = + /^(.+\b(?:Agency|AG|Company|Corp\.?|Corporation|GmbH\.?|Inc\.?|Limited|LLC|LLP|LP|Ltd\.?))\s+(.+)$/iu; + private static readonly ORGANIZATION_SUFFIX_TITLE_FRAGMENT_PATTERN = + /^(?:Agency|AG|Co\.?|Company|Corp\.?|Corporation|GmbH\.?|Inc\.?|Limited|LLC|LLP|LP|Ltd\.?)$/iu; + private static readonly COMMA_SEPARATED_ORGANIZATION_SUFFIXES: ReadonlySet = + new Set([ + 'company', + 'corp', + 'corporation', + 'gmbh', + 'inc', + 'labs', + 'llc', + 'llp', + 'lp', + 'ltd', + 'partners', + 'solutions', + 'systems', + 'technologies', + 'technology', + 'ventures', + ]); + static parseExperience( + textItems: TextItem[], + experienceStartY?: number, + experienceEndY?: number + ): WorkExperience[] { + return this.parseExperienceWithWarnings( + textItems, + experienceStartY, + experienceEndY + ).value; + } + + static parseExperienceWithWarnings( + textItems: TextItem[], + experienceStartY?: number, + experienceEndY?: number + ): ParsedSectionResult { + const layout = StructuralParser.detectLayout(textItems); + const initialStructuralLines = createStructuralLines({ + layout, + textItems, + }); + const hasSingleColumnMainCandidate = initialStructuralLines.some( + line => line.column === 'single' && line.x >= 150 + ); + const hasLeftSingleColumnExperienceHeader = initialStructuralLines.some( + line => + line.column === 'single' && + line.x < 150 && + isExperienceSectionHeaderText(line.text) + ); + // Use the raw cutoff only when single-column detection has no left-side + // Experience section to preserve. + const structuralLines = + layout.type === 'single-column' && + hasSingleColumnMainCandidate && + !hasLeftSingleColumnExperienceHeader + ? createStructuralLines({ + layout, + textItems: textItems.filter(item => item.x >= 150), + }) + : initialStructuralLines; + let relevantLines = structuralLines.filter( + line => line.column === 'right' || line.column === 'single' + ); + + if (experienceStartY !== undefined && experienceEndY !== undefined) { + relevantLines = relevantLines.filter( + line => line.y < experienceStartY && line.y > experienceEndY + ); + } + + const lines = this.extractExperienceStructuralLines(relevantLines); + const parserLines = createGroupedTextItemParserLines( + lines.map(line => { + return { + column: line.column, + fontSize: line.fontSize, + text: line.text, + x: line.x, + y: line.y, + }; + }) + ); + + // Classify each line + const classifiedSections = this.classifyLines(parserLines); + + // Build work experiences + const workExperiences = this.buildWorkExperiences(classifiedSections); + + return { + value: workExperiences, + warnings: this.createExperienceWarnings(workExperiences), + }; + } + + private static extractExperienceStructuralLines( + lines: StructuralLine[] + ): StructuralLine[] { + const experienceStartIndex = lines.findIndex(line => + isExperienceSectionHeaderText(line.text) + ); + + if (experienceStartIndex === -1) { + return lines; + } + + const educationStartOffset = lines + .slice(experienceStartIndex + 1) + .findIndex(line => isEducationSectionHeaderText(line.text)); + const experienceEndIndex = + educationStartOffset === -1 + ? lines.length + : experienceStartIndex + 1 + educationStartOffset; + + return lines.slice(experienceStartIndex + 1, experienceEndIndex); + } + + private static classifyLines( + parserLines: NormalizedParserLine[] + ): StructuralSection[] { + const sections: StructuralSection[] = []; + const normalizedParserLines = this.mergeWrappedHeaderLines(parserLines); + const expandedParserLines = this.expandCombinedOrganizationTitleLines( + normalizedParserLines + ); + const canonicalHeaderLineTypes = + this.createCanonicalHeaderLineTypes(expandedParserLines); + let state: ExperienceLineState = 'seeking_company'; + + for (let index = 0; index < expandedParserLines.length; index++) { + const parserLine = expandedParserLines[index]; + const line = parserLine.text; + + if (!line.trim() || line.length < 2) continue; + + const fontSize = parserLine.fontSize ?? 0; + const y = parserLine.y ?? 0; + + const section: StructuralSection = { + type: 'other', + text: line.trim(), + fontSize, + y, + confidence: 0, + }; + + section.type = + canonicalHeaderLineTypes.get(index) ?? + this.classifyLineType({ + allLines: expandedParserLines, + index, + line: parserLine, + state, + }); + state = this.nextState(state, section.type); + section.confidence = this.calculateConfidence( + line, + section.type, + fontSize + ); + + sections.push(section); + } + + return sections; + } + + private static mergeWrappedHeaderLines( + parserLines: NormalizedParserLine[] + ): NormalizedParserLine[] { + const mergedParserLines: NormalizedParserLine[] = []; + + for (let index = 0; index < parserLines.length; index++) { + const parserLine = parserLines[index]; + const nextLine = parserLines[index + 1]; + + if (!nextLine) { + mergedParserLines.push({ + ...parserLine, + index: mergedParserLines.length, + }); + continue; + } + + const combinedText = `${parserLine.text} ${nextLine.text}`.replace( + /\s+/g, + ' ' + ); + + if ( + this.shouldMergeWrappedPositionTitle({ + allLines: parserLines, + combinedText, + index, + line: parserLine, + nextLine, + }) || + this.shouldMergeWrappedOrganization({ + allLines: parserLines, + combinedText, + index, + line: parserLine, + nextLine, + }) + ) { + mergedParserLines.push( + this.createMergedParserLine({ + combinedText, + index: mergedParserLines.length, + line: parserLine, + nextLine, + }) + ); + index++; + continue; + } + + mergedParserLines.push({ + ...parserLine, + index: mergedParserLines.length, + }); + } + + return mergedParserLines; + } + + private static shouldMergeWrappedPositionTitle({ + allLines, + combinedText, + index, + line, + nextLine, + }: WrappedParserLineMergeParams): boolean { + const followingLine = this.nextContentLine(allLines, index + 2); + + return ( + followingLine !== undefined && + this.haveComparableHeaderFonts(line, nextLine) && + this.WRAPPED_TITLE_KEYWORD_PATTERN.test(line.text) && + this.looksLikePendingTitleContinuationLine(nextLine.text) && + this.looksLikeDuration(followingLine.text) && + this.looksLikePotentialPositionTitleLine(combinedText) + ); + } + + private static shouldMergeWrappedOrganization({ + allLines, + combinedText, + index, + line, + nextLine, + }: WrappedParserLineMergeParams): boolean { + const titleLine = this.nextContentLine(allLines, index + 2); + const durationLine = titleLine + ? this.nextContentLine(allLines, titleLine.index + 1) + : undefined; + const hasWrappedOrganizationShape = + this.looksLikeLongAcademicOrganizationHeaderText(combinedText) || + this.looksLikeWrappedOrganizationHeaderText(combinedText); + + return ( + titleLine !== undefined && + durationLine !== undefined && + this.haveComparableHeaderFonts(line, nextLine) && + !this.looksLikeDuration(line.text) && + !this.looksLikeDuration(nextLine.text) && + !this.looksLikePosition(line.text) && + !this.looksLikeLocation(line.text) && + hasWrappedOrganizationShape && + (this.looksLikePosition(titleLine.text) || + this.looksLikePotentialPositionTitleLine(titleLine.text)) && + this.looksLikeDuration(durationLine.text) + ); + } + + private static nextContentLine( + parserLines: NormalizedParserLine[], + startIndex: number + ): NormalizedParserLine | undefined { + return parserLines + .slice(startIndex) + .find(line => !this.isExperienceNoiseLine(line.text)); + } + + private static haveComparableHeaderFonts( + firstLine: NormalizedParserLine, + secondLine: NormalizedParserLine + ): boolean { + if (firstLine.fontSize === undefined || secondLine.fontSize === undefined) { + return true; + } + + return Math.abs(firstLine.fontSize - secondLine.fontSize) <= 0.75; + } + + private static createMergedParserLine({ + combinedText, + index, + line, + nextLine, + }: MergedParserLineParams): NormalizedParserLine { + return { + ...line, + fontSize: + line.fontSize !== undefined && nextLine.fontSize !== undefined + ? Math.max(line.fontSize, nextLine.fontSize) + : (line.fontSize ?? nextLine.fontSize), + index, + text: combinedText, + }; + } + + private static expandCombinedOrganizationTitleLines( + parserLines: NormalizedParserLine[] + ): NormalizedParserLine[] { + const expandedParserLines: NormalizedParserLine[] = []; + + for (let index = 0; index < parserLines.length; index++) { + const parserLine = parserLines[index]; + const splitLine = this.splitCombinedOrganizationTitleLine({ + line: parserLine.text, + nextLine: parserLines[index + 1]?.text, + }); + + if (!splitLine) { + expandedParserLines.push({ + ...parserLine, + index: expandedParserLines.length, + }); + continue; + } + + expandedParserLines.push({ + ...parserLine, + index: expandedParserLines.length, + text: splitLine.organization, + }); + expandedParserLines.push({ + ...parserLine, + index: expandedParserLines.length, + text: splitLine.title, + }); + } + + return expandedParserLines; + } + + private static createCanonicalHeaderLineTypes( + parserLines: NormalizedParserLine[] + ): Map { + const lineTypes = new Map(); + const lineTexts = parserLines.map(line => line.text); + + for (let index = 0; index < parserLines.length; index++) { + if (lineTypes.has(index)) { + continue; + } + + const candidate = this.createExperienceHeaderCandidate( + parserLines, + index, + lineTexts + ); + + if ( + !candidate || + candidate.score < this.EXPERIENCE_HEADER_ACCEPTANCE_SCORE + ) { + continue; + } + + lineTypes.set(candidate.organizationLine.index, 'organization'); + + if (candidate.totalDurationLine) { + lineTypes.set(candidate.totalDurationLine.index, 'duration'); + } + + lineTypes.set(candidate.titleLine.index, 'position'); + lineTypes.set(candidate.durationLine.index, 'duration'); + + if (candidate.locationLine) { + lineTypes.set(candidate.locationLine.index, 'location'); + } + } + + return lineTypes; + } + + private static createExperienceHeaderCandidate( + parserLines: NormalizedParserLine[], + index: number, + lineTexts: string[] + ): ExperienceHeaderCandidate | undefined { + const organizationLine = parserLines[index]; + + if ( + !organizationLine || + !this.canStartCanonicalExperienceHeader(organizationLine.text) + ) { + return undefined; + } + + const firstDetailLine = this.nextContentLine(parserLines, index + 1); + + if (!firstDetailLine) { + return undefined; + } + + if (this.looksLikeTotalDuration(firstDetailLine.text)) { + return this.createMultiPositionHeaderCandidate({ + lineTexts, + organizationLine, + parserLines, + totalDurationLine: firstDetailLine, + }); + } + + return this.createSinglePositionHeaderCandidate({ + lineTexts, + organizationLine, + parserLines, + titleLine: firstDetailLine, + }); + } + + private static createSinglePositionHeaderCandidate({ + lineTexts, + organizationLine, + parserLines, + titleLine, + }: { + lineTexts: string[]; + organizationLine: NormalizedParserLine; + parserLines: NormalizedParserLine[]; + titleLine: NormalizedParserLine; + }): ExperienceHeaderCandidate | undefined { + if (!this.looksLikeCanonicalHeaderTitleLine(titleLine, lineTexts)) { + return undefined; + } + + const durationLine = this.nextContentLine(parserLines, titleLine.index + 1); + + if (!durationLine || !this.looksLikeDuration(durationLine.text)) { + return undefined; + } + + const locationLine = this.canonicalHeaderLocationLine({ + durationLine, + lineTexts, + parserLines, + }); + const candidate: ExperienceHeaderCandidate = { + ...(locationLine ? { locationLine } : {}), + durationLine, + organizationLine, + score: 0, + titleLine, + }; + + return { + ...candidate, + score: this.scoreExperienceHeaderCandidate(candidate, parserLines), + }; + } + + private static createMultiPositionHeaderCandidate({ + lineTexts, + organizationLine, + parserLines, + totalDurationLine, + }: { + lineTexts: string[]; + organizationLine: NormalizedParserLine; + parserLines: NormalizedParserLine[]; + totalDurationLine: NormalizedParserLine; + }): ExperienceHeaderCandidate | undefined { + const titleLine = this.nextContentLine( + parserLines, + totalDurationLine.index + 1 + ); + + if ( + !titleLine || + !this.looksLikeCanonicalHeaderTitleLine(titleLine, lineTexts) + ) { + return undefined; + } + + const durationLine = this.nextContentLine(parserLines, titleLine.index + 1); + + if (!durationLine || !this.looksLikeDuration(durationLine.text)) { + return undefined; + } + + const locationLine = this.canonicalHeaderLocationLine({ + durationLine, + lineTexts, + parserLines, + }); + const candidate: ExperienceHeaderCandidate = { + ...(locationLine ? { locationLine } : {}), + durationLine, + organizationLine, + score: 0, + titleLine, + totalDurationLine, + }; + + return { + ...candidate, + score: this.scoreExperienceHeaderCandidate(candidate, parserLines), + }; + } + + private static canStartCanonicalExperienceHeader(line: string): boolean { + const normalizedLine = line.trim(); + + if ( + normalizedLine.length < 2 || + /^[-+*•]/u.test(normalizedLine) || + (/[.?]$/.test(normalizedLine) && + !/\b(?:co|corp|gmbh|inc|llc|ltd)\.$/i.test(normalizedLine)) || + normalizedLine.includes('@') || + /https?:\/\//iu.test(normalizedLine) || + this.looksLikeDuration(normalizedLine) || + this.looksLikeMediaDescriptionLine(normalizedLine) || + this.looksLikeSentenceLikeDescriptionText(normalizedLine) || + isSectionHeaderText(normalizedLine) + ) { + return false; + } + + const isLongAcademicOrganization = + this.looksLikeLongAcademicOrganizationHeaderText(normalizedLine); + const isWrappedOrganization = + this.looksLikeWrappedOrganizationHeaderText(normalizedLine); + + if ( + normalizedLine.length > 90 && + !isLongAcademicOrganization && + !isWrappedOrganization + ) { + return false; + } + + const isKnownLowercaseOrganization = + this.looksLikeKnownLowercaseOrganization(normalizedLine); + const isLowerCamelOrganization = + this.looksLikeLowerCamelOrganization(normalizedLine); + + if ( + /^[a-z]/u.test(normalizedLine) && + !isKnownLowercaseOrganization && + !isLowerCamelOrganization + ) { + return false; + } + + if ( + this.looksLikePosition(normalizedLine) && + !this.hasExplicitOrganizationCueText(normalizedLine) + ) { + return false; + } + + return ( + !this.looksLikeLocation(normalizedLine) || + this.hasExplicitOrganizationCueText(normalizedLine) + ); + } + + private static looksLikeCanonicalHeaderTitleLine( + line: NormalizedParserLine, + allLines: string[] + ): boolean { + const normalizedLine = line.text.trim(); + + return ( + (this.looksLikePosition(normalizedLine) || + this.looksLikePotentialPositionTitleLine(normalizedLine)) && + !this.looksLikeOrganizationBoundaryCandidate( + normalizedLine, + line.index, + allLines + ) + ); + } + + private static canonicalHeaderLocationLine({ + durationLine, + lineTexts, + parserLines, + }: { + durationLine: NormalizedParserLine; + lineTexts: string[]; + parserLines: NormalizedParserLine[]; + }): NormalizedParserLine | undefined { + const possibleLocationLine = this.nextContentLine( + parserLines, + durationLine.index + 1 + ); + + if (!possibleLocationLine) { + return undefined; + } + + const text = possibleLocationLine.text; + + if ( + this.canStartCanonicalExperienceHeader(text) && + (this.hasImmediateTitleAndDurationAfterOrganization( + possibleLocationLine.index, + lineTexts + ) || + this.hasTotalDurationThenPosition( + possibleLocationLine.index, + lineTexts + )) + ) { + return undefined; + } + + return this.looksLikeLocation(text) || + classifyLocationText({ + context: { structuralContext: 'after-duration' }, + text, + }).isLocation + ? possibleLocationLine + : undefined; + } + + private static scoreExperienceHeaderCandidate( + candidate: ExperienceHeaderCandidate, + parserLines: NormalizedParserLine[] + ): number { + const organizationText = candidate.organizationLine.text.trim(); + // Canonical construction is already high-confidence; scoring starts at the + // acceptance threshold, adds confirming layout/text signals, and mainly + // downgrades person-shaped false positives without explicit organization cues. + let score = 4; + + if (this.hasAlignedHeaderGeometry(candidate)) { + score += 2; + } + + if (this.hasProminentOrganizationFont(candidate)) { + score += 1; + } + + if (this.hasOrganizationHeaderShape(organizationText)) { + score += 1; + } + + if (this.descriptionMentionsOrganization(candidate, parserLines)) { + score += 1; + } + + if ( + looksLikePersonNameText(organizationText) && + !this.hasExplicitOrganizationCueText(organizationText) + ) { + score -= 3; + } + + return score; + } + + private static hasAlignedHeaderGeometry( + candidate: ExperienceHeaderCandidate + ): boolean { + const headerLines = this.headerGeometryLines(candidate); + const organizationX = candidate.organizationLine.x; + const hasAlignedX = + organizationX !== undefined && + headerLines.every( + line => + line.x !== undefined && + Math.abs(line.x - organizationX) <= + this.EXPERIENCE_HEADER_ALIGNMENT_TOLERANCE + ); + const knownColumns = headerLines + .map(line => line.column) + .filter(column => column !== undefined); + const firstColumn = knownColumns[0]; + const hasSameColumn = + firstColumn === undefined || + knownColumns.every(column => column === firstColumn); + + return hasAlignedX && hasSameColumn; + } + + private static hasProminentOrganizationFont( + candidate: ExperienceHeaderCandidate + ): boolean { + const organizationFontSize = candidate.organizationLine.fontSize; + + if (organizationFontSize === undefined) { + return false; + } + + return this.headerGeometryLines(candidate) + .filter(line => line !== candidate.organizationLine) + .every( + line => + line.fontSize !== undefined && organizationFontSize >= line.fontSize + ); + } + + private static headerGeometryLines( + candidate: ExperienceHeaderCandidate + ): NormalizedParserLine[] { + return [ + candidate.organizationLine, + ...(candidate.totalDurationLine ? [candidate.totalDurationLine] : []), + candidate.titleLine, + candidate.durationLine, + ]; + } + + private static hasOrganizationHeaderShape(text: string): boolean { + return ( + this.hasExplicitOrganizationCueText(text) || + this.looksLikeVisualOrganizationHeaderText(text) + ); + } + + private static hasExplicitOrganizationCueText(text: string): boolean { + const normalizedLine = text.trim(); + + return ( + this.looksLikeKnownLowercaseOrganization(normalizedLine) || + /\bMarine Corps\b/u.test(normalizedLine) || + this.looksLikeLowerCamelOrganization(normalizedLine) || + this.looksLikeLongAcademicOrganizationHeaderText(normalizedLine) || + this.looksLikeWrappedOrganizationHeaderText(normalizedLine) || + this.hasOrganizationDomainCueText(normalizedLine) || + this.hasOrganizationSuffixText(normalizedLine) || + looksLikeOrganizationNameText(normalizedLine) || + /\bthan\b/iu.test(normalizedLine) || + /[&|–-]/u.test(normalizedLine) || + /\b[A-Z]{2,}\b/u.test(normalizedLine) + ); + } + + private static descriptionMentionsOrganization( + candidate: ExperienceHeaderCandidate, + parserLines: NormalizedParserLine[] + ): boolean { + const organizationLookupText = this.normalizeHeaderLookupText( + candidate.organizationLine.text + ); + + if (organizationLookupText.length < 4) { + return false; + } + + let checkedLineCount = 0; + let currentIndex = + (candidate.locationLine ?? candidate.durationLine).index + 1; + + while ( + currentIndex < parserLines.length && + checkedLineCount < this.EXPERIENCE_HEADER_DESCRIPTION_LOOKAHEAD + ) { + const nextLine = parserLines[currentIndex]; + const text = nextLine.text; + + if (this.isExperienceNoiseLine(text)) { + currentIndex++; + continue; + } + + if (this.looksLikeDuration(text) || isSectionHeaderText(text)) { + break; + } + + if ( + this.normalizeHeaderLookupText(text).includes(organizationLookupText) + ) { + return true; + } + + checkedLineCount++; + currentIndex++; + } + + return false; + } + + private static normalizeHeaderLookupText(text: string): string { + return text + .normalize('NFKD') + .replace(/\p{M}+/gu, '') + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); + } + + private static splitCombinedOrganizationTitleLine({ + line, + nextLine, + }: CombinedOrganizationTitleLineParams): + | CombinedOrganizationTitleLine + | undefined { + if (!nextLine || !this.looksLikeDuration(nextLine)) { + return undefined; + } + + const normalizedLine = line.trim(); + const match = normalizedLine.match( + this.COMBINED_ORGANIZATION_TITLE_LINE_PATTERN + ); + + if (!match) { + return undefined; + } + + const organization = match[1].trim(); + const title = match[2].trim(); + + if ( + !this.looksLikeVisualOrganizationHeaderText(organization) || + this.looksLikeOrganizationSuffixText(title) || + looksLikeOrganizationNameText(title) || + (!this.looksLikePosition(title) && + !this.looksLikePotentialPositionTitleLine(title)) + ) { + return undefined; + } + + return { + organization, + title, + }; + } + + private static looksLikeOrganizationSuffixText(text: string): boolean { + return this.ORGANIZATION_SUFFIX_TITLE_FRAGMENT_PATTERN.test(text.trim()); + } + + private static hasOrganizationSuffixText(text: string): boolean { + return text + .split(/\s+/) + .some(word => + this.looksLikeOrganizationSuffixText(word.replace(/^[,]+|[,]+$/g, '')) + ); + } + + private static hasOrganizationDomainCueText(text: string): boolean { + return /\b(?:AI|Coalition|Connections|Federation|Forex|Labs?|Network|Robotics|Services?|Ventures?)\b/u.test( + text + ); + } + + private static classifyLineType({ + allLines, + index, + line, + state, + }: { + allLines: NormalizedParserLine[]; + index: number; + line: NormalizedParserLine; + state: ExperienceLineState; + }): StructuralSection['type'] { + const text = line.text; + const lowerLine = text.toLowerCase(); + + // Skip section headers + if ( + lowerLine === 'experience' || + lowerLine === 'experiência' || + this.isExperienceNoiseLine(text) + ) { + return 'other'; + } + + const lineTexts = allLines.map(candidate => candidate.text); + + switch (state) { + case 'seeking_company': + return this.looksLikeOrganization( + text, + line.fontSize ?? 0, + index, + lineTexts, + { allowPersonLikeName: false } + ) + ? 'organization' + : this.fallbackLineType(text, line.fontSize ?? 0, index, lineTexts); + case 'seeking_title': + if (this.looksLikeDuration(text)) { + return 'duration'; + } + + if ( + this.looksLikePosition(text) || + this.looksLikeLoosePositionTitle(text, index, lineTexts) + ) { + return 'position'; + } + + if ( + this.looksLikeOrganization( + text, + line.fontSize ?? 0, + index, + lineTexts, + { allowPersonLikeName: false } + ) + ) { + return 'organization'; + } + + return this.fallbackLineType( + text, + line.fontSize ?? 0, + index, + lineTexts + ); + case 'seeking_dates': + if (this.looksLikeOrganizationBeforePosition(text, index, lineTexts)) { + return 'organization'; + } + + if (this.looksLikeDuration(text)) { + return 'duration'; + } + + if ( + this.looksLikeLocation(text) || + this.looksLikeStandaloneLocationAfterDuration(text, index, lineTexts) + ) { + return 'location'; + } + + if (this.looksLikeWrappedTitleContinuation(text, index, lineTexts)) { + return 'description'; + } + + if ( + this.looksLikeOrganization( + text, + line.fontSize ?? 0, + index, + lineTexts, + { allowPersonLikeName: false } + ) + ) { + return 'organization'; + } + + if (this.looksLikePosition(text)) { + return 'position'; + } + + return text.length > 15 ? 'description' : 'other'; + case 'in_description': + if (this.isExperienceNoiseLine(text)) { + return 'other'; + } + + if (this.looksLikeOrganizationBeforePosition(text, index, lineTexts)) { + return 'organization'; + } + + if ( + this.looksLikeOrganization( + text, + line.fontSize ?? 0, + index, + lineTexts, + { allowPersonLikeName: true } + ) && + this.hasImmediateTitleAndDurationAfterOrganization(index, lineTexts) + ) { + return 'organization'; + } + + if (this.looksLikeDuration(text)) { + return 'duration'; + } + + if ( + this.looksLikeLocation(text) || + this.looksLikeStandaloneLocationAfterDuration(text, index, lineTexts) + ) { + return 'location'; + } + + if ( + (this.looksLikePosition(text) || + this.looksLikeLoosePositionTitle(text, index, lineTexts)) && + this.hasOwnDurationBeforeBoundary(index, lineTexts) + ) { + return 'position'; + } + + if ( + this.looksLikeSentenceEndingDescriptionContinuationLine( + text, + lineTexts[index - 1] ?? undefined + ) + ) { + return 'description'; + } + + if ( + this.looksLikeDescriptionLine({ + allLines: lineTexts, + index, + line: text, + previousLine: lineTexts[index - 1], + }) && + (!this.hasOwnDurationBeforeBoundary(index, lineTexts) || + text.length > this.MIN_DESCRIPTION_LINE_LENGTH) + ) { + return 'description'; + } + + if ( + this.looksLikeOrganization( + text, + line.fontSize ?? 0, + index, + lineTexts, + { allowPersonLikeName: true } + ) + ) { + return 'organization'; + } + + if ( + this.looksLikeDescriptionContinuationLine( + text, + lineTexts[index - 1] ?? undefined + ) + ) { + return 'description'; + } + + if ( + this.looksLikeDescriptionLine({ + allLines: lineTexts, + index, + line: text, + previousLine: lineTexts[index - 1], + }) + ) { + return 'description'; + } + + return 'other'; + } + } + + private static nextState( + currentState: ExperienceLineState, + sectionType: StructuralSection['type'] + ): ExperienceLineState { + switch (sectionType) { + case 'organization': + return 'seeking_title'; + case 'position': + return 'seeking_dates'; + case 'duration': + case 'location': + case 'description': + return currentState === 'seeking_company' + ? 'seeking_company' + : 'in_description'; + case 'other': + return currentState; + } + } + + private static fallbackLineType( + line: string, + fontSize: number, + index: number, + allLines: string[] + ): StructuralSection['type'] { + if (this.looksLikeDuration(line)) { + return 'duration'; + } + + if (this.looksLikeLocation(line)) { + return 'location'; + } + + if (this.looksLikeOrganization(line, fontSize, index, allLines)) { + return 'organization'; + } + + if (this.looksLikePosition(line)) { + return 'position'; + } + + return line.length > this.MIN_DESCRIPTION_LINE_LENGTH + ? 'description' + : 'other'; + } + + private static looksLikeOrganization( + line: string, + fontSize: number, + index: number, + allLines: string[], + options: { allowPersonLikeName: boolean } = { allowPersonLikeName: false } + ): boolean { + const normalizedLine = line.trim(); + const isKnownLowercaseOrganization = + this.looksLikeKnownLowercaseOrganization(normalizedLine); + const isLowerCamelOrganization = + this.looksLikeLowerCamelOrganization(normalizedLine); + const isLongAcademicOrganization = + this.looksLikeLongAcademicOrganizationHeaderText(normalizedLine); + const isWrappedOrganization = + this.looksLikeWrappedOrganizationHeaderText(normalizedLine); + const hasJobDetailsAfter = + this.hasJobDetailsAfterOrganization(index, allLines) || + this.hasImmediateTitleAndDurationAfterOrganization(index, allLines, 4) || + this.hasTotalDurationThenPosition(index, allLines, 5); + const hasVisualOrganizationCue = + isKnownLowercaseOrganization || + isLowerCamelOrganization || + isLongAcademicOrganization || + isWrappedOrganization || + this.hasOrganizationDomainCueText(normalizedLine) || + this.hasOrganizationSuffixText(normalizedLine) || + /\bthan\b/i.test(normalizedLine) || + /[&|–-]/u.test(normalizedLine) || + /\b[A-Z]{2,}\b/.test(normalizedLine); + + if ( + (normalizedLine.length > 80 && + !isLongAcademicOrganization && + !isWrappedOrganization) || + /^[-+*•]/u.test(normalizedLine) || + (/[.?]$/.test(normalizedLine) && + !/\b(?:co|corp|gmbh|inc|llc|ltd)\.$/i.test(normalizedLine)) || + (/^[a-z]/.test(normalizedLine) && + !isKnownLowercaseOrganization && + !isLowerCamelOrganization) || + this.looksLikeDuration(normalizedLine) || + (!hasJobDetailsAfter && this.looksLikeLocation(normalizedLine)) || + this.looksLikePosition(normalizedLine) || + this.looksLikeMediaDescriptionLine(normalizedLine) || + this.looksLikeSentenceLikeDescriptionText(normalizedLine) || + isSectionHeaderText(normalizedLine) || + (!options.allowPersonLikeName && + !hasVisualOrganizationCue && + looksLikePersonNameText(normalizedLine)) + ) { + return false; + } + + const hasOrganizationShape = + looksLikeOrganizationNameText(normalizedLine) || + isKnownLowercaseOrganization || + isLowerCamelOrganization || + isLongAcademicOrganization || + isWrappedOrganization || + ((options.allowPersonLikeName || hasVisualOrganizationCue) && + this.looksLikeVisualOrganizationHeaderText(normalizedLine)); + + return ( + hasJobDetailsAfter && + hasOrganizationShape && + (fontSize > 10 || normalizedLine.length <= 40) + ); + } + + private static looksLikeVisualOrganizationHeaderText(line: string): boolean { + const normalizedLine = line.trim(); + + if ( + normalizedLine.length < 2 || + normalizedLine.length > 80 || + normalizedLine.includes('@') || + normalizedLine.includes('•') || + /https?:\/\//i.test(normalizedLine) || + /^page\s+\d+\s+of\s+\d+$/i.test(normalizedLine) || + this.looksLikeDuration(normalizedLine) || + this.looksLikeLocation(normalizedLine) || + this.looksLikePosition(normalizedLine) || + this.looksLikeMediaDescriptionLine(normalizedLine) || + isSectionHeaderText(normalizedLine) + ) { + return false; + } + + const words = normalizedLine.split(/\s+/).filter(Boolean); + + return ( + words.length > 0 && + words.length <= 8 && + words.every( + word => + this.ORGANIZATION_CONNECTOR_WORD_PATTERN.test(word) || + this.looksLikeOrganizationSuffixText(word) || + /^&$/u.test(word) || + /^[-–]$/u.test(word) || + /^\([\p{Lu}0-9&.'+!–-]+\)$/u.test(word) || + /^\([a-z0-9.-]+\.[a-z0-9.-]+\)$/iu.test(word) || + /^[\p{Lu}0-9][\p{L}\p{M}0-9&.'+!–-]*$/u.test(word) + ) + ); + } + + private static looksLikeWrappedOrganizationHeaderText(line: string): boolean { + const normalizedLine = line.trim(); + const hasPipeSeparator = normalizedLine.includes('|'); + + if ( + normalizedLine.length < 12 || + normalizedLine.length > 140 || + normalizedLine.includes('@') || + normalizedLine.includes('•') || + /https?:\/\//i.test(normalizedLine) || + /^page\s+\d+\s+of\s+\d+$/i.test(normalizedLine) || + this.looksLikeDuration(normalizedLine) || + this.looksLikeLocation(normalizedLine) || + this.looksLikePosition(normalizedLine) || + this.looksLikeMediaDescriptionLine(normalizedLine) || + this.looksLikeSentenceLikeDescriptionText(normalizedLine) || + isSectionHeaderText(normalizedLine) + ) { + return false; + } + + const words = normalizedLine.split(/\s+/).filter(Boolean); + const hasOrganizationCue = + hasPipeSeparator || this.hasOrganizationSuffixText(normalizedLine); + + return ( + hasOrganizationCue && + words.length >= 3 && + words.length <= 18 && + words.every( + word => + this.ORGANIZATION_CONNECTOR_WORD_PATTERN.test(word) || + this.looksLikeOrganizationSuffixText(word) || + /^&$/u.test(word) || + /^\|$/u.test(word) || + /^[-–]$/u.test(word) || + /^\([\p{L}\p{M}0-9&.'+!–-]+\)$/u.test(word) || + /^[\p{Lu}0-9][\p{L}\p{M}0-9&.'+!–-]*$/u.test(word) || + (hasPipeSeparator && /^[\p{Ll}\p{M}]+$/u.test(word)) + ) + ); + } + + private static looksLikeLongAcademicOrganizationHeaderText( + line: string + ): boolean { + const normalizedLine = line.trim(); + + if ( + normalizedLine.length < 12 || + normalizedLine.length > 120 || + normalizedLine.includes('@') || + normalizedLine.includes('•') || + /https?:\/\//i.test(normalizedLine) || + /^page\s+\d+\s+of\s+\d+$/i.test(normalizedLine) || + this.looksLikeDuration(normalizedLine) || + this.looksLikeLocation(normalizedLine) || + this.looksLikePosition(normalizedLine) || + this.looksLikeMediaDescriptionLine(normalizedLine) || + isSectionHeaderText(normalizedLine) + ) { + return false; + } + + const words = normalizedLine.split(/\s+/).filter(Boolean); + const hasAcademicOrganizationWord = words.some(word => + /^(?:college|laboratory|lab|school|sciences?|university|institute)$/iu.test( + word.replace(/[(),.]+/g, '') + ) + ); + + return ( + hasAcademicOrganizationWord && + words.length >= 3 && + words.length <= 14 && + words.every( + word => + this.ORGANIZATION_CONNECTOR_WORD_PATTERN.test(word) || + /^\([\p{L}\p{M}0-9&.'+!–-]+\)$/u.test(word) || + /^[\p{Lu}0-9][\p{L}\p{M}0-9&.'+!–-]*$/u.test(word) + ) + ); + } + + private static looksLikePosition(line: string): boolean { + const normalizedLine = line.trim(); + + if (/^venture$/iu.test(normalizedLine)) { + // Real profiles use standalone "Venture" as a role; it is too broad for POSITION_KEYWORDS. + return true; + } + + return ( + !/^[-+*•]/u.test(normalizedLine) && + looksLikePositionTitleText(normalizedLine) && + !this.looksLikeDuration(normalizedLine) && + !this.looksLikeLocation(normalizedLine) + ); + } + + private static looksLikeKnownLowercaseOrganization(line: string): boolean { + return /^(?:self-employed)$/i.test(line.trim()); + } + + private static looksLikeLowerCamelOrganization(line: string): boolean { + return ( + /^[a-z][\p{Lu}][\p{L}\p{M}0-9&.'+-]*/u.test(line) && + /\b(?:Inc|LLC|Ltd|Solutions|Systems|Technologies)\b/iu.test(line) + ); + } + + private static looksLikeOrganizationBeforePosition( + line: string, + index: number, + allLines: string[] + ): boolean { + const normalizedLine = line.trim(); + const isLongAcademicOrganization = + this.looksLikeLongAcademicOrganizationHeaderText(normalizedLine); + const hasFollowingPosition = + this.hasImmediateTitleAndDurationAfterOrganization(index, allLines) || + this.hasTotalDurationThenPosition(index, allLines); + const hasLocationShape = this.looksLikeLocation(normalizedLine); + // Organization-name shape is useful only after location-shaped text is excluded; + // stronger cues below can still identify academic, camel-case, domain, suffix, or wrapped organization names. + const hasNonLocationOrganizationNameShape = + !hasLocationShape && looksLikeOrganizationNameText(normalizedLine); + const hasOrganizationCue = + isLongAcademicOrganization || + this.looksLikeLowerCamelOrganization(normalizedLine) || + this.hasOrganizationDomainCueText(normalizedLine) || + this.hasOrganizationSuffixText(normalizedLine) || + hasNonLocationOrganizationNameShape || + this.looksLikeWrappedOrganizationHeaderText(normalizedLine); + + if ( + normalizedLine.length < 2 || + (normalizedLine.length > 90 && !isLongAcademicOrganization) || + (/^[a-z]/.test(normalizedLine) && + !this.looksLikeLowerCamelOrganization(normalizedLine)) || + /[.!?]$/.test(normalizedLine) || + normalizedLine.includes('@') || + /^[-+*•]/u.test(normalizedLine) || + isSectionHeaderText(normalizedLine) || + this.looksLikeDuration(normalizedLine) || + (hasLocationShape && (!hasFollowingPosition || !hasOrganizationCue)) || + this.looksLikeMediaDescriptionLine(normalizedLine) || + this.looksLikeSentenceLikeDescriptionText(normalizedLine) + ) { + return false; + } - if (experienceStartY !== undefined && experienceEndY !== undefined) { - relevantItems = relevantItems.filter(item => - item.y <= experienceStartY && item.y >= experienceEndY - ); + return hasFollowingPosition; + } + + private static looksLikeLoosePositionTitle( + line: string, + index: number, + allLines: string[] + ): boolean { + const normalizedLine = line.trim(); + + if (!this.hasOwnDurationBeforeBoundary(index, allLines)) { + return false; } - // Group text by proximity with smaller Y distance for better line separation - const groups = StructuralParser.groupTextByProximity(relevantItems, 3); - const lines = StructuralParser.combineGroupedText(groups); + return ( + !this.hasImmediateTitleAndDurationAfterOrganization(index, allLines) && + this.looksLikePotentialPositionTitleLine(normalizedLine) && + !looksLikeOrganizationNameText(normalizedLine) + ); + } - // Classify each line - const classifiedSections = this.classifyLines(lines, groups); + private static hasImmediateTitleAndDurationAfterOrganization( + index: number, + allLines: string[], + maxLookahead = 3 + ): boolean { + let possibleTitleIndex = index + 1; + + while ( + possibleTitleIndex < allLines.length && + possibleTitleIndex <= index + maxLookahead && + this.isExperienceNoiseLine(allLines[possibleTitleIndex]) + ) { + possibleTitleIndex++; + } - // Build work experiences - const workExperiences = this.buildWorkExperiences(classifiedSections); + const possibleTitle = allLines[possibleTitleIndex]; + + if ( + !possibleTitle || + this.looksLikeOrganizationBoundaryCandidate( + possibleTitle, + possibleTitleIndex, + allLines + ) || + (!this.looksLikePosition(possibleTitle) && + !this.looksLikePotentialPositionTitleLine(possibleTitle)) + ) { + return false; + } - return workExperiences; + return allLines + .slice(possibleTitleIndex + 1, index + 1 + maxLookahead) + .some(nextLine => this.looksLikeDuration(nextLine)); } - private static classifyLines(lines: string[], groups: TextItem[][]): StructuralSection[] { - const sections: StructuralSection[] = []; + private static hasJobDetailsAfterOrganization( + index: number, + allLines: string[], + maxLookahead = 4 + ): boolean { + for ( + let nextIndex = index + 1; + nextIndex < allLines.length && nextIndex <= index + maxLookahead; + nextIndex++ + ) { + const nextLine = allLines[nextIndex]; + + if (this.isExperienceNoiseLine(nextLine)) { + continue; + } + + if ( + this.looksLikeOrganizationBoundaryCandidate( + nextLine, + nextIndex, + allLines + ) + ) { + return false; + } - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const group = groups[i]; + if ( + this.looksLikeDuration(nextLine) || + this.looksLikePosition(nextLine) + ) { + return true; + } - if (!line.trim() || line.length < 2) continue; + if (!this.looksLikeLocation(nextLine)) { + return false; + } + } - // Calculate average font size for the line - const avgFontSize = group.reduce((sum, item) => sum + item.fontSize, 0) / group.length; - const avgY = group.reduce((sum, item) => sum + item.y, 0) / group.length; + return false; + } - const section: StructuralSection = { - type: 'other', - text: line.trim(), - fontSize: avgFontSize, - y: avgY, - confidence: 0, - }; + private static hasTotalDurationThenPosition( + index: number, + allLines: string[], + maxLookahead = 4 + ): boolean { + const nextLines = allLines.slice(index + 1, index + 1 + maxLookahead); - // Classify based on content and structure - section.type = this.classifyLineType(line, avgFontSize, i, lines); - section.confidence = this.calculateConfidence(line, section.type, avgFontSize); + if (!nextLines[0] || !this.looksLikeTotalDuration(nextLines[0])) { + return false; + } - sections.push(section); + const linesAfterTotalDuration = nextLines.slice(1); + const durationIndex = linesAfterTotalDuration.findIndex(nextLine => + this.looksLikeDuration(nextLine) + ); + + if (durationIndex === -1) { + return false; } - return sections; + return linesAfterTotalDuration + .slice(0, durationIndex) + .some(nextLine => this.looksLikePosition(nextLine)); } - private static classifyLineType( + private static hasOwnDurationBeforeBoundary( + index: number, + allLines: string[], + maxLookahead = 3 + ): boolean { + for ( + let nextIndex = index + 1; + nextIndex < allLines.length && nextIndex <= index + maxLookahead; + nextIndex++ + ) { + const nextLine = allLines[nextIndex]; + + if (this.isExperienceNoiseLine(nextLine)) { + continue; + } + + if ( + this.looksLikeOrganizationBoundaryCandidate( + nextLine, + nextIndex, + allLines + ) + ) { + return false; + } + + if (this.looksLikeDuration(nextLine)) { + return true; + } + + if (!this.looksLikeLocation(nextLine)) { + return false; + } + } + + return false; + } + + private static looksLikeOrganizationBoundaryCandidate( line: string, - fontSize: number, index: number, allLines: string[] - ): StructuralSection['type'] { - const lowerLine = line.toLowerCase(); + ): boolean { + const normalizedLine = line.trim(); + const isKnownLowercaseOrganization = + this.looksLikeKnownLowercaseOrganization(normalizedLine); + const isLowerCamelOrganization = + this.looksLikeLowerCamelOrganization(normalizedLine); + const isLongAcademicOrganization = + this.looksLikeLongAcademicOrganizationHeaderText(normalizedLine); + + if ( + normalizedLine.length < 2 || + (normalizedLine.length > 90 && !isLongAcademicOrganization) || + (/^[a-z]/.test(normalizedLine) && + !isKnownLowercaseOrganization && + !isLowerCamelOrganization) || + (/[.!?]$/.test(normalizedLine) && + !/\b(?:co|corp|gmbh|inc|llc|ltd)\.$/i.test(normalizedLine)) || + normalizedLine.includes('@') || + /^[-+*•]/u.test(normalizedLine) || + isSectionHeaderText(normalizedLine) || + this.looksLikeDuration(normalizedLine) || + this.looksLikeLocation(normalizedLine) || + this.looksLikePosition(normalizedLine) || + this.looksLikeMediaDescriptionLine(normalizedLine) || + this.looksLikeSentenceLikeDescriptionText(normalizedLine) + ) { + return false; + } - // Skip section headers - if (lowerLine.includes('experience') || lowerLine.includes('experiência')) { - return 'other'; + const hasOrganizationShape = + looksLikeOrganizationNameText(normalizedLine) || + isKnownLowercaseOrganization || + isLowerCamelOrganization || + isLongAcademicOrganization || + this.looksLikeVisualOrganizationHeaderText(normalizedLine); + + return ( + hasOrganizationShape && + (this.hasImmediateTitleAndDurationAfterOrganization(index, allLines) || + this.hasTotalDurationThenPosition(index, allLines)) + ); + } + + private static looksLikePotentialPositionTitleLine(line: string): boolean { + const normalizedLine = line.trim(); + + return ( + normalizedLine.length >= 3 && + normalizedLine.length < 90 && + normalizedLine.split(/\s+/).length <= 10 && + /^[\p{Lu}0-9]/u.test(normalizedLine) && + !/[.!?]$/.test(normalizedLine) && + !normalizedLine.includes('@') && + !/https?:\/\//i.test(normalizedLine) && + !this.isExperienceNoiseLine(normalizedLine) && + !this.looksLikeDuration(normalizedLine) && + !this.looksLikeLocation(normalizedLine) && + !this.looksLikeMediaDescriptionLine(normalizedLine) && + !isSectionHeaderText(normalizedLine) + ); + } + + private static looksLikeWrappedTitleContinuation( + line: string, + index: number, + allLines: string[] + ): boolean { + const nextLines = allLines.slice(index + 1, index + 4); + const durationIndex = nextLines.findIndex(nextLine => + this.looksLikeDuration(nextLine) + ); + + if (durationIndex === -1) { + return false; } - // Organization detection - usually larger font, short line, followed by duration or position - if (this.looksLikeOrganization(line, fontSize, index, allLines)) { - return 'organization'; + const linesBeforeDuration = nextLines.slice(0, durationIndex); + + return ( + !linesBeforeDuration.some(nextLine => this.looksLikePosition(nextLine)) && + this.looksLikePendingTitleContinuationLine(line) + ); + } + + private static looksLikeDuration(line: string): boolean { + const normalizedLine = this.normalizeDurationLineText(line); + + if (/^[+*•]/u.test(normalizedLine)) { + return false; } - // Duration detection - if (this.looksLikeDuration(line)) { - return 'duration'; + return ( + this.looksLikeWholeLineDateRangeText(normalizedLine) || + this.looksLikeTotalDurationText(normalizedLine) + ); + } + + private static looksLikeTotalDuration(line: string): boolean { + return this.looksLikeTotalDurationText(line); + } + + private static looksLikeWholeLineDateRangeText(line: string): boolean { + const normalizedLine = this.normalizeDurationLineText(line); + const dateRangeText = extractProfileDateRangeText(normalizedLine); + + if (!dateRangeText || !looksLikeDateRangeText(normalizedLine)) { + return false; } - // Position detection - job titles - if (this.looksLikePosition(line)) { - return 'position'; + const lineDatePortion = this.stripDurationSuffixText(normalizedLine); + + return ( + this.normalizeDurationLineText(dateRangeText) === + this.normalizeDurationLineText(lineDatePortion) + ); + } + + private static looksLikeTotalDurationText(line: string): boolean { + return this.TOTAL_DURATION_LINE_PATTERN.test( + this.normalizeDurationLineText(line) + ); + } + + private static normalizeDurationLineText(text: string): string { + return text + .replace(/[\uE000-\uF8FF]/g, ' ') + .replace(/\u00A0/g, ' ') + .replace(/[–—−]/g, '-') + .replace(/\s+/g, ' ') + .trim(); + } + + private static stripDurationSuffixText(text: string): string { + return this.normalizeDurationLineText(text) + .replace(/\s*[·|]\s*.*$/u, '') + .replace( + new RegExp( + `\\s*\\([^)]*(?:less\\s+than\\s+a\\s+year|${this.DURATION_WORD_PATTERN.source})[^)]*\\)\\s*$`, + 'iu' + ), + '' + ) + .trim(); + } + + private static isExperienceNoiseLine(line: string): boolean { + return /^page\s+\d+\s+of\s+\d+$/i.test(line.trim()); + } + + private static looksLikeDescriptionLine({ + allLines, + index, + line, + previousLine, + }: DescriptionLineParams): boolean { + const normalizedLine = line.trim(); + const normalizedPreviousLine = previousLine?.trim(); + + // Longer lines are usually prose, while short lines need continuation cues. + if (normalizedLine.length > this.MIN_DESCRIPTION_LINE_LENGTH) { + return true; } - // Location detection - if (this.looksLikeLocation(line)) { - return 'location'; + if (this.looksLikeMediaDescriptionLine(normalizedLine)) { + return true; + } + + if ( + normalizedPreviousLine && + this.looksLikeShortDescriptorLine(normalizedLine) && + !this.looksLikeShortDescriptorEntryHeader(normalizedLine, index, allLines) + ) { + return true; + } + + // Stock ticker fragments often appear in description text for public companies. + if (/\$[A-Z]{1,8}\b/.test(normalizedLine)) { + return true; + } + + if (/^[-+*•]\s*\S/u.test(normalizedLine)) { + return true; + } + + if ( + /^[\p{Lu}0-9][\p{L}\p{M}0-9\s&/+.'-]{1,45}:\s*(?:\S.*)?$/u.test( + normalizedLine + ) + ) { + return true; + } + + if (!normalizedPreviousLine) { + return false; + } + + if ( + this.looksLikeShortDescriptorEntryHeader(normalizedLine, index, allLines) + ) { + return false; + } + + // Short continuations rely on syntax: lowercase starts, sentence endings, or + // previous-line connector words that imply the sentence is not finished. + return ( + /^[a-z]/.test(normalizedLine) || + /[.!?]$/.test(normalizedLine) || + this.DESCRIPTION_CONTINUATION_CONNECTOR_PATTERN.test( + normalizedPreviousLine + ) + ); + } + + private static looksLikeSentenceEndingDescriptionContinuationLine( + line: string, + previousLine?: string + ): boolean { + const normalizedLine = line.trim(); + const normalizedPreviousLine = previousLine?.trim(); + + if ( + !normalizedPreviousLine || + normalizedPreviousLine.length < + this.MIN_DESCRIPTION_CONTINUATION_CONTEXT_LENGTH || + !/[.!?]$/.test(normalizedLine) + ) { + return false; } - // Description - everything else with substantial content - if (line.length > 30) { - return 'description'; + if ( + /[.!?]$/.test(normalizedPreviousLine) && + this.looksLikePosition(normalizedLine) + ) { + return false; } - return 'other'; + return ( + !this.looksLikeDuration(normalizedLine) && + !this.looksLikeLocation(normalizedLine) && + !looksLikeOrganizationNameText(normalizedLine) && + !this.looksLikeVisualOrganizationHeaderText(normalizedLine) + ); } - private static looksLikeOrganization( + private static looksLikeDescriptionContinuationLine( line: string, - fontSize: number, - index: number, - allLines: string[] + previousLine?: string ): boolean { - // Short line (likely company name) - if (line.length > 50) return false; - - // Look ahead for duration or position indicators - const nextFewLines = allLines.slice(index + 1, index + 4); - const hasJobDetailsAfter = nextFewLines.some(nextLine => - this.looksLikeDuration(nextLine) || - this.looksLikePosition(nextLine) || - /^\d+\s+(years?|months?|anos?|meses?)/.test(nextLine) - ); - - // Skip common section headers that aren't companies - const nonCompanyHeaders = [ - 'contact', 'top skills', 'strategic roadmaps', 'electronic engineering', - 'project planning', 'languages', 'summary', 'education', 'experience', - 'experiência', 'formação', 'idiomas', 'competências', 'habilidades' - ]; + const normalizedLine = line.trim(); + const normalizedPreviousLine = previousLine?.trim(); + + if (!normalizedPreviousLine) { + return false; + } - if (nonCompanyHeaders.some(header => - line.toLowerCase().includes(header) || line.toLowerCase() === header - )) { + if ( + this.DESCRIPTION_CONTINUATION_CONNECTOR_PATTERN.test( + normalizedPreviousLine + ) + ) { + return true; + } + + if ( + normalizedPreviousLine.length < + this.MIN_DESCRIPTION_CONTINUATION_CONTEXT_LENGTH + ) { return false; } - // Known companies or pattern matching - const knownCompanies = ['Carta', 'Boba Joy', 'Zestt', 'Guild', 'Liquido', 'Automox', 'AevoTech', 'Inovare', 'CEPEL', 'CPTI', 'Arena Games', 'PontoTel', 'Partiu']; - const foundKnownCompany = knownCompanies.find(company => - line.toLowerCase().includes(company.toLowerCase()) + return ( + /^[a-z]/.test(normalizedLine) || + this.looksLikeMediaDescriptionLine(normalizedLine) || + (/[.!?]$/.test(normalizedLine) && + !this.looksLikeDuration(normalizedLine) && + !this.looksLikeLocation(normalizedLine) && + !this.looksLikePosition(normalizedLine) && + !looksLikeOrganizationNameText(normalizedLine) && + !this.looksLikeVisualOrganizationHeaderText(normalizedLine)) ); + } + + private static looksLikeLocation(line: string): boolean { + const normalizedLine = this.normalizeCompletedLocationText(line); + const isAddressLocation = this.looksLikeAddressLocationText(normalizedLine); - // For known companies, the line should be just the company name (or very close to it) - if (foundKnownCompany) { - // Only accept if the line is primarily the company name (not mixed with other content) - const cleanLine = line.trim().toLowerCase(); - const companyName = foundKnownCompany.toLowerCase(); + if ( + /^[a-z]/.test(normalizedLine) && + !isLikelyLocationText(normalizedLine) + ) { + return false; + } - // Line should either be exactly the company name, or start/end with it and be short - const isCleanCompanyName = - cleanLine === companyName || - (cleanLine.startsWith(companyName) && line.length < companyName.length + 20) || - (cleanLine.endsWith(companyName) && line.length < companyName.length + 20) || - (line.length < 30 && cleanLine.includes(companyName)); + if ( + !isAddressLocation && + this.looksLikeCommaSeparatedProseText(normalizedLine) + ) { + return false; + } - return isCleanCompanyName && hasJobDetailsAfter; + if (this.looksLikeCommaSeparatedOrganizationName(normalizedLine)) { + return false; } - // Better company patterns - focus on actual business names - const companyPatterns = [ - /^[A-Z][A-Za-z\s&.,-]{2,25}$/, // Standard company names (shorter, cleaner) - /^[A-Z]{2,6}$/, // Acronyms (2-6 letters) - /^[A-Z][A-Za-z]+\s+(Inc|LLC|Ltd|Corp|Corporation|Company|Technologies|Tech|Solutions|Systems|Group|Labs|Studio)$/i, // Business with suffixes - ]; + const locationClassification = classifyLocationText({ + text: normalizedLine, + }); + const hasLocationShape = + isAddressLocation || locationClassification.isLocation; + + return ( + normalizedLine.length < 120 && + !looksLikePositionTitleText(normalizedLine) && + hasLocationShape && + !this.looksLikeDuration(normalizedLine) + ); + } + + private static looksLikeAddressLocationText(line: string): boolean { + return /^(?:Rua|R\.|Av\.?|Avenida|Alameda|Praça|Street|St\.|Avenue|Ave\.|Road|Rd\.)(?!\w)/iu.test( + line + ); + } - const matchesPattern = companyPatterns.some(pattern => pattern.test(line)); + private static looksLikeCommaSeparatedProseText(line: string): boolean { + const parts = line + .split(',') + .map(part => part.trim()) + .filter(Boolean); - // Font size hint - company names are often larger - const isLargerFont = fontSize > 11; + if (parts.length < 2) { + return false; + } - return matchesPattern && isLargerFont && hasJobDetailsAfter; + return parts.some(part => { + const words = part.split(/\s+/).filter(Boolean); + + return ( + words.length > 4 || + words.some(word => this.looksLikeNonLocationLowercaseWord(word)) + ); + }); } - private static looksLikePosition(line: string): boolean { - const positionKeywords = [ - // English titles - 'manager', 'engineer', 'director', 'lead', 'senior', 'principal', 'chief', 'head of', - 'co-founder', 'founder', 'president', 'vice president', 'vp', 'analyst', 'specialist', - 'developer', 'architect', 'consultant', 'coordinator', 'supervisor', 'specialist', - // Portuguese titles - 'gerente', 'diretor', 'coordenador', 'analista', 'especialista', 'consultor', - 'desenvolvedor', 'engenheiro', 'arquiteto', 'supervisor', 'assessor', 'gestor', - // Additional position indicators - 'product manager', 'software engineer', 'tech lead', 'technical lead', 'scrum master', - ]; + private static looksLikeNonLocationLowercaseWord(word: string): boolean { + const normalizedWord = word.replace(/^[("']+|[)"'.]+$/g, ''); - const lowerLine = line.toLowerCase(); - const hasPositionKeyword = positionKeywords.some(keyword => - lowerLine.includes(keyword) - ); - - // Avoid lines that are clearly durations or locations - const isDuration = this.looksLikeDuration(line); - const isLocation = this.looksLikeLocation(line); - - // Exclude lines that are clearly descriptions (too long, have sentence structure) - const isDescription = - line.length > 80 || // Too long for a job title - line.toLowerCase().startsWith('i ') || // Starts with "I" (personal statement) - line.toLowerCase().includes('i lead') || - line.toLowerCase().includes('i manage') || - line.toLowerCase().includes('i work') || - line.toLowerCase().includes('i was') || - line.toLowerCase().includes('responsible for') || - line.toLowerCase().includes('working as') || - line.toLowerCase().includes('joined the') || - line.toLowerCase().includes('my role') || - line.includes('•') || // Contains bullet points - line.includes('...') || // Continuation - line.split(' ').length > 15; // Too many words for a title - - // Must be a reasonable job title format - const hasValidTitleFormat = - line.length > 5 && // Not too short - line.length < 80 && // Not too long - !line.includes('(') && !line.includes(')') && // No parentheses - !line.includes('•') && // No bullets - !line.includes('http') && // No URLs - !line.includes('@') && // No email symbols - line.split(' ').length <= 12; // Reasonable word count - - return hasPositionKeyword && !isDuration && !isLocation && !isDescription && hasValidTitleFormat; + return ( + /^[\p{Ll}]/u.test(normalizedWord) && + !/^(?:al|and|da|das|de|del|der|di|do|dos|du|el|for|la|of|the|van|von)$/iu.test( + normalizedWord + ) + ); } - private static looksLikeDuration(line: string): boolean { - const durationPatterns = [ - // English patterns - /\b\d{4}\s*-\s*\d{4}\b/, - /\b\d{4}\s*-\s*(present|current)\b/i, - /\b(january|february|march|april|may|june|july|august|september|october|november|december)\s+\d{4}/i, - /\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\s+\d{4}/i, - /\(\d+\s+(years?|months?)\s*\d*\s*(months?)?\)/i, - /\d+\s+(years?|months?)\s+\d+\s+(months?|years?)/i, - // Portuguese patterns - /\b(janeiro|fevereiro|março|abril|maio|junho|julho|agosto|setembro|outubro|novembro|dezembro)\s+de\s+\d{4}/i, - /\(\d+\s+(anos?|meses?)\s*\d*\s*(meses?)?\)/i, - /\d+\s+(anos?|meses?)\s+\d+\s+(meses?|anos?)/i, - ]; + private static looksLikeStandaloneLocationAfterDuration( + line: string, + index: number, + allLines: string[] + ): boolean { + const previousLine = allLines[index - 1]; + + return ( + previousLine !== undefined && + this.looksLikeDuration(previousLine) && + classifyLocationText({ + context: { structuralContext: 'after-duration' }, + text: line, + }).isLocation + ); + } - return durationPatterns.some(pattern => pattern.test(line)); + private static normalizeLocationText(text: string): string { + return ( + text + .replace(/\bY\s+ork\b/g, 'York') + // Strip trailing US/USA variants from Greater/Metro Area locations while preserving the captured place name. + .replace( + /\b((?:Greater\s+)?[\p{L}\p{M}.'-]+(?:\s+[\p{L}\p{M}.'-]+){0,5}\s+(?:Area|Metro(?:politan)?\s+Area))[,\s]*(?:U\.?\s*S\.?(?:\s*A\.?)?|USA\.?)[,\s]*$/iu, + '$1' + ) + .replace(/,\s*([A-Z])\s+([A-Z])$/g, ', $1$2') + .replace(/\s+,/g, ',') + .replace(/,\s*/g, ', ') + .trim() + ); } - private static looksLikeLocation(line: string): boolean { - // Common location patterns - const locationPatterns = [ - /^[A-Z][a-z]+,\s*[A-Z]{2}$/, // City, ST - /^[A-Z][a-z]+,\s*[A-Z][a-z]+$/, // City, State - /^[A-Z][a-z]+,\s*[A-Z][a-z]+,\s*[A-Z][a-z]+/, // City, State, Country - /(California|New York|Texas|Florida|United States|Brasil|Brazil|Rio de Janeiro|São Paulo)/i, - ]; + private static normalizeCompletedLocationText(text: string): string { + return this.normalizeLocationText(text) + .replace(/,+\s*$/u, '') + .trim(); + } - return line.length < 80 && - locationPatterns.some(pattern => pattern.test(line)) && - !this.looksLikeDuration(line); + /** + * Detects comma-separated organization suffixes such as "Company, Inc" while + * preserving locations like "Los Angeles, California" and "Denver, CO". + * Parts are normalized by trimming whitespace and trailing dots first. + */ + private static looksLikeCommaSeparatedOrganizationName( + line: string + ): boolean { + const parts = line + .split(',') + .map(part => part.trim().replace(/[.]+$/, '').toLowerCase()) + .filter(Boolean); + + return ( + parts.length >= 2 && + parts + .slice(1) + .some(part => this.COMMA_SEPARATED_ORGANIZATION_SUFFIXES.has(part)) + ); } - private static calculateConfidence(line: string, type: StructuralSection['type'], fontSize: number): number { + private static calculateConfidence( + line: string, + type: StructuralSection['type'], + fontSize: number + ): number { let confidence = 0.5; // Base confidence switch (type) { @@ -256,7 +2059,11 @@ export class ExperienceStructuralParser { if (line.length < 30) confidence += 0.2; break; case 'position': - if (line.toLowerCase().includes('manager') || line.toLowerCase().includes('engineer')) confidence += 0.3; + if ( + line.toLowerCase().includes('manager') || + line.toLowerCase().includes('engineer') + ) + confidence += 0.3; break; case 'duration': if (/\d{4}/.test(line)) confidence += 0.3; @@ -269,7 +2076,9 @@ export class ExperienceStructuralParser { return Math.min(confidence, 1.0); } - private static buildWorkExperiences(sections: StructuralSection[]): WorkExperience[] { + private static buildWorkExperiences( + sections: StructuralSection[] + ): WorkExperience[] { const workExperiences: WorkExperience[] = []; let currentWorkExperience: Partial | null = null; let currentPosition: Partial | null = null; @@ -278,39 +2087,64 @@ export class ExperienceStructuralParser { for (const section of sections) { switch (section.type) { case 'organization': - // Save previous work experience - if (currentWorkExperience && currentWorkExperience.organization) { - if (currentPosition && currentPosition.title) { - currentPosition.description = descriptionLines.join(' ').trim(); - currentWorkExperience.positions = currentWorkExperience.positions || []; - currentWorkExperience.positions.push(currentPosition as Position); + { + const cleanOrgName = this.extractCleanOrganizationName( + section.text + ); + + if (!cleanOrgName) { + if (currentWorkExperience || currentPosition) { + descriptionLines.push(section.text); + } + + break; + } + + const completedWorkExperience = this.completeWorkExperience({ + workExperience: currentWorkExperience, + position: currentPosition, + descriptionLines, + }); + + if (completedWorkExperience) { + workExperiences.push(completedWorkExperience); } - workExperiences.push(currentWorkExperience as WorkExperience); - } - // Start new work experience with clean organization name - const cleanOrgName = this.extractCleanOrganizationName(section.text); - if (cleanOrgName) { // Only create if we have a valid organization name currentWorkExperience = { organization: cleanOrgName, positions: [], }; currentPosition = null; descriptionLines = []; - } else { - // If no valid organization name, treat this as description for previous experience - if (section.text.trim()) { - descriptionLines.push(section.text); - } } break; case 'position': - // Save previous position - if (currentPosition && currentPosition.title && currentWorkExperience) { - currentPosition.description = descriptionLines.join(' ').trim(); - currentWorkExperience.positions = currentWorkExperience.positions || []; - currentWorkExperience.positions.push(currentPosition as Position); + { + if ( + currentPosition?.title && + !currentPosition.duration && + descriptionLines.length === 0 && + this.areEquivalentPositionTitles( + currentPosition.title, + section.text + ) + ) { + currentPosition.title = section.text; + break; + } + + const completedPosition = this.completePosition({ + position: currentPosition, + descriptionLines, + }); + + if (completedPosition && currentWorkExperience) { + currentWorkExperience.positions = [ + ...(currentWorkExperience.positions ?? []), + completedPosition, + ]; + } } // Start new position @@ -323,16 +2157,32 @@ export class ExperienceStructuralParser { case 'duration': const cleanDuration = this.extractCleanDuration(section.text); + const dates = parseProfileDateRange(section.text); if (currentPosition) { + if (this.hasPendingTitleContinuation(descriptionLines)) { + currentPosition.title = + `${currentPosition.title} ${descriptionLines.join(' ')}`.replace( + /\s+/g, + ' ' + ); + descriptionLines = []; + } currentPosition.duration = cleanDuration; - } else if (currentWorkExperience && !currentWorkExperience.totalDuration) { + currentPosition.dates = dates; + } else if ( + currentWorkExperience && + !currentWorkExperience.totalDuration + ) { currentWorkExperience.totalDuration = cleanDuration; } break; case 'location': if (currentPosition) { - currentPosition.location = section.text; + const locationText = currentPosition.location + ? `${currentPosition.location} ${section.text}` + : section.text; + currentPosition.location = this.normalizeLocationText(locationText); } break; @@ -343,81 +2193,173 @@ export class ExperienceStructuralParser { } // Save final work experience - if (currentWorkExperience && currentWorkExperience.organization) { - if (currentPosition && currentPosition.title) { - currentPosition.description = descriptionLines.join(' ').trim(); - currentWorkExperience.positions = currentWorkExperience.positions || []; - currentWorkExperience.positions.push(currentPosition as Position); - } - workExperiences.push(currentWorkExperience as WorkExperience); + const completedWorkExperience = this.completeWorkExperience({ + workExperience: currentWorkExperience, + position: currentPosition, + descriptionLines, + }); + + if (completedWorkExperience) { + workExperiences.push(completedWorkExperience); } return workExperiences; } - private static extractCleanOrganizationName(text: string): string { - const knownCompanies = ['Carta', 'Boba Joy', 'Zestt', 'Guild', 'Liquido', 'Automox', 'AevoTech', 'Inovare', 'CEPEL', 'CPTI', 'Arena Games', 'PontoTel', 'Partiu']; + private static completeWorkExperience({ + workExperience, + position, + descriptionLines, + }: { + workExperience: Partial | null; + position: Partial | null; + descriptionLines: string[]; + }): WorkExperience | undefined { + if (!workExperience?.organization) { + return undefined; + } - // First, check if this is a known company and extract just that name - for (const company of knownCompanies) { - if (text.toLowerCase().includes(company.toLowerCase())) { - // Return just the known company name - return company; - } + const completedPosition = this.completePosition({ + position, + descriptionLines, + }); + const positions = completedPosition + ? [...(workExperience.positions ?? []), completedPosition] + : (workExperience.positions ?? []); + + if (positions.length === 0) { + return undefined; } - // Exclude common person names that might be mistaken for companies - const commonPersonNames = ['Daniel Braga', 'Arkady Zalkowitsch', 'Thamiris Zalkowitsch']; - if (commonPersonNames.some(name => text.toLowerCase().includes(name.toLowerCase()))) { - return ''; // Return empty to skip this as organization + return { + organization: workExperience.organization, + totalDuration: workExperience.totalDuration, + positions, + }; + } + + private static completePosition({ + position, + descriptionLines, + }: { + position: Partial | null; + descriptionLines: string[]; + }): Position | undefined { + if (!position?.title) { + return undefined; } - // For other companies, try to extract clean company name patterns - const cleanPatterns = [ - // Company name at the beginning of the line - /^([A-Z][A-Za-z\s&.,-]{1,30})(?:\s+[a-z]|\s*-|\s*\||$)/, - // Standalone company name - /^([A-Z][A-Za-z\s&.,-]{1,25})$/, - // Company with business suffix - /^([A-Z][A-Za-z\s&.,-]+(?:Inc|LLC|Ltd|Corp|Corporation|Company|Technologies|Tech|Solutions|Systems|Group|Labs|Studio))/i, - ]; + const dates = + position.dates ?? + (position.duration + ? parseProfileDateRange(position.duration) + : undefined); + + return { + ...(dates ? { dates } : {}), + title: position.title, + duration: position.duration ?? '', + ...(position.location + ? { location: this.normalizeCompletedLocationText(position.location) } + : {}), + description: descriptionLines.join(' ').trim(), + }; + } - for (const pattern of cleanPatterns) { - const match = text.match(pattern); - if (match) { - let companyName = match[1].trim(); + private static hasPendingTitleContinuation(lines: string[]): boolean { + return ( + lines.length > 0 && + lines.every(line => this.looksLikePendingTitleContinuationLine(line)) + ); + } - // Remove common trailing words that aren't part of company name - companyName = companyName.replace(/\s+(clarifications|for|scalable|solutions|and|or|the|of|in|at|with).*$/i, ''); + private static areEquivalentPositionTitles( + currentTitle: string, + nextTitle: string + ): boolean { + return ( + currentTitle.localeCompare(nextTitle, undefined, { + sensitivity: 'base', + }) === 0 + ); + } - // Additional check: if it looks like a person name (two capitalized words), skip it - const wordCount = companyName.split(' ').length; - const isLikelyPersonName = wordCount === 2 && /^[A-Z][a-z]+ [A-Z][a-z]+$/.test(companyName); + private static looksLikePendingTitleContinuationLine(line: string): boolean { + const normalizedLine = line.trim(); + + return ( + normalizedLine.length > 1 && + normalizedLine.length <= 40 && + normalizedLine.split(/\s+/).length <= 4 && + /^[\p{Lu}0-9]/u.test(normalizedLine) && + !/[.!?]$/.test(normalizedLine) && + !/^[-+*•]/u.test(normalizedLine) && + !looksLikePositionTitleText(normalizedLine) && + !this.looksLikeDuration(normalizedLine) && + !this.looksLikeLocation(normalizedLine) && + !isSectionHeaderText(normalizedLine) + ); + } - if (isLikelyPersonName) { - return ''; // Skip potential person names - } + private static extractCleanOrganizationName( + text: string + ): string | undefined { + if (this.looksLikeKnownLowercaseOrganization(text)) { + return text.trim(); + } - // Ensure reasonable length - if (companyName.length >= 2 && companyName.length <= 30) { - return companyName; - } - } + if (/\bMarine Corps\b/u.test(text.trim())) { + return text.trim(); + } + + if ( + /^[\p{Lu}0-9][\p{L}\p{M}0-9&.'+!–\-\s]+\s+\([A-Za-z0-9.-]+\.[A-Za-z0-9.-]+\)$/u.test( + text.trim() + ) + ) { + return text.trim(); + } + + if (this.looksLikeLowerCamelOrganization(text.trim())) { + return text.trim(); + } + + if (this.looksLikeLongAcademicOrganizationHeaderText(text.trim())) { + return text.trim(); + } + + if (this.looksLikeWrappedOrganizationHeaderText(text.trim())) { + return text.trim(); } - // Fallback: take first 30 characters and clean up - let cleanName = text.trim(); - if (cleanName.length > 30) { - cleanName = cleanName.substring(0, 30).trim(); + const cleanOrganizationName = cleanOrganizationNameText(text); + + if (cleanOrganizationName) { + return cleanOrganizationName; } - // Remove common trailing pollution - cleanName = cleanName.replace(/\s+(clarifications|for|scalable|solutions|and|or|the|of|in|at|with).*$/i, ''); + const normalizedText = text + .replace(/[\uE000-\uF8FF]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); - return cleanName || text.trim(); + return this.looksLikeVisualOrganizationHeaderText(normalizedText) || + this.looksLikeWrappedOrganizationHeaderText(normalizedText) + ? normalizedText + : undefined; } private static extractCleanDuration(text: string): string { + const normalizedText = text + .replace(/[\uE000-\uF8FF]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + const parsedDurationText = extractProfileDateRangeText(normalizedText); + + if (parsedDurationText) { + return parsedDurationText; + } + // Common duration patterns to extract const durationPatterns = [ // Full date ranges with years @@ -425,6 +2367,7 @@ export class ExperienceStructuralParser { /\b([A-Z][a-z]+\s+\d{4}\s*-\s*Present)\b/i, /\b(\d{4}\s*-\s*\d{4})\b/, /\b(\d{4}\s*-\s*Present)\b/i, + /\b((?:janeiro|fevereiro|março|marco|abril|maio|junho|julho|agosto|setembro|outubro|novembro|dezembro)\s+de\s+\d{4}\s*[-–]\s*(?:(?:janeiro|fevereiro|março|marco|abril|maio|junho|julho|agosto|setembro|outubro|novembro|dezembro)\s+de\s+\d{4}|presente|atual|present))\b/i, // Month/year formats /\b([A-Z][a-z]+\s+\d{4})\b/i, @@ -439,24 +2382,28 @@ export class ExperienceStructuralParser { // Try to extract the cleanest duration match for (const pattern of durationPatterns) { - const match = text.match(pattern); + const match = normalizedText.match(pattern); if (match) { return match[1].trim(); } } // If no specific pattern matched, try to clean up the text by removing obvious non-duration content - let cleanText = text.trim(); + let cleanText = normalizedText; // Remove bullet points and common leading text cleanText = cleanText.replace(/^[•\-\*]\s*/, ''); - cleanText = cleanText.replace(/^(Provided|Led|Managed|Built|Developed|Implemented|Created|Designed|Worked|Coordinated|Contributed)\s+.*?(?=\b[A-Z][a-z]+\s+\d{4}|\d{4})/i, ''); + cleanText = cleanText.replace( + /^(Provided|Led|Managed|Built|Developed|Implemented|Created|Designed|Worked|Coordinated|Contributed)\s+.*?(?=\b[A-Z][a-z]+\s+\d{4}|\d{4})/i, + '' + ); // Extract just the date-like portions - const datePortions = []; - const dateRegex = /\b(?:[A-Z][a-z]+\s+\d{4}|\d{4}(?:\s*-\s*(?:[A-Z][a-z]+\s+\d{4}|\d{4}|Present))?|\(\d+\s+(?:years?|months?|anos?|meses?)(?:\s+\d+\s+(?:months?|meses?))?)\)/gi; + const datePortions: string[] = []; + const dateRegex = + /\b(?:[A-Z][a-z]+\s+\d{4}|\d{4}(?:\s*-\s*(?:[A-Z][a-z]+\s+\d{4}|\d{4}|Present))?|\(\d+\s+(?:years?|months?|anos?|meses?)(?:\s+\d+\s+(?:months?|meses?))?)\)/gi; - let match; + let match: RegExpExecArray | null; while ((match = dateRegex.exec(cleanText)) !== null) { datePortions.push(match[0]); } @@ -467,7 +2414,12 @@ export class ExperienceStructuralParser { } // Fallback: if text is reasonably short and might be a duration, return it - if (cleanText.length < 50 && (cleanText.includes('-') || cleanText.match(/\d{4}/) || cleanText.includes('Present'))) { + if ( + cleanText.length < 50 && + (cleanText.includes('-') || + cleanText.match(/\d{4}/) || + cleanText.includes('Present')) + ) { return cleanText; } @@ -476,6 +2428,106 @@ export class ExperienceStructuralParser { return cleanText.substring(0, 50).trim(); } - return text.trim(); + return normalizedText; + } + + private static looksLikeMediaDescriptionLine(line: string): boolean { + return this.MEDIA_DESCRIPTION_LINE_PATTERN.test(line.trim()); + } + + private static looksLikeSentenceLikeDescriptionText(line: string): boolean { + const normalizedLine = line.trim(); + + return ( + /…/u.test(normalizedLine) || + /[.!?]\s+(?:actively|i|our|successfully|the|this|we)\b/iu.test( + normalizedLine + ) + ); + } + + private static looksLikeShortDescriptorLine(line: string): boolean { + const normalizedLine = line.trim(); + + return ( + normalizedLine.length >= 2 && + normalizedLine.length <= 45 && + normalizedLine.split(/\s+/).length <= 5 && + /^[\p{Lu}0-9]/u.test(normalizedLine) && + !/[.!?]$/.test(normalizedLine) && + !normalizedLine.includes('@') && + !/https?:\/\//i.test(normalizedLine) && + !/^[-+*•]/u.test(normalizedLine) && + !this.looksLikeDuration(normalizedLine) && + !this.looksLikeLocation(normalizedLine) && + !isSectionHeaderText(normalizedLine) + ); + } + + private static looksLikeShortDescriptorEntryHeader( + line: string, + index: number, + allLines: string[] + ): boolean { + const normalizedLine = line.trim(); + + if ( + this.looksLikePosition(normalizedLine) || + this.looksLikeLoosePositionTitle(normalizedLine, index, allLines) + ) { + return true; + } + + return ( + (looksLikeOrganizationNameText(normalizedLine) || + this.looksLikeVisualOrganizationHeaderText(normalizedLine) || + this.looksLikeWrappedOrganizationHeaderText(normalizedLine)) && + this.looksLikeOrganizationBeforePosition(normalizedLine, index, allLines) + ); + } + + private static createExperienceWarnings( + workExperiences: WorkExperience[] + ): SectionParseWarning[] { + const warnings: SectionParseWarning[] = []; + let positionEntry = 0; + + workExperiences.forEach((workExperience, workExperienceIndex) => { + if (workExperience.positions.length === 0) { + warnings.push({ + code: 'section_parse_warning', + entry: workExperienceIndex, + field: 'positions', + message: 'Could not extract any positions for experience entry', + rawText: workExperience.organization, + section: 'experience', + }); + } + + workExperience.positions.forEach(position => { + if (!position.duration) { + warnings.push({ + code: 'section_parse_warning', + entry: positionEntry, + field: 'dates', + message: 'Could not extract date range for experience entry', + rawText: position.title, + section: 'experience', + }); + } else if (!position.dates) { + warnings.push({ + code: 'section_parse_warning', + entry: positionEntry, + field: 'dates', + message: 'Could not parse date range', + rawText: position.duration, + section: 'experience', + }); + } + positionEntry++; + }); + }); + + return warnings; } -} \ No newline at end of file +} diff --git a/src/parsers/experience.ts b/src/parsers/experience.ts index d56760e..c8b47c4 100644 --- a/src/parsers/experience.ts +++ b/src/parsers/experience.ts @@ -1,64 +1,99 @@ import { REGEX_PATTERNS } from '../utils/regex-patterns.js'; +import { normalizeWhitespace } from '../utils/text-utils.js'; +import type { + Experience, + ParsedSectionResult, + SectionParseWarning, +} from '../types/profile.js'; import { - extractSection, - splitLines, - normalizeWhitespace, -} from '../utils/text-utils.js'; - -export interface Experience { - title: string; - company: string; - duration: string; - location?: string; - description?: string; -} + looksLikeDateRangeText, + parseProfileDateRange, +} from '../utils/date-parser.js'; +import { createTextParserLines } from '../utils/parser-lines.js'; +import { + cleanOrganizationNameText, + isSectionHeaderText, + looksLikeOrganizationNameText, + looksLikePositionTitleText, +} from '../utils/profile-text.js'; + +type TextExperienceState = + | 'seeking_company' + | 'seeking_title' + | 'seeking_dates' + | 'in_description'; export class ExperienceParser { static parse(text: string): Experience[] { - const experienceSection = extractSection(text, REGEX_PATTERNS.EXPERIENCE); + return this.parseWithWarnings(text).value; + } - if (!experienceSection) { - return []; - } + static parseWithWarnings(text: string): ParsedSectionResult { + const parserLines = createTextParserLines(text).filter( + line => line.section === 'experience' + ); const experiences: Experience[] = []; - const lines = splitLines(experienceSection).map(line => normalizeWhitespace(line)).filter(line => line.length > 0); - - // Manual parsing approach for LinkedIn PDF structure - const knownCompanies = ['Carta', 'Boba Joy', 'Zestt', 'Partiu Vantagens!', 'AevoTech', 'Inovare', 'CEPEL', 'CPTI / PUC-Rio', 'Arena Games', 'Guild', 'Springboard']; + const warnings: SectionParseWarning[] = []; + const lines = parserLines + .map(line => line.text) + .filter(line => line.length > 0); let currentCompany = ''; let currentPosition: Partial | null = null; let descriptionLines: string[] = []; + let state: TextExperienceState = 'seeking_company'; for (let i = 0; i < lines.length; i++) { const line = lines[i]; - // Stop at Education section - if (line.toLowerCase().includes('education')) { + if (isSectionHeaderText(line) && !/^experience$/i.test(line)) { break; } - // Check for known company names - if (knownCompanies.some(company => line.toLowerCase().includes(company.toLowerCase()))) { - // Save previous position - if (currentPosition && currentPosition.title) { - currentPosition.description = descriptionLines.join(' ').trim(); - experiences.push(currentPosition as Experience); + const inlineExperience = this.parseInlineTitleAndCompany(line); + if (inlineExperience) { + const completedPosition = this.completeExperience({ + position: currentPosition, + descriptionLines, + }); + + if (completedPosition) { + experiences.push(completedPosition); + } + + currentCompany = inlineExperience.company; + currentPosition = inlineExperience; + descriptionLines = []; + state = 'seeking_dates'; + continue; + } + + if (this.looksLikeCompanyName(line, lines, i)) { + const completedPosition = this.completeExperience({ + position: currentPosition, + descriptionLines, + }); + + if (completedPosition) { + experiences.push(completedPosition); } - currentCompany = line; + currentCompany = cleanOrganizationNameText(line) ?? line; currentPosition = null; descriptionLines = []; + state = 'seeking_title'; continue; } - // Check for job titles - be more specific if (this.isJobTitle(line) && currentCompany) { - // Save previous position - if (currentPosition && currentPosition.title) { - currentPosition.description = descriptionLines.join(' ').trim(); - experiences.push(currentPosition as Experience); + const completedPosition = this.completeExperience({ + position: currentPosition, + descriptionLines, + }); + + if (completedPosition) { + experiences.push(completedPosition); } // Create new position @@ -67,9 +102,10 @@ export class ExperienceParser { company: currentCompany, duration: '', location: '', - description: '' + description: '', }; descriptionLines = []; + state = 'seeking_dates'; continue; } @@ -77,177 +113,144 @@ export class ExperienceParser { if (currentPosition) { if (this.looksLikeDuration(line)) { currentPosition.duration = line; + currentPosition.dates = parseProfileDateRange(line); + state = 'in_description'; } else if (this.looksLikeLocation(line) && !currentPosition.location) { currentPosition.location = line; + state = 'in_description'; } else if (line.length > 15 && !line.includes('Page')) { descriptionLines.push(line); + state = 'in_description'; } + } else if (state !== 'seeking_company' && line.length > 2) { + warnings.push({ + code: 'section_parse_warning', + field: 'entry', + message: + 'Discarded experience line that did not fit the expected state', + rawText: line, + section: 'experience', + }); } } // Add final position - if (currentPosition && currentPosition.title) { - currentPosition.description = descriptionLines.join(' ').trim(); - experiences.push(currentPosition as Experience); - } - - return experiences; - } + const completedPosition = this.completeExperience({ + position: currentPosition, + descriptionLines, + }); - private static isJobTitle(line: string): boolean { - const titleKeywords = [ - 'Engineering Manager', 'Tech Lead Manager', 'Senior Software Engineer', - 'Co-founder', 'Engineering Director', 'Head of Engineering', - 'Senior Lead Software Engineer', 'Lead Project Engineer', - 'Robotics Researcher', 'Technical Researcher', 'Technical Support Analyst', - 'Software Engineer III', 'Senior Software Engineer I' - ]; - - // Don't include lines that start with duration patterns - if (/^\d+\s+(year|month)/.test(line)) { - return false; + if (completedPosition) { + experiences.push(completedPosition); } - // Check for exact title matches or titles that start the line - for (const title of titleKeywords) { - if (line.toLowerCase().includes(title.toLowerCase()) && - !line.includes('•') && - line.length < 150) { - // Make sure duration info isn't mixed in the title - const cleanTitle = line.replace(/\d+\s+(year|month)s?\s+\d+\s+(month|year)s?/gi, '').trim(); - if (cleanTitle.length > 10) { - return true; - } - } - } + warnings.push(...this.createExperienceWarnings(experiences, lines)); - return false; + return { + value: experiences, + warnings, + }; } - private static looksLikeCompanyName(line: string, lines: string[], index: number): boolean { - // Skip obvious non-companies - if ( - line.length < 2 || - line.length > 50 || - line.toLowerCase() === 'experience' || - line.toLowerCase().includes('page') || - this.looksLikeDuration(line) || - this.looksLikeLocation(line) || - this.looksLikeJobTitle(line) || - line.includes('•') || - line.includes('(') || - line.includes(')') || - /^[a-z]/.test(line) || // Starts with lowercase - /\d+\s+years?\s+\d+\s+months?/.test(line) // Duration patterns - ) { - return false; + private static completeExperience({ + position, + descriptionLines, + }: { + position: Partial | null; + descriptionLines: string[]; + }): Experience | undefined { + if (!position?.title || !position.company) { + return undefined; } - // Look ahead to see if next few lines look like job details - const nextLines = lines.slice(index + 1, index + 6); - const hasJobDetailsAfter = nextLines.some(nextLine => { - const normalizedNext = nextLine.trim(); - return ( - this.looksLikeDuration(normalizedNext) || - this.looksLikeJobTitle(normalizedNext) || - /^\d+\s+years?\s+\d+\s+months?/.test(normalizedNext) || - normalizedNext.includes('Manager') || - normalizedNext.includes('Engineer') || - normalizedNext.includes('Director') - ); - }); + return { + ...(position.dates ? { dates: position.dates } : {}), + title: position.title, + company: position.company, + duration: position.duration ?? '', + location: position.location, + description: descriptionLines.join(' ').trim(), + }; + } - // Known company name patterns - be more specific - const companyPatterns = [ - /^(Carta|Boba Joy|Zestt|Partiu Vantagens!|AevoTech|Inovare|CEPEL|CPTI|Arena Games)$/i, - /^[A-Z]{2,5}$/, // Acronyms like CEPEL, CPTI - /^[A-Z][A-Za-z\s&]+$/, // Standard company names - ]; + private static parseInlineTitleAndCompany( + line: string + ): Experience | undefined { + const inlinePatterns = [/^(.+?)\s+(?:at|@)\s+(.+)$/i]; - // Special handling for multi-part company names - if (/^CPTI\s*\/\s*PUC/.test(line)) { - return true; - } + for (const pattern of inlinePatterns) { + const match = line.match(pattern); - return hasJobDetailsAfter && companyPatterns.some(pattern => pattern.test(line)); - } + if (!match) { + continue; + } - private static parseJobTitleLine(line: string): Partial { - // Try to extract title and company from various formats - const patterns = [ - /^(.+?)\s+at\s+(.+)$/i, - /^(.+?)\s+@\s+(.+)$/i, - /^(.+?)\s*[·•-]\s*(.+)$/, - /^(.+?),\s*(.+)$/, - ]; + const title = normalizeWhitespace(match[1]); + const company = cleanOrganizationNameText(match[2]); - for (const pattern of patterns) { - const match = line.match(pattern); - if (match) { + if (this.isJobTitle(title) && company) { return { - title: match[1].trim(), - company: match[2].trim(), - location: '', + title, + company, duration: '', + location: '', description: '', }; } } - // For LinkedIn PDFs, sometimes company names appear separately - // If it's just a job title, we'll need to find the company in context - return { - title: line, - company: '', // Will be filled by parsing context - location: '', - duration: '', - description: '', - }; + return undefined; } - private static looksLikeJobTitle(line: string): boolean { - const lowerLine = line.toLowerCase(); - - // Skip obvious non-job-title lines + private static looksLikeCompanyName( + line: string, + lines: string[], + index: number + ): boolean { if ( - line.length < 5 || - line.length > 100 || - lowerLine.includes('education') || - lowerLine.includes('skills') || this.looksLikeDuration(line) || this.looksLikeLocation(line) || - line.includes('•') || // Bullet points are usually descriptions - line.includes('%') || // Percentages are usually descriptions - line.includes('$') || // Money amounts are usually descriptions - /^\d+/.test(line) || // Lines starting with numbers are usually descriptions - /^[a-z]/.test(line) // Lines starting with lowercase are usually descriptions + this.isJobTitle(line) || + isSectionHeaderText(line) ) { return false; } - // More specific job title patterns - const jobTitlePatterns = [ - // Title at/@ Company patterns - /^[A-Z][A-Za-z\s]+\s+(at|@)\s+[A-Z][A-Za-z\s]+$/, - // Standalone titles that are likely job titles - /^(Senior|Lead|Principal|Chief|Head of|Director of|VP of|President of)\s+/i, - /^(Engineering|Software|Product|Data|Marketing|Sales|Business|Technical|Project)\s+(Manager|Engineer|Analyst|Director|Lead)/i, - // Company names with title patterns - /[·•-]\s*[A-Z][A-Za-z\s]+$/, - ]; - - return jobTitlePatterns.some(pattern => pattern.test(line)); + const nextLines = lines.slice(index + 1, index + 5); + const hasJobDetailsAfter = nextLines.some( + nextLine => + this.looksLikeDuration(nextLine) || + this.isJobTitle(nextLine) || + /^\d+\s+(years?|months?|anos?|meses?)/i.test(nextLine) + ); + + return hasJobDetailsAfter && looksLikeOrganizationNameText(line); + } + + private static isJobTitle(line: string): boolean { + return ( + looksLikePositionTitleText(line) && + !this.looksLikeDuration(line) && + !this.looksLikeLocation(line) + ); } private static looksLikeDuration(line: string): boolean { + const normalizedLine = normalizeWhitespace(line); + REGEX_PATTERNS.DATE_RANGE.lastIndex = 0; + + if (REGEX_PATTERNS.DATE_RANGE.test(normalizedLine)) { + return true; + } + + if (normalizedLine.length > 40) { + return false; + } + return ( - REGEX_PATTERNS.DATE_RANGE.test(line) || - /\b(january|february|march|april|may|june|july|august|september|october|november|december)\b/i.test(line) || - /\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\b/i.test(line) || - /\b\d{4}\s*-\s*\d{4}\b/.test(line) || - /\b\d{4}\s*-\s*(present|current)\b/i.test(line) || - /\(\d+\s+years?\s+\d+\s+months?\)/i.test(line) || - /present|atual|current/i.test(line) && line.length < 50 + /[-–—]|\bto\b|\bpresent\b|\bcurrent\b|\batual\b|\bpresente\b/i.test( + normalizedLine + ) && looksLikeDateRangeText(normalizedLine) ); } @@ -255,25 +258,56 @@ export class ExperienceParser { return ( line.length > 2 && line.length < 50 && - ( - /^[A-Z][a-z]+,\s*[A-Z]{2}$/.test(line) || // "City, ST" + (/^[A-Z][a-z]+,\s*[A-Z]{2}$/.test(line) || // "City, ST" /^[A-Z][a-z]+,\s*[A-Z][a-z]+$/.test(line) || // "City, State" /^[A-Z][a-z]+,\s*[A-Z][a-z]+,\s*[A-Z][a-z]+/.test(line) || // "City, State, Country" - /(California|New York|Texas|Florida|Illinois|Pennsylvania|Ohio|Georgia|North Carolina|Michigan|CA|NY|TX|FL)/.test(line) - ) && + /(California|New York|Texas|Florida|Illinois|Pennsylvania|Ohio|Georgia|North Carolina|Michigan|CA|NY|TX|FL)/.test( + line + )) && !this.looksLikeDuration(line) && !line.includes('@') && !line.includes('|') ); } - private static isSectionHeader(line: string): boolean { - const lowerLine = line.toLowerCase(); - return ( - lowerLine.includes('education') || - lowerLine.includes('skills') || - lowerLine.includes('languages') || - lowerLine.includes('certifications') - ); + private static createExperienceWarnings( + experiences: Experience[], + lines: string[] + ): SectionParseWarning[] { + const warnings: SectionParseWarning[] = []; + + if (lines.length > 0 && experiences.length === 0) { + warnings.push({ + code: 'section_parse_warning', + field: 'entry', + message: 'Detected an experience section but could not extract entries', + rawText: lines.join(' '), + section: 'experience', + }); + } + + experiences.forEach((experience, entry) => { + if (!experience.duration) { + warnings.push({ + code: 'section_parse_warning', + entry, + field: 'dates', + message: 'Could not extract date range for experience entry', + rawText: experience.title, + section: 'experience', + }); + } else if (!experience.dates) { + warnings.push({ + code: 'section_parse_warning', + entry, + field: 'dates', + message: 'Could not parse date range', + rawText: experience.duration, + section: 'experience', + }); + } + }); + + return warnings; } } diff --git a/src/parsers/extra-sections.ts b/src/parsers/extra-sections.ts new file mode 100644 index 0000000..36df78d --- /dev/null +++ b/src/parsers/extra-sections.ts @@ -0,0 +1,332 @@ +import type { StructuralLine } from '../utils/structural-lines.js'; +import { + PROFILE_SECTION_HEADER_ENTRIES, + type ProfileSectionKey, +} from '../utils/profile-section-headers.js'; +import { normalizeWhitespace, splitLines } from '../utils/text-utils.js'; +import type { + ParsedSectionResult, + SectionParseWarning, + WarningSection, +} from '../types/profile.js'; + +export interface ExtraProfileSections { + certifications: string[]; + honors_awards: string[]; + volunteer_work: string[]; + projects: string[]; + publications: string[]; +} + +type ExtraSectionKey = keyof ExtraProfileSections; + +type SectionHeader = + | { + kind: 'target'; + key: ExtraSectionKey; + } + | { + kind: 'boundary'; + }; + +const TARGET_SECTION_HEADERS = new Map( + createTargetSectionHeaderEntries() +); + +const BOUNDARY_SECTION_HEADERS = new Set([ + ...PROFILE_SECTION_HEADER_ENTRIES.map(([text]) => + normalizeSectionHeader(text) + ), + 'courses', + 'patents', + 'organizations', + 'recommendations', + 'interests', + ...TARGET_SECTION_HEADERS.keys(), +]); + +function createTargetSectionHeaderEntries(): Array< + readonly [string, ExtraSectionKey] +> { + const entries: Array = []; + + for (const [text, section] of PROFILE_SECTION_HEADER_ENTRIES) { + if (isExtraSectionKey(section)) { + entries.push([normalizeSectionHeader(text), section]); + } + } + + return entries; +} + +function isExtraSectionKey( + section: ProfileSectionKey +): section is ExtraSectionKey { + return ( + section === 'certifications' || + section === 'honors_awards' || + section === 'projects' || + section === 'publications' || + section === 'volunteer_work' + ); +} + +export class ExtraSectionParser { + static parseText(text: string): ExtraProfileSections { + return this.parseTextWithWarnings(text).value; + } + + static parseTextWithWarnings( + text: string + ): ParsedSectionResult { + return parseSectionLines(splitLines(text).map(cleanSectionLine)); + } + + static parseStructural(lines: StructuralLine[]): ExtraProfileSections { + return this.parseStructuralWithWarnings(lines).value; + } + + static parseStructuralWithWarnings( + lines: StructuralLine[] + ): ParsedSectionResult { + const sections = createEmptySections(); + const warnings: SectionParseWarning[] = []; + const columns: StructuralLine['column'][] = ['left', 'right', 'single']; + + for (const column of columns) { + const columnLines = lines + .filter(line => line.column === column) + .map(line => ({ + ...line, + text: cleanSectionLine(line.text), + })); + const mergedColumnLines = mergeWrappedStructuralSectionLines(columnLines); + const columnSections = parseSectionLines(mergedColumnLines); + + sections.certifications.push(...columnSections.value.certifications); + sections.honors_awards.push(...columnSections.value.honors_awards); + sections.projects.push(...columnSections.value.projects); + sections.publications.push(...columnSections.value.publications); + sections.volunteer_work.push(...columnSections.value.volunteer_work); + warnings.push(...columnSections.warnings); + } + + return { + value: sections, + warnings: filterMergedSectionWarnings({ sections, warnings }), + }; + } +} + +export function filterMergedSectionWarnings({ + sections, + warnings, +}: { + sections: ExtraProfileSections; + warnings: SectionParseWarning[]; +}): SectionParseWarning[] { + const entriesByWarningSection: Partial> = { + certifications: sections.certifications, + honors_awards: sections.honors_awards, + projects: sections.projects, + publications: sections.publications, + volunteer_work: sections.volunteer_work, + }; + const emittedEmptySectionWarnings = new Set(); + + return warnings.filter(warning => { + if (warning.field !== 'section') { + return true; + } + + const entries = entriesByWarningSection[warning.section]; + + if (entries === undefined) { + return true; + } + + if (entries.length > 0) { + return false; + } + + if (emittedEmptySectionWarnings.has(warning.section)) { + return false; + } + + emittedEmptySectionWarnings.add(warning.section); + return true; + }); +} + +function mergeWrappedStructuralSectionLines(lines: StructuralLine[]): string[] { + const mergedLines: string[] = []; + let activeSection: ExtraSectionKey | undefined; + let previousEntry: + | { + line: StructuralLine; + mergedLineIndex: number; + } + | undefined; + + for (const line of lines) { + const header = getSectionHeader(line.text); + + if (header?.kind === 'target') { + activeSection = header.key; + previousEntry = undefined; + mergedLines.push(line.text); + continue; + } + + if (header?.kind === 'boundary') { + activeSection = undefined; + previousEntry = undefined; + mergedLines.push(line.text); + continue; + } + + if ( + activeSection && + previousEntry && + isWrappedStructuralEntryLine(previousEntry.line, line) + ) { + mergedLines[previousEntry.mergedLineIndex] = normalizeWhitespace( + `${mergedLines[previousEntry.mergedLineIndex]} ${line.text}` + ); + previousEntry = { + line, + mergedLineIndex: previousEntry.mergedLineIndex, + }; + continue; + } + + mergedLines.push(line.text); + previousEntry = activeSection + ? { + line, + mergedLineIndex: mergedLines.length - 1, + } + : undefined; + } + + return mergedLines; +} + +function isWrappedStructuralEntryLine( + previousLine: StructuralLine, + line: StructuralLine +): boolean { + const yGap = previousLine.y - line.y; + const maxExpectedWrapGap = Math.max(previousLine.height, line.height) + 4; + const isAligned = Math.abs(previousLine.x - line.x) <= 8; + const hasSimilarFontSize = + Math.abs(previousLine.fontSize - line.fontSize) < 1; + + return ( + yGap > 0 && yGap <= maxExpectedWrapGap && isAligned && hasSimilarFontSize + ); +} + +function parseSectionLines( + lines: string[] +): ParsedSectionResult { + const sections = createEmptySections(); + const detectedSections = new Set(); + let activeSection: ExtraSectionKey | undefined; + + for (const line of lines) { + if (!line || /^page\s+\d+\s+of\s+\d+$/i.test(line)) { + continue; + } + + const header = getSectionHeader(line); + + if (header?.kind === 'target') { + activeSection = header.key; + detectedSections.add(header.key); + continue; + } + + if (header?.kind === 'boundary') { + activeSection = undefined; + continue; + } + + if (activeSection) { + sections[activeSection].push(line); + } + } + + return { + value: sections, + warnings: createExtraSectionWarnings(sections, detectedSections), + }; +} + +function createEmptySections(): ExtraProfileSections { + return { + certifications: [], + honors_awards: [], + projects: [], + publications: [], + volunteer_work: [], + }; +} + +function getSectionHeader(line: string): SectionHeader | undefined { + const normalizedHeader = normalizeSectionHeader(line); + const targetSection = TARGET_SECTION_HEADERS.get(normalizedHeader); + + if (targetSection) { + return { + kind: 'target', + key: targetSection, + }; + } + + return BOUNDARY_SECTION_HEADERS.has(normalizedHeader) + ? { kind: 'boundary' } + : undefined; +} + +function cleanSectionLine(line: string): string { + return normalizeWhitespace( + line + .replace(/[\uE000-\uF8FF]/g, ' ') + .replace(/\u00A0/g, ' ') + .replace(/^[•*-]\s*/, '') + ); +} + +function normalizeSectionHeader(line: string): string { + return cleanSectionLine(line) + .normalize('NFD') + .replace(/\p{M}/gu, '') + .replace(/&/g, ' and ') + .replace(/[^a-zA-Z\s]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); +} + +function createExtraSectionWarnings( + sections: ExtraProfileSections, + detectedSections: Set +): SectionParseWarning[] { + const warnings: SectionParseWarning[] = []; + + for (const section of detectedSections) { + if (sections[section].length > 0) { + continue; + } + + warnings.push({ + code: 'section_parse_warning', + field: 'section', + message: `Detected a ${section} section but could not extract entries`, + section, + }); + } + + return warnings; +} diff --git a/src/parsers/identity-structural.ts b/src/parsers/identity-structural.ts new file mode 100644 index 0000000..be9a58f --- /dev/null +++ b/src/parsers/identity-structural.ts @@ -0,0 +1,228 @@ +import type { StructuralLine } from '../utils/structural-lines.js'; +import type { + ParsedSectionResult, + SectionParseWarning, +} from '../types/profile.js'; +import { TOP_SKILLS_LIMIT } from '../utils/parser-limits.js'; +import { + isLikelyLocationText, + isSectionHeaderText, +} from '../utils/profile-text.js'; +import { normalizeWhitespace } from '../utils/text-utils.js'; + +export interface StructuralIdentity { + name?: string; + headline?: string; + location?: string; + linkedinUrl?: string; + topSkills: string[]; +} + +export class IdentityStructuralParser { + static parse(lines: StructuralLine[]): StructuralIdentity { + return this.parseWithWarnings(lines).value; + } + + static parseWithWarnings( + lines: StructuralLine[] + ): ParsedSectionResult { + const leftLines = lines.filter(line => line.column === 'left'); + const mainLines = lines.filter( + line => line.column === 'right' || line.column === 'single' + ); + const identityLines = this.extractIdentityLines(mainLines); + const nameLine = this.findNameLine(identityLines); + const nameIndex = nameLine ? identityLines.indexOf(nameLine) : -1; + const locationLine = this.findLocationLine(identityLines, nameIndex); + const locationIndex = locationLine + ? identityLines.indexOf(locationLine) + : -1; + + const value: StructuralIdentity = { + name: nameLine?.text, + headline: this.extractHeadline({ + identityLines, + locationIndex, + nameIndex, + }), + location: locationLine?.text, + linkedinUrl: this.extractLinkedInUrl(leftLines.map(line => line.text)), + topSkills: this.extractTopSkills(leftLines), + }; + + return { + value, + warnings: this.createStructuralIdentityWarnings(leftLines, value), + }; + } + + private static extractIdentityLines( + mainLines: StructuralLine[] + ): StructuralLine[] { + const visibleLines = mainLines.filter(line => !this.isNoiseLine(line.text)); + const firstSectionIndex = visibleLines.findIndex(line => + isSectionHeaderText(line.text) + ); + + return firstSectionIndex === -1 + ? visibleLines + : visibleLines.slice(0, firstSectionIndex); + } + + private static findNameLine( + identityLines: StructuralLine[] + ): StructuralLine | undefined { + const candidates = identityLines.filter(line => + this.isIdentityCandidateLine(line.text) + ); + + if (candidates.length === 0) { + return undefined; + } + + return candidates.reduce((bestLine, currentLine) => + currentLine.fontSize > bestLine.fontSize ? currentLine : bestLine + ); + } + + private static findLocationLine( + identityLines: StructuralLine[], + nameIndex: number + ): StructuralLine | undefined { + const searchLines = + nameIndex === -1 ? identityLines : identityLines.slice(nameIndex + 1); + + return searchLines.find(line => isLikelyLocationText(line.text)); + } + + private static extractHeadline({ + identityLines, + locationIndex, + nameIndex, + }: { + identityLines: StructuralLine[]; + locationIndex: number; + nameIndex: number; + }): string | undefined { + const startIndex = nameIndex === -1 ? 0 : nameIndex + 1; + const endIndex = + locationIndex === -1 || locationIndex < startIndex + ? identityLines.length + : locationIndex; + const headline = normalizeWhitespace( + identityLines + .slice(startIndex, endIndex) + .map(line => line.text) + .filter(text => this.isIdentityCandidateLine(text)) + .join(' ') + ); + + return headline || undefined; + } + + private static extractTopSkills(lines: StructuralLine[]): string[] { + const topSkillsIndex = lines.findIndex(line => + /^top skills$/i.test(line.text) + ); + + if (topSkillsIndex === -1) { + return []; + } + + const followingLines = lines.slice(topSkillsIndex + 1); + const endIndex = followingLines.findIndex(line => + isSidebarSectionHeader(line.text) + ); + const skillLines = + endIndex === -1 ? followingLines : followingLines.slice(0, endIndex); + + return skillLines + .map(line => line.text) + .filter(skill => skill.length > 1 && skill.length < 50) + .slice(0, TOP_SKILLS_LIMIT); + } + + private static extractLinkedInUrl(lines: string[]): string | undefined { + const linkedInIndex = lines.findIndex(line => + /linkedin\.com\/in\//i.test(line) + ); + + if (linkedInIndex === -1) { + return undefined; + } + + const linkedInLine = lines[linkedInIndex]; + const nextLine = lines[linkedInIndex + 1] ?? ''; + const combinedLine = + linkedInLine.trim().endsWith('-') || /\(LinkedIn\)/i.test(nextLine) + ? `${linkedInLine}${nextLine}` + : linkedInLine; + const compactLine = combinedLine + .replace(/\s+/g, '') + .replace(/\(LinkedIn\)/i, ''); + const match = compactLine.match( + /(?:www\.)?linkedin\.com\/in\/([a-zA-Z0-9-]+)/ + ); + + return match ? `https://linkedin.com/in/${match[1]}` : undefined; + } + + private static isIdentityCandidateLine(text: string): boolean { + return ( + !this.isNoiseLine(text) && + !isSectionHeaderText(text) && + text.length >= 2 && + text.length <= 180 + ); + } + + private static isNoiseLine(text: string): boolean { + return ( + !text || + /[A-Z0-9._%+-]+\s*@\s*[A-Z0-9.-]+\.[A-Z]{2,63}/i.test(text) || + /https?:\/\//i.test(text) || + /(?:^|\s)www\./i.test(text) || + /^page\s+\d+\s+of\s+\d+$/i.test(text) + ); + } + + private static createStructuralIdentityWarnings( + leftLines: StructuralLine[], + identity: StructuralIdentity + ): SectionParseWarning[] { + const warnings: SectionParseWarning[] = []; + const hasTopSkillsSection = leftLines.some(line => + /^top skills$/i.test(line.text) + ); + const hasContactSection = leftLines.some(line => + /^contact$/i.test(line.text) + ); + const hasLinkedInText = leftLines.some(line => + /linkedin\.com\/in\//i.test(line.text) + ); + + if (hasTopSkillsSection && identity.topSkills.length === 0) { + warnings.push({ + code: 'section_parse_warning', + field: 'section', + message: 'Detected a top skills section but could not extract skills', + section: 'top_skills', + }); + } + + if (hasContactSection && hasLinkedInText && !identity.linkedinUrl) { + warnings.push({ + code: 'section_parse_warning', + field: 'linkedin_url', + message: 'Detected a LinkedIn URL but could not normalize it', + section: 'contact', + }); + } + + return warnings; + } +} + +function isSidebarSectionHeader(text: string): boolean { + return isSectionHeaderText(text); +} diff --git a/src/parsers/lists.ts b/src/parsers/lists.ts index ff75ff0..056b962 100644 --- a/src/parsers/lists.ts +++ b/src/parsers/lists.ts @@ -1,57 +1,153 @@ import { REGEX_PATTERNS } from '../utils/regex-patterns.js'; +import { normalizeWhitespace } from '../utils/text-utils.js'; +import type { + Language, + ParsedSectionResult, + SectionParseWarning, +} from '../types/profile.js'; import { - extractSection, - splitLines, - normalizeWhitespace, -} from '../utils/text-utils.js'; - -export interface Language { - language: string; - proficiency: string; + isSectionHeaderText, + looksLikeExperienceDetailText, + looksLikeOrganizationNameText, +} from '../utils/profile-text.js'; +import { + createTextParserLines, + getParserLineSectionHeader, + type ParserLineSection, +} from '../utils/parser-lines.js'; +import { TOP_SKILLS_LIMIT } from '../utils/parser-limits.js'; +import { extractStructuralSectionLines } from '../utils/structural-sections.js'; +import type { StructuralLine } from '../utils/structural-lines.js'; + +interface SkillCandidateContext { + skill: string; + followingLines: string[]; } +type ListState = 'seeking_section' | 'collecting'; + export class ListParser { static parseSkills(text: string): string[] { - const skillsSection = extractSection(text, REGEX_PATTERNS.TOP_SKILLS); + return this.parseSkillsWithWarnings(text).value; + } + + static parseSkillsWithWarnings(text: string): ParsedSectionResult { + const parserLines = createTextParserLines(text); + const hasSkillsSection = parserLines.some(line => + isHeaderForSection(line.text, 'top_skills') + ); + + if (!hasSkillsSection) { + return { + value: [], + warnings: [], + }; + } - if (!skillsSection) { - return []; + let state: ListState = 'seeking_section'; + const skills: string[] = []; + const warnings: SectionParseWarning[] = []; + const lines = parserLines.filter(line => line.section === 'top_skills'); + + for (let index = 0; index < lines.length; index++) { + const skill = normalizeWhitespace(lines[index].text); + const followingLines = lines + .slice(index + 1, index + 4) + .map(line => line.text); + + state = 'collecting'; + + if (this.isLikelySkill({ skill, followingLines })) { + skills.push(skill); + } else if (skill) { + warnings.push({ + code: 'section_parse_warning', + field: 'item', + message: 'Discarded top skills line that did not look like a skill', + rawText: skill, + section: 'top_skills', + }); + } + + if (skills.length === TOP_SKILLS_LIMIT) { + break; + } + } + + if (state === 'collecting' && skills.length === 0) { + warnings.push({ + code: 'section_parse_warning', + field: 'section', + message: 'Detected a top skills section but could not extract skills', + section: 'top_skills', + }); } - const lines = splitLines(skillsSection); - return lines - .map(line => normalizeWhitespace(line)) - .filter(skill => { - const lowerSkill = skill.toLowerCase(); - return ( - skill.length > 1 && - skill.length < 50 && - !lowerSkill.includes('languages') && - !lowerSkill.includes('summary') && - !lowerSkill.includes('experience') && - !lowerSkill.includes('education') && - !lowerSkill.includes('page ') && - !lowerSkill.match(/^\d+$/) - ); - }) - .slice(0, 10); + return { + value: skills, + warnings, + }; } static parseLanguages(text: string): Language[] { - const languagesSection = extractSection(text, REGEX_PATTERNS.LANGUAGES); + return this.parseLanguagesWithWarnings(text).value; + } - if (!languagesSection) { - return []; + static parseLanguagesWithWarnings( + text: string + ): ParsedSectionResult { + const parserLines = createTextParserLines(text); + const hasLanguagesSection = parserLines.some(line => + isHeaderForSection(line.text, 'languages') + ); + + return this.parseLanguageLines({ + hasLanguagesSection, + lines: parserLines + .filter(line => line.section === 'languages') + .map(line => line.text), + }); + } + + static parseStructuralLanguagesWithWarnings( + structuralLines: StructuralLine[] + ): ParsedSectionResult { + const sectionLines = extractStructuralSectionLines({ + section: 'languages', + structuralLines, + }); + + return this.parseLanguageLines({ + hasLanguagesSection: sectionLines.hasSection, + lines: this.mergeWrappedLanguageLines( + sectionLines.lines.map(line => line.text) + ), + }); + } + + private static parseLanguageLines({ + hasLanguagesSection, + lines, + }: { + hasLanguagesSection: boolean; + lines: string[]; + }): ParsedSectionResult { + if (!hasLanguagesSection) { + return { + value: [], + warnings: [], + }; } - const lines = splitLines(languagesSection); const languages: Language[] = []; + const warnings: SectionParseWarning[] = []; for (const line of lines) { const normalizedLine = normalizeWhitespace(line); if ( !normalizedLine || + isLanguageSectionHeaderText(normalizedLine) || normalizedLine.toLowerCase().includes('summary') || normalizedLine.toLowerCase().includes('experience') || normalizedLine.toLowerCase().includes('education') || @@ -63,19 +159,43 @@ export class ListParser { const language = this.extractLanguageInfo(normalizedLine); if (language) { languages.push(language); + } else { + warnings.push({ + code: 'section_parse_warning', + field: 'item', + message: + 'Discarded language line that did not match a language shape', + rawText: normalizedLine, + section: 'languages', + }); } } - return languages; + if (hasLanguagesSection && languages.length === 0) { + warnings.push({ + code: 'section_parse_warning', + field: 'section', + message: 'Detected a languages section but could not extract languages', + section: 'languages', + }); + } + + return { + value: languages, + warnings, + }; } private static extractLanguageInfo(line: string): Language | null { // Handle specific patterns from LinkedIn PDFs const specificPatterns = [ + // "Chinese (Traditional) (Limited Working)" where the language variant + // and proficiency are both parenthesized. + /^([\p{L}\s.+-]+(?:\s*\([\p{L}\s.+-]+\))?)\s*\(([^)]+)\)$/u, // "Português (Native or Bilingual)" or "Inglês (Professional Working)" - /^([A-Za-zção]+)\s*\(([^)]+)\)/, + /^([\p{L}\s.+-]+?)\s*\(([^)]+)\)/u, // "Inglês Professional Working" - without parentheses - /^([A-Za-zção]+)\s+((?:Professional|Native|Elementary|Bilingual|Working|Limited|Fluent)(?:\s+\w+)?)/i, + /^([\p{L}\s.+-]+?)\s+((?:Professional|Native|Elementary|Bilingual|Working|Limited|Fluent)(?:\s+\w+)?)/iu, ]; for (const pattern of specificPatterns) { @@ -104,7 +224,7 @@ export class ListParser { } } - if (line.length > 1 && line.length < 20 && /^[A-Za-zção]+$/.test(line)) { + if (line.length > 1 && line.length < 20 && /^[\p{L}.+-]+$/u.test(line)) { return { language: line, proficiency: 'Unknown', @@ -113,4 +233,111 @@ export class ListParser { return null; } + + private static mergeWrappedLanguageLines(lines: string[]): string[] { + const mergedLines: string[] = []; + let bufferedLine: string | undefined; + + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + const normalizedLine = normalizeWhitespace(line); + + if (!normalizedLine) { + continue; + } + + if (bufferedLine) { + bufferedLine = normalizeWhitespace(`${bufferedLine} ${normalizedLine}`); + + if (parenthesisBalance(bufferedLine) <= 0) { + mergedLines.push(bufferedLine); + bufferedLine = undefined; + } + + continue; + } + + const nextLine = normalizeWhitespace(lines[index + 1] ?? ''); + if ( + looksLikeParenthesizedLanguageContinuation(nextLine) && + looksLikeLanguageBaseWithoutProficiency(normalizedLine) + ) { + const mergedLine = normalizeWhitespace(`${normalizedLine} ${nextLine}`); + + if (parenthesisBalance(mergedLine) <= 0) { + mergedLines.push(mergedLine); + } else { + bufferedLine = mergedLine; + } + + index += 1; + continue; + } + + if (parenthesisBalance(normalizedLine) > 0) { + bufferedLine = normalizedLine; + continue; + } + + mergedLines.push(normalizedLine); + } + + if (bufferedLine) { + mergedLines.push(bufferedLine); + } + + return mergedLines; + } + + private static isLikelySkill({ + skill, + followingLines, + }: SkillCandidateContext): boolean { + const nextLine = followingLines[0] ?? ''; + const nextLineSuggestsExperience = + looksLikeExperienceDetailText(nextLine) || + /^\d+\s+(years?|months?|anos?|meses?)/i.test(nextLine); + const looksLikeCompanyOrInstitution = + /[\s,]/.test(skill) && + looksLikeOrganizationNameText(skill) && + nextLineSuggestsExperience; + + return ( + skill.length > 1 && + skill.length < 50 && + !looksLikeCompanyOrInstitution && + !looksLikeExperienceDetailText(skill) && + !isSectionHeaderText(skill) && + !/^page\s+\d+/i.test(skill) && + !/^\d+$/.test(skill) + ); + } +} + +function isHeaderForSection(text: string, section: ParserLineSection): boolean { + const header = getParserLineSectionHeader(text); + + return header?.kind === 'target' && header.section === section; +} + +function isLanguageSectionHeaderText(text: string): boolean { + return /^languages?$/i.test(text) || isHeaderForSection(text, 'languages'); +} + +function parenthesisBalance(text: string): number { + return ( + Array.from(text.matchAll(/\(/g)).length - + Array.from(text.matchAll(/\)/g)).length + ); +} + +function looksLikeParenthesizedLanguageContinuation(text: string): boolean { + return /^\([^)]+(?:\)|$)$/u.test(text); +} + +function looksLikeLanguageBaseWithoutProficiency(text: string): boolean { + return ( + !REGEX_PATTERNS.LANGUAGE_PROFICIENCY.test(text) && + /^[\p{L}\s.+-]+(?:\s*\([\p{L}\s.+-]+\))?$/u.test(text) + ); } diff --git a/src/parsers/structural-parser.ts b/src/parsers/structural-parser.ts index 607759a..cb1bf2c 100644 --- a/src/parsers/structural-parser.ts +++ b/src/parsers/structural-parser.ts @@ -1,41 +1,40 @@ +import { getDocumentProxy, extractTextItems } from 'unpdf'; import { TextItem, LayoutInfo } from '../types/structural.js'; +import { getTextItemStructuralColumn } from '../utils/structural-layout.js'; export class StructuralParser { - static async extractStructuredText(pdfBuffer: Buffer): Promise<{ + private static readonly MIN_LEFT_ITEMS_FOR_TWO_COLUMN = 2; + private static readonly MIN_RIGHT_ITEMS_FOR_TWO_COLUMN = 15; + private static readonly MIN_MAIN_CLUSTER_ITEMS = 5; + private static readonly MIN_MAIN_COLUMN_X = 180; + private static readonly MAIN_COLUMN_LEFT_TOLERANCE = 8; + private static readonly MIN_COLUMN_GAP = 18; + + static async extractStructuredText( + pdfInput: ArrayBuffer | Uint8Array + ): Promise<{ textItems: TextItem[]; layout: LayoutInfo; }> { - // Use legacy build for Node.js compatibility - const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs'); - - // Set worker source from node_modules - (pdfjs.GlobalWorkerOptions as any).workerSrc = - process.cwd() + '/node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs'; - - const uint8Array = new Uint8Array(pdfBuffer); - const pdf = await pdfjs.getDocument({ - data: uint8Array - }).promise; - const allTextItems: TextItem[] = []; - - // Extract text from all pages - for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) { - const page = await pdf.getPage(pageNum); - const textContent = await page.getTextContent(); - - const pageTextItems = textContent.items.map((item: any) => ({ - text: item.str, - x: item.transform[4], - y: item.transform[5], - fontSize: item.height, - fontFamily: item.fontName || 'unknown', - width: item.width, - height: item.height, - transform: item.transform, - })); - - allTextItems.push(...pageTextItems); - } + const data = new Uint8Array(pdfInput); + const pdf = await getDocumentProxy(data); + const { items } = await extractTextItems(pdf); + + const allTextItems: TextItem[] = items.flatMap((pageItems, pageIndex) => + pageItems + .map(item => ({ + text: item.str.trim(), + x: item.x, + // PDF pages reuse the same coordinate space; offset pages before flattening. + y: item.y - pageIndex * 10000, + pageIndex, + fontSize: item.fontSize, + fontFamily: item.fontFamily || 'unknown', + width: item.width, + height: item.height, + })) + .filter(item => item.text.length > 0) + ); // Detect layout const layout = this.detectLayout(allTextItems); @@ -46,25 +45,97 @@ export class StructuralParser { }; } - private static detectLayout(textItems: TextItem[]): LayoutInfo { - // Analyze X positions to detect columns + static detectLayout(textItems: TextItem[]): LayoutInfo { + if (textItems.length === 0) { + return { + type: 'single-column', + }; + } + + const pageLayouts = this.detectPageLayouts(textItems); + const twoColumnPageLayouts = pageLayouts.filter( + layout => layout.type === 'two-column' + ); + const globalLayout = this.detectPageLayout(textItems); + const twoColumnLayouts = + twoColumnPageLayouts.length > 0 + ? twoColumnPageLayouts + : globalLayout.type === 'two-column' + ? [globalLayout] + : []; + + if (twoColumnLayouts.length === 0) { + return { + type: 'single-column', + pageLayouts, + }; + } + + return { + type: 'two-column', + pageLayouts, + sidebarBounds: this.mergeBounds( + twoColumnLayouts.map(layout => layout.sidebarBounds) + ), + mainBounds: this.mergeBounds( + twoColumnLayouts.map(layout => layout.mainBounds) + ), + }; + } + + private static detectPageLayouts(textItems: TextItem[]): LayoutInfo[] { + const itemsByPage = new Map(); + + for (const item of textItems) { + const pageIndex = item.pageIndex ?? inferPageIndex(item); + const pageItems = itemsByPage.get(pageIndex) ?? []; + + pageItems.push(item); + itemsByPage.set(pageIndex, pageItems); + } + + const pageLayouts: LayoutInfo[] = []; + + for (const [pageIndex, pageItems] of Array.from(itemsByPage.entries()).sort( + ([firstPage], [secondPage]) => firstPage - secondPage + )) { + pageLayouts[pageIndex] = this.detectPageLayout(pageItems); + } + + return pageLayouts; + } + + private static detectPageLayout(textItems: TextItem[]): LayoutInfo { const xPositions = textItems.map(item => item.x); const minX = Math.min(...xPositions); const maxX = Math.max(...xPositions); + const mainLeft = this.findMainColumnLeft(textItems); + + if (mainLeft === undefined) { + return { + type: 'single-column', + }; + } - // Look for two distinct clusters of X positions - // Based on analysis, left column is around x=20, right column around x=220 - const leftItems = textItems.filter(item => item.x < 150); - const rightItems = textItems.filter(item => item.x >= 150); + const mainBoundary = mainLeft - this.MAIN_COLUMN_LEFT_TOLERANCE; + const leftItems = textItems.filter( + item => item.x < mainBoundary && !this.isPageNumberItem(item) + ); + const rightItems = textItems.filter(item => item.x >= mainBoundary); - // Check if there's a significant gap indicating columns - const hasLeftColumn = leftItems.length > 20; - const hasRightColumn = rightItems.length > 20; + if ( + leftItems.length >= this.MIN_LEFT_ITEMS_FOR_TWO_COLUMN && + rightItems.length >= this.MIN_RIGHT_ITEMS_FOR_TWO_COLUMN + ) { + const sidebarRight = Math.max( + ...leftItems.map(item => item.x + (item.width || 100)) + ); - if (hasLeftColumn && hasRightColumn) { - // Two-column layout detected - const sidebarRight = Math.max(...leftItems.map(item => item.x + (item.width || 100))); - const mainLeft = Math.min(...rightItems.map(item => item.x)); + if (mainLeft - sidebarRight < this.MIN_COLUMN_GAP) { + return { + type: 'single-column', + }; + } return { type: 'two-column', @@ -88,14 +159,75 @@ export class StructuralParser { }; } - static groupTextByProximity(textItems: TextItem[], maxYDistance = 5): TextItem[][] { + private static findMainColumnLeft(textItems: TextItem[]): number | undefined { + const clusters = new Map(); + + for (const item of textItems) { + if (item.x < this.MIN_MAIN_COLUMN_X || this.isPageNumberItem(item)) { + continue; + } + + const clusterKey = Math.round(item.x / 5) * 5; + const clusterItems = clusters.get(clusterKey) ?? []; + + clusterItems.push(item.x); + clusters.set(clusterKey, clusterItems); + } + + const [bestCluster] = Array.from(clusters.values()).sort( + (first, second) => second.length - first.length + ); + + if (!bestCluster || bestCluster.length < this.MIN_MAIN_CLUSTER_ITEMS) { + return undefined; + } + + return Math.min(...bestCluster); + } + + private static mergeBounds( + bounds: Array + ): LayoutInfo['sidebarBounds'] { + const presentBounds = bounds.filter( + (bound): bound is NonNullable => bound !== undefined + ); + + if (presentBounds.length === 0) { + return undefined; + } + + return { + left: Math.min(...presentBounds.map(bound => bound.left)), + right: Math.max(...presentBounds.map(bound => bound.right)), + top: Math.min(...presentBounds.map(bound => bound.top)), + bottom: Math.max(...presentBounds.map(bound => bound.bottom)), + }; + } + + static groupTextByProximity( + textItems: TextItem[], + maxYDistance = 5 + ): TextItem[][] { // Detect layout first to handle columns separately const layout = this.detectLayout(textItems); if (layout.type === 'two-column') { - // Process each column separately using the fixed boundary - const leftItems = textItems.filter(item => item.x < 150); - const rightItems = textItems.filter(item => item.x >= 150); + const leftItems: TextItem[] = []; + const rightItems: TextItem[] = []; + + for (const item of textItems) { + const column = getTextItemStructuralColumn({ + fallbackColumn: 'right', + item, + layout, + }); + + if (column === 'left') { + leftItems.push(item); + } else { + rightItems.push(item); + } + } const leftGroups = this.groupItemsByY(leftItems, maxYDistance); const rightGroups = this.groupItemsByY(rightItems, maxYDistance); @@ -115,7 +247,14 @@ export class StructuralParser { } } - private static groupItemsByY(textItems: TextItem[], maxYDistance = 5): TextItem[][] { + private static isPageNumberItem(item: TextItem): boolean { + return /^(?:page|\d+|of)$/i.test(item.text.trim()); + } + + private static groupItemsByY( + textItems: TextItem[], + maxYDistance = 5 + ): TextItem[][] { // Sort by Y position (top to bottom) const sorted = [...textItems].sort((a, b) => b.y - a.y); const groups: TextItem[][] = []; @@ -150,7 +289,18 @@ export class StructuralParser { return groups.map(group => { // Sort by X position within group (left to right) const sortedGroup = group.sort((a, b) => a.x - b.x); - return sortedGroup.map(item => item.text).join(' ').trim(); + return sortedGroup + .map(item => item.text) + .join(' ') + .trim(); }); } -} \ No newline at end of file +} + +function inferPageIndex(item: TextItem): number { + if (item.y >= 0) { + return 0; + } + + return Math.ceil(Math.abs(item.y) / 10000); +} diff --git a/src/pdf-source-debug.ts b/src/pdf-source-debug.ts new file mode 100644 index 0000000..212520c --- /dev/null +++ b/src/pdf-source-debug.ts @@ -0,0 +1,36 @@ +import { StructuralParser } from './parsers/structural-parser.js'; +import { createStructuralLines } from './utils/structural-lines.js'; +import type { LayoutInfo, TextItem } from './types/structural.js'; +import type { StructuralLine } from './utils/structural-lines.js'; + +export interface LinkedInPDFSourceDebugArtifacts { + layout: LayoutInfo; + rawText: string; + structuralLines: StructuralLine[]; + textItems: TextItem[]; +} + +export async function extractLinkedInPDFSourceDebug( + input: ArrayBuffer | Uint8Array +): Promise { + const structuralData = await StructuralParser.extractStructuredText(input); + const rawText = createRawTextFromTextItems(structuralData.textItems); + const structuralLines = createStructuralLines({ + layout: structuralData.layout, + textItems: structuralData.textItems, + }); + + return { + layout: structuralData.layout, + rawText, + structuralLines, + textItems: structuralData.textItems, + }; +} + +function createRawTextFromTextItems(textItems: TextItem[]): string { + const groups = StructuralParser.groupTextByProximity(textItems); + const lines = StructuralParser.combineGroupedText(groups); + + return lines.join('\n'); +} diff --git a/src/schemas.ts b/src/schemas.ts new file mode 100644 index 0000000..7ec2405 --- /dev/null +++ b/src/schemas.ts @@ -0,0 +1,130 @@ +import { z } from 'zod'; +import { WARNING_SECTIONS } from './warning-sections.js'; + +export const ContactLinkSchema = z.object({ + label: z.string().optional(), + rawText: z.string(), + url: z.string(), +}); + +export const ContactSchema = z.object({ + email: z.string().optional(), + linkedin_url: z.string().optional(), + links: z.array(ContactLinkSchema).optional(), + location: z.string().optional(), + phone: z.string().optional(), +}); + +export const LanguageSchema = z.object({ + language: z.string(), + proficiency: z.string(), +}); + +export const ParsedProfileDateSchema = z.object({ + iso: z.string(), + precision: z.enum(['year', 'month', 'day']), + text: z.string(), +}); + +const ParsedDateRangeBaseSchema = z.object({ + durationText: z.string().optional(), + originalText: z.string(), + start: ParsedProfileDateSchema, +}); + +export const ParsedDateRangeSchema = z.discriminatedUnion('kind', [ + ParsedDateRangeBaseSchema.extend({ + end: z.undefined().optional(), + kind: z.literal('current'), + }), + ParsedDateRangeBaseSchema.extend({ + end: ParsedProfileDateSchema, + kind: z.literal('completed'), + }), + ParsedDateRangeBaseSchema.extend({ + end: z.undefined().optional(), + kind: z.literal('single'), + }), +]); + +export const ExperienceSchema = z.object({ + company: z.string(), + dates: ParsedDateRangeSchema.optional(), + description: z.string().optional(), + duration: z.string(), + location: z.string().optional(), + title: z.string(), +}); + +export const ExperienceGroupPositionSchema = ExperienceSchema.omit({ + company: true, +}); + +export const ExperienceGroupSchema = z.object({ + company: z.string(), + positions: z.array(ExperienceGroupPositionSchema), + totalDuration: z.string().optional(), +}); + +export const EducationSchema = z.object({ + dates: ParsedDateRangeSchema.optional(), + degree: z.string(), + description: z.string().optional(), + institution: z.string(), + location: z.string().optional(), + year: z.string().optional(), +}); + +export const LinkedInProfileSchema = z.object({ + certifications: z.array(z.string()), + contact: ContactSchema, + education: z.array(EducationSchema), + experience: z.array(ExperienceSchema), + experience_groups: z.array(ExperienceGroupSchema), + headline: z.string().optional(), + honors_awards: z.array(z.string()), + languages: z.array(LanguageSchema), + location: z.string().optional(), + name: z.string().optional(), + projects: z.array(z.string()), + publications: z.array(z.string()), + summary: z.string().optional(), + top_skills: z.array(z.string()), + volunteer_work: z.array(z.string()), +}); + +const MissingProfileFieldWarningSchema = z.object({ + code: z.literal('missing_profile_field'), + field: z.enum(['profile.name', 'profile.contact.email']), + message: z.string(), +}); + +export const WarningSectionSchema = z.enum(WARNING_SECTIONS); + +const SectionParseWarningSchema = z.object({ + code: z.literal('section_parse_warning'), + entry: z.number().int().nonnegative().optional(), + field: z.string(), + message: z.string(), + rawText: z.string().optional(), + section: WarningSectionSchema, +}); + +export const ParseWarningSchema = z.union([ + MissingProfileFieldWarningSchema, + SectionParseWarningSchema, +]); + +export const ParseDiagnosticsSchema = z.object({ + confidence: z.number().min(0).max(1), + isEmpty: z.boolean(), + isLikelyLinkedInExport: z.boolean(), + sectionsFound: z.array(WarningSectionSchema), +}); + +export const ParseResultSchema = z.object({ + diagnostics: ParseDiagnosticsSchema, + profile: LinkedInProfileSchema, + rawText: z.string().optional(), + warnings: z.array(ParseWarningSchema), +}); diff --git a/src/types/profile.ts b/src/types/profile.ts new file mode 100644 index 0000000..9fdecdd --- /dev/null +++ b/src/types/profile.ts @@ -0,0 +1,133 @@ +import type { WarningSection } from '../warning-sections.js'; +export type { WarningSection }; + +export interface Contact { + email?: string; + phone?: string; + linkedin_url?: string; + location?: string; + links?: ContactLink[]; +} + +export interface ContactLink { + label?: string; + rawText: string; + url: string; +} + +export interface Language { + language: string; + proficiency: string; +} + +export type ParsedProfileDatePrecision = 'year' | 'month' | 'day'; + +export interface ParsedProfileDate { + iso: string; + precision: ParsedProfileDatePrecision; + text: string; +} + +interface ParsedDateRangeBase { + originalText: string; + start: ParsedProfileDate; + durationText?: string; +} + +export type ParsedDateRange = + | (ParsedDateRangeBase & { + kind: 'current'; + end?: undefined; + }) + | (ParsedDateRangeBase & { + kind: 'completed'; + end: ParsedProfileDate; + }) + | (ParsedDateRangeBase & { + kind: 'single'; + end?: undefined; + }); + +export interface Experience { + title: string; + company: string; + duration: string; + dates?: ParsedDateRange; + location?: string; + description?: string; +} + +export type ExperienceGroupPosition = Omit; + +export interface ExperienceGroup { + company: string; + positions: ExperienceGroupPosition[]; + totalDuration?: string; +} + +export interface Education { + degree: string; + institution: string; + year?: string; + dates?: ParsedDateRange; + location?: string; + description?: string; +} + +export interface LinkedInProfile { + name?: string; + headline?: string; + location?: string; + contact: Contact; + top_skills: string[]; + languages: Language[]; + certifications: string[]; + volunteer_work: string[]; + projects: string[]; + publications: string[]; + honors_awards: string[]; + summary?: string; + experience_groups: ExperienceGroup[]; + experience: Experience[]; + education: Education[]; +} + +export interface ParseOptions { + includeRawText?: boolean; +} + +export interface ParseDiagnostics { + confidence: number; + isEmpty: boolean; + isLikelyLinkedInExport: boolean; + sectionsFound: WarningSection[]; +} + +export interface MissingProfileFieldWarning { + code: 'missing_profile_field'; + field: 'profile.name' | 'profile.contact.email'; + message: string; +} + +export interface SectionParseWarning { + code: 'section_parse_warning'; + section: WarningSection; + entry?: number; + field: string; + message: string; + rawText?: string; +} + +export type ParseWarning = MissingProfileFieldWarning | SectionParseWarning; + +export interface ParseResult { + profile: LinkedInProfile; + warnings: ParseWarning[]; + diagnostics: ParseDiagnostics; + rawText?: string; +} + +export interface ParsedSectionResult { + value: T; + warnings: SectionParseWarning[]; +} diff --git a/src/types/structural.ts b/src/types/structural.ts index a331686..c5d5d2a 100644 --- a/src/types/structural.ts +++ b/src/types/structural.ts @@ -1,16 +1,19 @@ +import type { ParsedDateRange } from './profile.js'; + export interface TextItem { text: string; x: number; y: number; + pageIndex?: number; fontSize: number; fontFamily: string; width: number; height: number; - transform: number[]; } export interface LayoutInfo { type: 'two-column' | 'single-column'; + pageLayouts?: LayoutInfo[]; sidebarBounds?: { left: number; right: number; @@ -34,14 +37,21 @@ export interface WorkExperience { export interface Position { title: string; duration: string; + dates?: ParsedDateRange; location?: string; description?: string; } export interface StructuralSection { - type: 'organization' | 'position' | 'duration' | 'location' | 'description' | 'other'; + type: + | 'organization' + | 'position' + | 'duration' + | 'location' + | 'description' + | 'other'; text: string; fontSize: number; y: number; confidence: number; -} \ No newline at end of file +} diff --git a/src/utils/date-parser.ts b/src/utils/date-parser.ts new file mode 100644 index 0000000..7500b0a --- /dev/null +++ b/src/utils/date-parser.ts @@ -0,0 +1,558 @@ +import * as chrono from 'chrono-node'; +import type { ParsedComponents, ParsedResult } from 'chrono-node'; +import type { ParsedDateRange, ParsedProfileDate } from '../types/profile.js'; + +type ChronoParser = { + parse(text: string): ParsedResult[]; +}; + +interface DatePortion { + text: string; + durationText?: string; +} + +const CHRONO_PARSERS: ChronoParser[] = [ + chrono.en.casual, + chrono.pt.casual, + chrono.es.casual, + chrono.fr.casual, + chrono.de.casual, + chrono.it.casual, + chrono.nl.casual, +]; + +const CURRENT_WORDS = [ + 'present', + 'current', + 'presente', + 'atual', + 'présent', + 'presentes', + 'actual', +]; + +const DURATION_WORDS = [ + 'yr', + 'yrs', + 'year', + 'years', + 'mo', + 'mos', + 'month', + 'months', + 'jahr', + 'jahre', + 'ano', + 'anos', + 'mes', + 'mês', + 'meses', +]; + +const MONTH_REPLACEMENTS: ReadonlyArray = [ + ['jan', 'January'], + ['january', 'January'], + ['janeiro', 'January'], + ['janvier', 'January'], + ['enero', 'January'], + ['januar', 'January'], + ['gennaio', 'January'], + ['januari', 'January'], + ['feb', 'February'], + ['february', 'February'], + ['fevereiro', 'February'], + ['février', 'February'], + ['fevrier', 'February'], + ['febrero', 'February'], + ['februar', 'February'], + ['febbraio', 'February'], + ['februari', 'February'], + ['mar', 'March'], + ['march', 'March'], + ['março', 'March'], + ['marco', 'March'], + ['mars', 'March'], + ['marzo', 'March'], + ['märz', 'March'], + ['maerz', 'March'], + ['maart', 'March'], + ['apr', 'April'], + ['abril', 'April'], + ['avril', 'April'], + ['aprile', 'April'], + ['april', 'April'], + ['maio', 'May'], + ['may', 'May'], + ['mayo', 'May'], + ['mai', 'May'], + ['maggio', 'May'], + ['mei', 'May'], + ['jun', 'June'], + ['june', 'June'], + ['junho', 'June'], + ['juin', 'June'], + ['junio', 'June'], + ['juni', 'June'], + ['giugno', 'June'], + ['jul', 'July'], + ['july', 'July'], + ['julho', 'July'], + ['juillet', 'July'], + ['julio', 'July'], + ['juli', 'July'], + ['luglio', 'July'], + ['aug', 'August'], + ['agosto', 'August'], + ['août', 'August'], + ['aout', 'August'], + ['august', 'August'], + ['sep', 'September'], + ['sept', 'September'], + ['setembro', 'September'], + ['septembre', 'September'], + ['septiembre', 'September'], + ['september', 'September'], + ['settembre', 'September'], + ['oct', 'October'], + ['out', 'October'], + ['outubro', 'October'], + ['octobre', 'October'], + ['octubre', 'October'], + ['oktober', 'October'], + ['ottobre', 'October'], + ['october', 'October'], + ['nov', 'November'], + ['novembro', 'November'], + ['novembre', 'November'], + ['noviembre', 'November'], + ['november', 'November'], + ['dec', 'December'], + ['dez', 'December'], + ['dezembro', 'December'], + ['décembre', 'December'], + ['decembre', 'December'], + ['diciembre', 'December'], + ['dezember', 'December'], + ['dicembre', 'December'], + ['december', 'December'], +]; + +const CURRENT_TEXT_PATTERN = createWordListPattern(CURRENT_WORDS); +const DURATION_TEXT_PATTERN = createWordListPattern(DURATION_WORDS); +const MONTH_REPLACEMENT_PATTERNS = MONTH_REPLACEMENTS.map( + ([localizedMonth, englishMonth]) => ({ + englishMonth, + pattern: new RegExp( + `(^|[^\\p{L}])${escapeRegExp(localizedMonth)}(?=[^\\p{L}]|$)`, + 'giu' + ), + }) +); +const COMPACT_YEAR_RANGE_PATTERN = new RegExp( + `^((?:19|20)\\d{2})-((?:19|20)\\d{2}|${CURRENT_WORDS.map(escapeRegExp).join('|')})$`, + 'iu' +); + +export function parseProfileDateRange( + text: string +): ParsedDateRange | undefined { + const originalText = cleanDateText(text); + + if (!originalText) { + return undefined; + } + + if (!hasProfileDateSignal(originalText)) { + return undefined; + } + + const datePortion = extractDatePortion(originalText); + const normalizedText = normalizeLocalizedDateText(datePortion.text); + const rangeParts = splitDateRange(normalizedText); + const isCurrent = rangeParts.some(isCurrentText); + + if (rangeParts.length >= 2) { + const start = parseProfileDate(rangeParts[0]); + const end = isCurrentText(rangeParts[1]) + ? undefined + : parseProfileDate(rangeParts[1]); + + if (!start || (!end && !isCurrent)) { + return undefined; + } + + return createDateRange({ + durationText: datePortion.durationText, + end, + isCurrent, + originalText, + start, + }); + } + + const chronoRange = parseWithChrono(normalizedText); + if (chronoRange) { + return createDateRange({ + durationText: datePortion.durationText, + end: chronoRange.end, + isCurrent, + originalText, + start: chronoRange.start, + }); + } + + const start = parseProfileDate(normalizedText); + + if (!start) { + return undefined; + } + + return createDateRange({ + durationText: datePortion.durationText, + isCurrent, + originalText, + start, + }); +} + +export function extractProfileDateRangeText(text: string): string | undefined { + const originalText = cleanDateText(text); + + if (!parseProfileDateRange(originalText)) { + return undefined; + } + + return extractDatePortion(originalText).text; +} + +export function looksLikeDateRangeText(text: string): boolean { + return parseProfileDateRange(text) !== undefined; +} + +function createDateRange({ + durationText, + end, + isCurrent, + originalText, + start, +}: { + durationText?: string; + end?: ParsedProfileDate; + isCurrent: boolean; + originalText: string; + start: ParsedProfileDate; +}): ParsedDateRange { + const base = { + ...(durationText ? { durationText } : {}), + originalText, + start, + }; + + if (isCurrent) { + return { + ...base, + kind: 'current', + }; + } + + if (end) { + return { + ...base, + end, + kind: 'completed', + }; + } + + return { + ...base, + kind: 'single', + }; +} + +function parseWithChrono( + text: string +): { start: ParsedProfileDate; end?: ParsedProfileDate } | undefined { + for (const parser of CHRONO_PARSERS) { + const result = parser + .parse(text) + .find(candidate => candidate.text.trim().length >= 4); + + if (!result) { + continue; + } + + const start = createParsedProfileDate(result.start, result.text); + + if (!start) { + continue; + } + + return { + end: result.end + ? createParsedProfileDate(result.end, endTextFromChronoResult(result)) + : undefined, + start, + }; + } + + return undefined; +} + +function parseProfileDate(text: string): ParsedProfileDate | undefined { + const normalizedText = normalizeLocalizedDateText(text); + const yearOnlyMatch = normalizedText.match(/^(19|20)\d{2}$/); + + if (yearOnlyMatch) { + return { + iso: normalizedText, + precision: 'year', + text: normalizedText, + }; + } + + const isoMonthOrDayMatch = normalizedText.match( + /^((?:19|20)\d{2})-(0[1-9]|1[0-2])(?:-(0[1-9]|[12]\d|3[01]))?$/ + ); + + if (isoMonthOrDayMatch) { + const year = Number(isoMonthOrDayMatch[1]); + const month = Number(isoMonthOrDayMatch[2]); + const dayText = isoMonthOrDayMatch[3]; + + if (dayText) { + const day = Number(dayText); + + return { + iso: formatIsoDate({ day, month, precision: 'day', year }), + precision: 'day', + text: normalizedText, + }; + } + + return { + iso: formatIsoDate({ month, precision: 'month', year }), + precision: 'month', + text: normalizedText, + }; + } + + const chronoRange = parseWithChrono(normalizedText); + + return chronoRange?.start; +} + +function createParsedProfileDate( + components: ParsedComponents, + text: string +): ParsedProfileDate | undefined { + const year = components.get('year'); + + if (!year) { + return undefined; + } + + const month = components.get('month'); + const day = components.get('day'); + const precision = components.isCertain('day') + ? 'day' + : components.isCertain('month') + ? 'month' + : 'year'; + + if (precision === 'day') { + if (!month || !day) { + return undefined; + } + + return { + iso: formatIsoDate({ day, month, precision, year }), + precision, + text: cleanDateText(text), + }; + } + + if (precision === 'month') { + if (!month) { + return undefined; + } + + return { + iso: formatIsoDate({ month, precision, year }), + precision, + text: cleanDateText(text), + }; + } + + return { + iso: formatIsoDate({ precision, year }), + precision, + text: cleanDateText(text), + }; +} + +type FormatIsoDateParams = + | { + precision: 'year'; + year: number; + } + | { + month: number; + precision: 'month'; + year: number; + } + | { + day: number; + month: number; + precision: 'day'; + year: number; + }; + +function formatIsoDate(params: FormatIsoDateParams): string { + const { precision, year } = params; + + if (precision === 'year') { + return `${year}`; + } + + const paddedMonth = String(params.month).padStart(2, '0'); + + if (precision === 'month') { + return `${year}-${paddedMonth}`; + } + + return `${year}-${paddedMonth}-${String(params.day).padStart(2, '0')}`; +} + +function endTextFromChronoResult(result: ParsedResult): string { + if (!result.end) { + return ''; + } + + const delimiterIndex = result.text.search(/\s[-–—]\s/); + + return delimiterIndex === -1 + ? result.text + : result.text.slice(delimiterIndex + 3); +} + +function extractDatePortion(text: string): DatePortion { + // LinkedIn often appends durations after a middle dot or pipe; keep the left + // side as the date range while preserving duration text for the parsed result. + const dotParts = text.split(/\s*[·|]\s*/); + const durationText = dotParts + .slice(1) + .map(part => cleanDateText(part.replace(/[()]/g, ''))) + .find(part => containsDurationWord(part)); + const parentheticalDurationMatch = Array.from( + dotParts[0].matchAll(/\(([^)]*)\)/gu) + ).find(match => containsDurationWord(match[1])); + const parentheticalDuration = parentheticalDurationMatch + ? cleanDateText(parentheticalDurationMatch[1]) + : undefined; + // Parenthetical durations belong in durationText, not in the chrono input. + const dateText = trimLeadingNonDateText( + parentheticalDurationMatch + ? dotParts[0].replace(parentheticalDurationMatch[0], '') + : dotParts[0] + ); + + return { + durationText: durationText ?? parentheticalDuration, + text: cleanDateText(dateText), + }; +} + +function trimLeadingNonDateText(text: string): string { + const normalizedText = cleanDateText(text); + // Search the localized-normalized version so month names such as "janeiro" + // are found using the same English month vocabulary chrono understands. + const normalizedEnglishText = normalizeLocalizedDateText(normalizedText); + // Find the first credible date token and discard leading prose like + // "Provided support from" before handing the date portion to parsers. + const dateStartIndex = normalizedEnglishText.search( + /\b(?:(?:19|20)\d{2}|January|February|March|April|May|June|July|August|September|October|November|December)\b/iu + ); + + return dateStartIndex <= 0 + ? normalizedText + : normalizedText.slice(dateStartIndex).trim(); +} + +function splitDateRange(text: string): string[] { + // Require spaces around range separators so ISO dates and "Jan-2020" remain + // single dates; compact year ranges are handled explicitly below. + const rangeParts = text + .split(/\s+-\s+/u) + .map(part => cleanDateText(part)) + .filter(Boolean); + + if (rangeParts.length > 1) { + return rangeParts; + } + + const compactYearRange = cleanDateText(text).match( + COMPACT_YEAR_RANGE_PATTERN + ); + if (compactYearRange) { + return [compactYearRange[1], compactYearRange[2]]; + } + + return [cleanDateText(text)]; +} + +function normalizeLocalizedDateText(text: string): string { + let normalizedText = cleanDateText(text).toLowerCase(); + + // Replace localized month names with English equivalents before chrono runs. + for (const { englishMonth, pattern } of MONTH_REPLACEMENT_PATTERNS) { + normalizedText = normalizedText.replace(pattern, `$1${englishMonth}`); + } + + return ( + normalizedText + // Collapse forms such as "março de 2024" or "March of 2024". + .replace( + /\b(January|February|March|April|May|June|July|August|September|October|November|December)\s+(?:de|of|del|du)\s+((?:19|20)\d{2})\b/giu, + '$1 $2' + ) + // Normalize whitespace introduced by replacements and PDF extraction. + .replace(/\s+/g, ' ') + .trim() + ); +} + +function cleanDateText(text: string): string { + return text + .replace(/[\uE000-\uF8FF]/g, ' ') + .replace(/\u00A0/g, ' ') + .replace(/[–—−]/g, '-') + .replace(/\s+/g, ' ') + .trim(); +} + +function isCurrentText(text: string): boolean { + const normalizedText = cleanDateText(text).toLowerCase(); + + return CURRENT_TEXT_PATTERN.test(normalizedText); +} + +function containsDurationWord(text: string): boolean { + const lowerText = text.toLowerCase(); + + return DURATION_TEXT_PATTERN.test(lowerText); +} + +function hasProfileDateSignal(text: string): boolean { + return /\b(?:19|20)\d{2}\b/.test(text) || isCurrentText(text); +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function createWordListPattern(words: readonly string[]): RegExp { + return new RegExp( + `(^|[^\\p{L}])(?:${words.map(escapeRegExp).join('|')})(?=[^\\p{L}]|$)`, + 'iu' + ); +} diff --git a/src/utils/location-classifier.ts b/src/utils/location-classifier.ts new file mode 100644 index 0000000..d675142 --- /dev/null +++ b/src/utils/location-classifier.ts @@ -0,0 +1,651 @@ +type LocationStructuralContext = 'profile' | 'metadata' | 'after-duration'; + +type LocationSignal = + | 'remote' + | 'postal-code' + | 'exact-place' + | 'known-place' + | 'country-or-region' + | 'admin-region' + | 'region-code' + | 'comma-region' + | 'qualified-area' + | 'proper-shape' + | 'after-duration' + | 'too-long' + | 'bad-character' + | 'duration' + | 'sentence-verb' + | 'prose-shape' + | 'relational-connector' + | 'title-or-organization'; + +interface LocationClassifierContext { + structuralContext?: LocationStructuralContext; +} + +interface ClassifyLocationTextParams { + text: string; + context?: LocationClassifierContext; +} + +interface LocationClassification { + isLocation: boolean; + score: number; + signals: readonly LocationSignal[]; +} + +const locationThreshold = 4; +const afterDurationLocationThreshold = 4; + +const connectorWords: ReadonlySet = new Set([ + 'and', + 'de', + 'del', + 'la', + 'of', + 'the', +]); + +const remoteLocationTexts: ReadonlySet = new Set([ + 'hybrid', + 'on site', + 'on-site', + 'onsite', + 'remote', +]); + +const knownPlaceNames: ReadonlySet = new Set([ + 'atlanta', + 'austin', + 'berlin', + 'baltimore', + 'boston', + 'charlottesville', + 'chicago', + 'dallas', + 'denver', + 'dubai', + 'geneva', + 'greenwich', + 'harjumaa', + 'the hague', + 'houston', + 'incheon', + 'kauai', + 'london', + 'los angeles', + 'miami', + 'minneapolis st paul', + 'minneapolis', + 'munich', + 'münchen', + 'new york', + 'new york city', + 'palo alto', + 'paris', + 'rio de janeiro', + 'reno', + 'san diego', + 'san francisco', + 'sao paulo', + 'seattle', + 'seoul', + 'singapore', + 'smithfield', + 'st louis', + 'stamford', + 'sydney', + 'tallinn', + 'tel aviv', + 'tokyo', + 'toronto', + 'washington', +]); + +const countryAndRegionNames: ReadonlySet = new Set([ + 'australia', + 'brasil', + 'brazil', + 'canada', + 'china', + 'england', + 'estonia', + 'france', + 'germany', + 'deutschland', + 'india', + 'ireland', + 'israel', + 'italy', + 'japan', + 'korea', + 'mexico', + 'portugal', + 'scotland', + 'singapore', + 'spain', + 'netherlands', + 'switzerland', + 'united kingdom', + 'united states', + 'united arab emirates', + 'vereinigte arabische emirate', + 'vatican city state holy see', + 'wales', +]); + +const adminRegionNames: ReadonlySet = new Set([ + 'bayern', + 'california', + 'connecticut', + 'colorado', + 'florida', + 'georgia', + 'harjumaa', + 'hawaii', + 'illinois', + 'maryland', + 'massachusetts', + 'michigan', + 'minnesota', + 'nevada', + 'new york', + 'ohio', + 'ontario', + 'pennsylvania', + 'quebec', + 'rhode island', + 'texas', + 'virginia', +]); + +const regionCodes: ReadonlySet = new Set([ + 'ak', + 'al', + 'ar', + 'az', + 'ca', + 'can', + 'co', + 'dc', + 'de', + 'fl', + 'ga', + 'hi', + 'ia', + 'id', + 'il', + 'in', + 'ks', + 'ky', + 'la', + 'ma', + 'md', + 'me', + 'mi', + 'mn', + 'mo', + 'ms', + 'mt', + 'nc', + 'nd', + 'ne', + 'nh', + 'nj', + 'nm', + 'nv', + 'ny', + 'oh', + 'ok', + 'on', + 'or', + 'pa', + 'qc', + 'ri', + 'sc', + 'sd', + 'tn', + 'tx', + 'uk', + 'us', + 'usa', + 'ut', + 'va', + 'vt', + 'wa', + 'wi', + 'wv', + 'wy', +]); + +const genericLocationQualifiers: ReadonlySet = new Set([ + 'area', + 'bay', + 'county', + 'metropolitan', + 'metro', + 'province', + 'region', + 'state', +]); + +const titleOrOrganizationWords: ReadonlySet = new Set([ + 'assistant', + 'associate', + 'chief', + 'college', + 'company', + 'consulate', + 'consultant', + 'corporate', + 'corporation', + 'director', + 'engineer', + 'federation', + 'finance', + 'fellow', + 'foundation', + 'founder', + 'forex', + 'group', + 'head', + 'intern', + 'investor', + 'law', + 'manager', + 'officer', + 'partner', + 'partners', + 'president', + 'principal', + 'professor', + 'researcher', + 'school', + 'scientist', + 'service', + 'services', + 'university', +]); + +const ambiguousRegionCodes: ReadonlySet = new Set(['in', 'me', 'or']); + +const sentenceStartVerbs: ReadonlySet = new Set([ + 'built', + 'created', + 'developed', + 'drove', + 'enabled', + 'founded', + 'grew', + 'helped', + 'implemented', + 'improved', + 'led', + 'managed', + 'owned', + 'provided', + 'served', + 'supported', + 'worked', +]); + +export function classifyLocationText({ + context, + text, +}: ClassifyLocationTextParams): LocationClassification { + const normalizedText = normalizeVisibleText(text); + const signals: LocationSignal[] = []; + let score = 0; + + function add(signal: LocationSignal, value: number): void { + if (!signals.includes(signal)) { + signals.push(signal); + } + + score += value; + } + + if (normalizedText.length === 0) { + return { isLocation: false, score: 0, signals }; + } + + if (normalizedText.length > 120) { + add('too-long', -5); + return { isLocation: false, score, signals }; + } + + if (/[$@!?;:]/u.test(normalizedText)) { + add('bad-character', -5); + return { isLocation: false, score, signals }; + } + + if (looksLikeDurationText(normalizedText)) { + add('duration', -5); + return { isLocation: false, score, signals }; + } + + const lookupText = normalizeLookupText(normalizedText); + const words = visibleWords(normalizedText); + const lookupWords = lookupWordsFor(lookupText); + + if (startsWithSentenceVerb(lookupWords)) { + add('sentence-verb', -4); + } + + if (words.length > 8 || endsWithSentencePunctuation(words)) { + add('prose-shape', -3); + } + + const hasBlockedDomainWord = lookupWords.some(word => + titleOrOrganizationWords.has(word) + ); + if (hasBlockedDomainWord) { + add('title-or-organization', -4); + } + + const hasRelationalConnector = lookupWords.some( + word => word === 'in' || word === 'à' + ); + + if (remoteLocationTexts.has(lookupText)) { + add('remote', 5); + } + + if (/^\d{5}(?:-\d{3,4})?$/u.test(normalizedText)) { + add('postal-code', 5); + } + + const exactPlace = knownPlaceNames.has(lookupText); + const hasKnownPlace = + exactPlace || containsKnownPhrase(lookupText, knownPlaceNames); + const hasCountryOrRegion = + countryAndRegionNames.has(lookupText) || + containsKnownPhrase(lookupText, countryAndRegionNames); + const hasAdminRegion = + adminRegionNames.has(lookupText) || + containsKnownPhrase(lookupText, adminRegionNames); + const hasRegionCode = hasContextualRegionCode({ + hasKnownPlace, + lookupWords, + normalizedText, + }); + const hasCommaRegion = hasCommaSeparatedRegionEvidence(normalizedText); + const hasQualifiedArea = + hasGenericLocationQualifier(lookupWords) && + (hasKnownPlace || hasCountryOrRegion || hasAdminRegion || hasRegionCode); + const hasProperShape = looksLikeProperLocationShape(words); + + if (hasRelationalConnector && hasKnownPlace && hasCountryOrRegion) { + add('relational-connector', -6); + } + + if (exactPlace) { + add('exact-place', context?.structuralContext === 'metadata' ? 2 : 4); + } else if (hasKnownPlace) { + add('known-place', 3); + } + + if (hasCountryOrRegion) { + add('country-or-region', countryAndRegionNames.has(lookupText) ? 4 : 3); + } + + if (hasAdminRegion) { + add('admin-region', adminRegionNames.has(lookupText) ? 4 : 2); + } + + if (hasRegionCode) { + add('region-code', 2); + } + + if (hasCommaRegion) { + add('comma-region', 2); + } + + if (hasQualifiedArea) { + add('qualified-area', 2); + } + + if (hasProperShape) { + add('proper-shape', 1); + } + + if (context?.structuralContext === 'after-duration') { + add('after-duration', 1); + } + + const threshold = + context?.structuralContext === 'after-duration' + ? afterDurationLocationThreshold + : locationThreshold; + + return { + isLocation: score >= threshold && !hardReject(signals), + score, + signals, + }; +} + +export function isLikelyScoredLocationText(text: string): boolean { + return classifyLocationText({ text }).isLocation; +} + +function normalizeVisibleText(text: string): string { + return text + .replace(/[\uE000-\uF8FF]/g, ' ') + .replace(/\s+/g, ' ') + .replace(/,+$/u, '') + .trim(); +} + +function normalizeLookupText(text: string): string { + return text + .normalize('NFKC') + .replace(/[“”]/g, '"') + .replace(/[‘’]/g, "'") + .replace(/[‐‑‒–—]/g, '-') + .replace(/-/g, ' ') + .replace(/[().,]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); +} + +function visibleWords(text: string): string[] { + return text + .split(/\s+/u) + .map(word => word.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}.]+$/gu, '')) + .filter(word => word.length > 0); +} + +function lookupWordsFor(lookupText: string): string[] { + return lookupText.split(/\s+/u).filter(word => word.length > 0); +} + +function endsWithSentencePunctuation(words: readonly string[]): boolean { + const lastWord = words[words.length - 1]; + + return ( + lastWord !== undefined && + /[.!?]$/u.test(lastWord) && + !/^(?:[\p{Lu}]\.)+$/u.test(lastWord) + ); +} + +function hardReject(signals: readonly LocationSignal[]): boolean { + return ( + signals.includes('too-long') || + signals.includes('bad-character') || + signals.includes('duration') || + signals.includes('sentence-verb') + ); +} + +function looksLikeDurationText(text: string): boolean { + return ( + /\b\d{4}\s*[\p{Pd}−]\s*(?:\d{4}|present|current)\b/iu.test(text) || + /\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+\d{4}/iu.test( + text + ) || + /\(\d+\s+(?:years?|months?|anos?|meses?)[^)]*\)/iu.test(text) + ); +} + +function startsWithSentenceVerb(words: readonly string[]): boolean { + const firstWord = words[0]; + + return firstWord !== undefined && sentenceStartVerbs.has(firstWord); +} + +function containsKnownPhrase( + text: string, + phrases: ReadonlySet +): boolean { + for (const phrase of phrases) { + if (phrase.length > 0 && containsDelimitedPhrase(text, phrase)) { + return true; + } + } + + return false; +} + +function containsDelimitedPhrase(text: string, phrase: string): boolean { + let searchIndex = 0; + + while (searchIndex <= text.length) { + const index = text.indexOf(phrase, searchIndex); + + if (index < 0) { + return false; + } + + const before = text[index - 1]; + const after = text[index + phrase.length]; + + if (isDelimiter(before) && isDelimiter(after)) { + return true; + } + + searchIndex = index + phrase.length; + } + + return false; +} + +function isDelimiter(value: string | undefined): boolean { + return value === undefined || !/[\p{L}\p{N}]/u.test(value); +} + +function hasContextualRegionCode({ + hasKnownPlace, + lookupWords, + normalizedText, +}: { + hasKnownPlace: boolean; + lookupWords: readonly string[]; + normalizedText: string; +}): boolean { + const codeWords = regionCodeCandidates(lookupWords).filter(word => + regionCodes.has(word) + ); + + if (codeWords.length === 0) { + return false; + } + + const hasUnambiguousRegionCode = codeWords.some( + word => !ambiguousRegionCodes.has(word) + ); + + if (hasKnownPlace && hasUnambiguousRegionCode) { + return true; + } + + const commaSegments = normalizedText + .split(',') + .map(segment => segment.trim()) + .filter(segment => segment.length > 0); + + if (commaSegments.length < 2) { + return false; + } + + // Comma-separated locations need segment-level evidence so full-text place matches + // or ambiguous region codes do not promote unrelated segments. + return commaSegments.some(segment => { + const lookupSegment = normalizeLookupText(segment); + const segmentWords = lookupWordsFor(lookupSegment); + const hasKnownPlaceSegment = + hasKnownPlace && containsKnownPhrase(lookupSegment, knownPlaceNames); + const hasUnambiguousRegionCodeSegment = regionCodeCandidates( + segmentWords + ).some(word => regionCodes.has(word) && !ambiguousRegionCodes.has(word)); + + return hasKnownPlaceSegment || hasUnambiguousRegionCodeSegment; + }); +} + +function regionCodeCandidates(words: readonly string[]): string[] { + const candidates: string[] = []; + + for (const word of words) { + candidates.push(word); + } + + for (let index = 0; index < words.length - 1; index += 1) { + const firstWord = words[index]; + const secondWord = words[index + 1]; + + if ( + firstWord !== undefined && + secondWord !== undefined && + firstWord.length === 1 && + secondWord.length === 1 + ) { + candidates.push(`${firstWord}${secondWord}`); + } + } + + return candidates; +} + +function hasCommaSeparatedRegionEvidence(text: string): boolean { + const parts = text + .split(',') + .map(part => normalizeLookupText(part)) + .filter(part => part.length > 0); + + if (parts.length < 2 || parts.length > 3) { + return false; + } + + return parts + .slice(1) + .some( + part => + (regionCodes.has(part) && !ambiguousRegionCodes.has(part)) || + countryAndRegionNames.has(part) || + adminRegionNames.has(part) + ); +} + +function hasGenericLocationQualifier(words: readonly string[]): boolean { + return words.some(word => genericLocationQualifiers.has(word)); +} + +function looksLikeProperLocationShape(words: readonly string[]): boolean { + return ( + words.length > 0 && + words.length <= 7 && + words.every(word => { + const lookupWord = normalizeLookupText(word); + + return ( + connectorWords.has(lookupWord) || + regionCodes.has(lookupWord) || + /^[\p{Lu}\d][\p{L}\p{M}\d.'-]*$/u.test(word) + ); + }) + ); +} diff --git a/src/utils/parser-limits.ts b/src/utils/parser-limits.ts new file mode 100644 index 0000000..e977e74 --- /dev/null +++ b/src/utils/parser-limits.ts @@ -0,0 +1 @@ +export const TOP_SKILLS_LIMIT = 10; diff --git a/src/utils/parser-lines.ts b/src/utils/parser-lines.ts new file mode 100644 index 0000000..fa18a3c --- /dev/null +++ b/src/utils/parser-lines.ts @@ -0,0 +1,157 @@ +import type { StructuralLine } from './structural-lines.js'; +import { + PROFILE_SECTION_HEADER_ENTRIES, + type ProfileSectionKey, +} from './profile-section-headers.js'; +import { normalizeWhitespace, splitLines } from './text-utils.js'; + +type ParserLineSource = 'text' | 'structural'; + +export type ParserLineSection = 'identity' | ProfileSectionKey | 'other'; + +export interface NormalizedParserLine { + text: string; + source: ParserLineSource; + index: number; + section: ParserLineSection; + x?: number; + y?: number; + fontSize?: number; + column?: StructuralLine['column']; + gapBefore?: number; + xDelta?: number; + fontDelta?: number; +} + +interface BaseParserLine { + text: string; + source: ParserLineSource; + index: number; + x?: number; + y?: number; + fontSize?: number; + column?: StructuralLine['column']; +} + +interface SectionHeader { + kind: 'target' | 'boundary'; + section?: ParserLineSection; +} + +const TARGET_SECTION_HEADERS = new Map( + PROFILE_SECTION_HEADER_ENTRIES +); + +const BOUNDARY_SECTION_HEADERS = new Set([ + 'courses', + 'patents', + 'organizations', + 'recommendations', + 'interests', +]); + +export function createTextParserLines(text: string): NormalizedParserLine[] { + return enrichParserLines( + splitLines(text).map((line, index) => ({ + index, + source: 'text', + text: normalizeWhitespace(line), + })) + ); +} + +export function createGroupedTextItemParserLines( + groups: { + text: string; + x: number; + y: number; + fontSize: number; + column?: StructuralLine['column']; + }[] +): NormalizedParserLine[] { + return enrichParserLines( + groups.map((line, index) => ({ + fontSize: line.fontSize, + column: line.column, + index, + source: 'structural', + text: normalizeWhitespace(line.text), + x: line.x, + y: line.y, + })) + ); +} + +function normalizeSectionHeader(text: string): string { + return normalizeWhitespace(text) + .normalize('NFD') + .replace(/\p{M}/gu, '') + .replace(/&/g, ' and ') + .replace(/[^a-zA-Z\s]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); +} + +export function getParserLineSectionHeader( + text: string +): SectionHeader | undefined { + const normalizedHeader = normalizeSectionHeader(text); + const section = TARGET_SECTION_HEADERS.get(normalizedHeader); + + if (section) { + return { + kind: 'target', + section, + }; + } + + return BOUNDARY_SECTION_HEADERS.has(normalizedHeader) + ? { kind: 'boundary' } + : undefined; +} + +function enrichParserLines( + baseLines: BaseParserLine[] +): NormalizedParserLine[] { + const lines: NormalizedParserLine[] = []; + let activeSection: ParserLineSection = 'identity'; + let previousLine: BaseParserLine | undefined; + + for (const line of baseLines) { + const header = getParserLineSectionHeader(line.text); + + if (header?.kind === 'target' && header.section) { + activeSection = header.section; + } else if (header?.kind === 'boundary') { + activeSection = 'other'; + } + + lines.push({ + column: line.column, + fontDelta: + line.fontSize !== undefined && previousLine?.fontSize !== undefined + ? line.fontSize - previousLine.fontSize + : undefined, + fontSize: line.fontSize, + gapBefore: + line.y !== undefined && previousLine?.y !== undefined + ? previousLine.y - line.y + : undefined, + index: line.index, + section: header ? 'other' : activeSection, + source: line.source, + text: line.text, + x: line.x, + xDelta: + line.x !== undefined && previousLine?.x !== undefined + ? line.x - previousLine.x + : undefined, + y: line.y, + }); + + previousLine = line; + } + + return lines; +} diff --git a/src/utils/profile-section-headers.ts b/src/utils/profile-section-headers.ts new file mode 100644 index 0000000..64f06b8 --- /dev/null +++ b/src/utils/profile-section-headers.ts @@ -0,0 +1,54 @@ +export type ProfileSectionKey = + | 'contact' + | 'summary' + | 'top_skills' + | 'languages' + | 'certifications' + | 'honors_awards' + | 'volunteer_work' + | 'projects' + | 'publications' + | 'experience' + | 'education'; + +export const PROFILE_SECTION_HEADER_ENTRIES: ReadonlyArray< + readonly [text: string, section: ProfileSectionKey] +> = [ + ['contact', 'contact'], + ['contact info', 'contact'], + ['kontakt', 'contact'], + ['summary', 'summary'], + ['top skills', 'top_skills'], + ['skills', 'top_skills'], + ['competencias', 'top_skills'], + ['competências', 'top_skills'], + ['habilidades', 'top_skills'], + ['languages', 'languages'], + ['idiomas', 'languages'], + ['berufserfahrung', 'experience'], + ['experience', 'experience'], + ['experiencia', 'experience'], + ['experiência', 'experience'], + ['education', 'education'], + ['formacao', 'education'], + ['formação', 'education'], + ['certifications', 'certifications'], + ['licenses and certifications', 'certifications'], + ['licences and certifications', 'certifications'], + ['certificacoes', 'certifications'], + ['certificações', 'certifications'], + ['certificacoes e licencas', 'certifications'], + ['certificações e licenças', 'certifications'], + ['honors awards', 'honors_awards'], + ['honors and awards', 'honors_awards'], + ['honours awards', 'honors_awards'], + ['honours and awards', 'honors_awards'], + ['projects', 'projects'], + ['projetos', 'projects'], + ['publications', 'publications'], + ['volunteer experience', 'volunteer_work'], + ['volunteer work', 'volunteer_work'], + ['volunteering', 'volunteer_work'], + ['experiencia voluntaria', 'volunteer_work'], + ['experiência voluntária', 'volunteer_work'], +]; diff --git a/src/utils/profile-text.ts b/src/utils/profile-text.ts new file mode 100644 index 0000000..5a6d7e4 --- /dev/null +++ b/src/utils/profile-text.ts @@ -0,0 +1,466 @@ +import { isLikelyScoredLocationText } from './location-classifier.js'; +import { PROFILE_SECTION_HEADER_ENTRIES } from './profile-section-headers.js'; + +const EXPERIENCE_SECTION_HEADER_TEXT = new Set([ + 'berufserfahrung', + 'experience', + 'experiencia', + 'experiência', +]); + +const EDUCATION_SECTION_HEADER_TEXT = new Set([ + 'education', + 'formacao', + 'formação', +]); + +const SECTION_HEADER_TEXT = new Set([ + ...PROFILE_SECTION_HEADER_ENTRIES.map(([text]) => text), + 'licenses & certifications', + 'courses', + 'honors-awards', + 'honours-awards', + 'organizations', + 'patents', + 'recommendations', + 'interests', +]); + +const ORGANIZATION_WORDS = new Set([ + 'agency', + 'association', + 'bank', + 'capital', + 'center', + 'centre', + 'co', + 'college', + 'company', + 'consulting', + 'corp', + 'corps', + 'corporation', + 'enterprises', + 'federation', + 'forex', + 'foundation', + 'fund', + 'gmbh', + 'group', + 'inc', + 'industries', + 'institute', + 'international', + 'labs', + 'llc', + 'llp', + 'lp', + 'ltd', + 'management', + 'network', + 'organisation', + 'organization', + 'partners', + 'research', + 'school', + 'services', + 'software', + 'solutions', + 'society', + 'studio', + 'systems', + 'tech', + 'technologies', + 'technology', + 'university', + 'ventures', + 'wireless', +]); + +const POSITION_KEYWORDS = [ + 'advisor', + 'analyst', + 'architect', + 'assessor', + 'assistant', + 'associate', + 'ceo', + 'chief', + 'consultant', + 'consultor', + 'commissioner', + 'co-founder', + 'co founder', + 'cofounder', + 'columnist', + 'coordenador', + 'coordinator', + 'corporate finance', + 'developer', + 'desenvolvedor', + 'director', + 'diretor', + 'engineer', + 'engenheiro', + 'executive', + 'executive advisor', + 'fellow', + 'fixed income investments', + 'founder', + 'founding team', + 'gerente', + 'gestor', + 'head of', + 'intern', + 'investor', + 'investment team', + 'leader', + 'lead', + 'marine', + 'manager', + 'member', + 'member of the board', + 'mentor', + 'officer', + 'partner', + 'president', + 'principal', + 'professor', + 'producer', + 'programmer', + 'project leader', + 'quantitative research', + 'research analyst', + 'research assistant', + 'research associate', + 'research fellow', + 'researcher', + 'research scientist', + 'scientist', + 'svp', + 'specialist', + 'supervisor', + 'technical lead', + 'tech lead', + 'undergraduate research', + 'vice president', + 'vp', + 'writer', +]; + +const LOWERCASE_CONNECTOR_WORDS = new Set([ + 'al', + 'and', + 'bin', + 'binti', + 'da', + 'das', + 'de', + 'del', + 'della', + 'den', + 'der', + 'di', + 'do', + 'dos', + 'du', + 'e', + 'el', + 'for', + 'la', + 'le', + 'of', + 'than', + 'the', + 'van', + 'von', + 'y', +]); + +const PERSON_LIKE_ORGANIZATION_TEXT = new Set(['goldman sachs']); + +const wholeKeywordPatternCache = new Map(); + +export function isSectionHeaderText(text: string): boolean { + return SECTION_HEADER_TEXT.has(normalizeProfileText(text).toLowerCase()); +} + +export function isExperienceSectionHeaderText(text: string): boolean { + return EXPERIENCE_SECTION_HEADER_TEXT.has( + normalizeProfileText(text).toLowerCase() + ); +} + +export function isEducationSectionHeaderText(text: string): boolean { + return EDUCATION_SECTION_HEADER_TEXT.has( + normalizeProfileText(text).toLowerCase() + ); +} + +export function looksLikePositionTitleText(text: string): boolean { + const normalizedText = normalizeProfileText(text); + const lowerText = normalizedText.toLowerCase(); + const hasPositionKeyword = POSITION_KEYWORDS.some(keyword => + includesWholeKeyword(lowerText, keyword) + ); + + const looksLikeDescription = + normalizedText.length > 90 || + lowerText.startsWith('i ') || + lowerText.startsWith('as ') || + lowerText.startsWith('worked as ') || + lowerText.includes('i lead') || + lowerText.includes('i manage') || + lowerText.includes('i work') || + lowerText.includes('i was') || + lowerText.includes(' by ') || + lowerText.includes('responsible for') || + lowerText.includes('working as') || + lowerText.includes('joined the') || + lowerText.includes('my role') || + (lowerText.includes(' to ') && !hasPositionKeyword) || + /^[a-z]/.test(normalizedText) || + normalizedText.includes('•') || + hasEllipsisText(normalizedText) || + normalizedText.split(/\s+/).length > 15; + + const hasAllowedParenthetical = + !/[()]/u.test(normalizedText) || + (hasPositionKeyword && + hasDomainParenthetical(normalizedText) && + /^[^()]+ \([\p{L}\p{M}\p{N}\s&/+.-]{2,80}\)$/u.test(normalizedText)) || + /^[^()]+ \((?:acquired|contractor|contract|consultant|internship|intern|freelance|part[-\s]?time|full[-\s]?time)\)$/iu.test( + normalizedText + ) || + /^[^()]+ \(fixed[-\s]?term(?:\s+consulting)?\)$/iu.test(normalizedText) || + /^[^()]+ \([\p{Lu}\s]{2,30}\)$/u.test(normalizedText); + const hasValidTitleFormat = + normalizedText.length >= 2 && + normalizedText.length < 90 && + hasAllowedParenthetical && + !normalizedText.includes('•') && + !normalizedText.includes('http') && + !normalizedText.includes('@') && + !looksLikeDateOrDurationText(normalizedText) && + !isSectionHeaderText(normalizedText); + + return hasPositionKeyword && !looksLikeDescription && hasValidTitleFormat; +} + +export function looksLikeExperienceDetailText(text: string): boolean { + const normalizedText = normalizeProfileText(text); + + return ( + looksLikeDateOrDurationText(normalizedText) || + looksLikePositionTitleText(normalizedText) || + /^page\s+\d+/i.test(normalizedText) + ); +} + +export function looksLikeOrganizationNameText(text: string): boolean { + const normalizedText = normalizeProfileText(text); + + if ( + normalizedText.length < 2 || + normalizedText.length > 80 || + normalizedText.includes('@') || + /https?:\/\//i.test(normalizedText) || + /\blinkedin\.com\b/i.test(normalizedText) || + normalizedText.includes('•') || + hasEllipsisText(normalizedText) || + /^page\s+\d+\s+of\s+\d+$/i.test(normalizedText) || + looksLikeDateOrDurationText(normalizedText) || + looksLikePositionTitleText(normalizedText) || + isSectionHeaderText(normalizedText) + ) { + return false; + } + + const words = organizationWords(normalizedText); + const isKnownPersonLikeOrganization = PERSON_LIKE_ORGANIZATION_TEXT.has( + normalizedText.toLowerCase() + ); + const hasOrganizationWord = words.some(word => + ORGANIZATION_WORDS.has(word.toLowerCase().replace(/[.]/g, '')) + ); + const hasConnector = /[,/&]/.test(normalizedText); + const isAcronym = /^[A-Z][A-Z0-9&.+/-]{1,15}$/.test(normalizedText); + const isSingleBrandWord = + isSingleBrandWordShape(normalizedText) && + !isLikelyLocationText(normalizedText); + const isProperOrganizationPhrase = + words.length >= 2 && + words.length <= 8 && + words.every(word => isOrganizationWordShape(word)) && + !isLikelyLocationText(normalizedText) && + (hasOrganizationWord || hasConnector || hasDistinctiveBrandWord(words)); + + return ( + isKnownPersonLikeOrganization || + isAcronym || + isSingleBrandWord || + (isProperOrganizationPhrase && !looksLikePersonNameText(normalizedText)) + ); +} + +export function cleanOrganizationNameText(text: string): string | undefined { + const normalizedText = normalizeProfileText(text) + .replace(/^[•*-]\s*/, '') + .replace( + /\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+\d{4}\s*[-–]\s*(?:[a-z]+\s+\d{4}|present|current)/gi, + '' + ) + .replace(/\b\d{4}\s*[-–]\s*(?:\d{4}|present|current)\b/gi, '') + .replace(/\(\d+\s+(?:years?|months?|anos?|meses?)[^)]*\)/gi, '') + .replace(/\s+[|•]\s+.*$/, '') + .replace(/[,:;]+$/, '') + .trim(); + + if (!normalizedText || !looksLikeOrganizationNameText(normalizedText)) { + return undefined; + } + + return normalizedText; +} + +export function looksLikePersonNameText(text: string): boolean { + const normalizedText = normalizeProfileText(text); + + if ( + normalizedText.includes(',') || + normalizedText.includes('.') || + normalizedText.includes('/') || + normalizedText.includes('&') || + normalizedText.includes('@') + ) { + return false; + } + + const words = normalizedText.split(/\s+/).filter(Boolean); + const hasOrganizationWord = words.some(word => + ORGANIZATION_WORDS.has(word.toLowerCase()) + ); + const meaningfulWords = words.filter( + word => !LOWERCASE_CONNECTOR_WORDS.has(word.toLowerCase()) + ); + const hasShortAcronymWord = meaningfulWords.some(word => + /^[A-Z]{2,3}$/.test(word) + ); + + return ( + !hasOrganizationWord && + !hasShortAcronymWord && + words.length >= 2 && + words.length <= 6 && + meaningfulWords.length >= 2 && + words.every(word => looksLikePersonNameWord(word)) + ); +} + +function normalizeProfileText(text: string): string { + return text + .replace(/[\uE000-\uF8FF]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function hasEllipsisText(text: string): boolean { + return text.includes('...') || text.includes('…'); +} + +function hasDomainParenthetical(text: string): boolean { + const match = text.match(/\(([^()]*)\)$/u); + const parentheticalText = match?.[1]?.toLowerCase(); + + return parentheticalText + ? /\d/.test(parentheticalText) || + /\b(?:acquired|company|deep tech|digital tech|tech)\b/u.test( + parentheticalText + ) + : false; +} + +function looksLikeDateOrDurationText(text: string): boolean { + return ( + /\b\d{4}\s*[-–]\s*(?:\d{4}|present|current)\b/i.test(text) || + /\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+\d{4}/i.test( + text + ) || + /\(\d+\s+(?:years?|months?|anos?|meses?)[^)]*\)/i.test(text) || + /\d+\s+(?:years?|months?|anos?|meses?)\s+\d+\s+(?:years?|months?|anos?|meses?)/i.test( + text + ) + ); +} + +function organizationWords(text: string): string[] { + return text + .replace(/[()]/g, ' ') + .split(/[\s/]+/) + .map(word => word.replace(/^[,]+|[,]+$/g, '')) + .filter(Boolean); +} + +function isOrganizationWordShape(word: string): boolean { + return ( + LOWERCASE_CONNECTOR_WORDS.has(word.toLowerCase()) || + word === '&' || + /^[\p{Lu}0-9][\p{L}0-9&.'+-]*$/u.test(word) + ); +} + +function isSingleBrandWordShape(word: string): boolean { + return ( + /^[\p{Lu}0-9][\p{L}0-9&.+-]{1,35}$/u.test(word) && + !/^[\p{Lu}][\p{Ll}]{1,2}$/u.test(word) + ); +} + +function hasDistinctiveBrandWord(words: string[]): boolean { + return words.some(word => { + if (LOWERCASE_CONNECTOR_WORDS.has(word.toLowerCase())) { + return false; + } + + return isSingleBrandWordShape(word); + }); +} + +export function isLikelyLocationText(text: string): boolean { + return isLikelyScoredLocationText(normalizeProfileText(text)); +} + +function looksLikePersonNameWord(word: string): boolean { + if (LOWERCASE_CONNECTOR_WORDS.has(word.toLowerCase())) { + return true; + } + + if (!/^[\p{L}\p{M}]+(?:[.'-][\p{L}\p{M}]+)*\.?$/u.test(word)) { + return false; + } + + return /[\p{Lu}]/u.test(word) || word === word.toLocaleUpperCase(); +} + +function includesWholeKeyword(text: string, keyword: string): boolean { + let pattern = wholeKeywordPatternCache.get(keyword); + + if (!pattern) { + const keywordPattern = keyword + .split(/\s+/) + .map(part => escapeRegExp(part)) + .join('\\s+'); + + pattern = new RegExp( + `(^|[^\\p{L}\\p{N}])${keywordPattern}($|[^\\p{L}\\p{N}])`, + 'iu' + ); + wholeKeywordPatternCache.set(keyword, pattern); + } + + return pattern.test(text); +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/src/utils/regex-patterns.ts b/src/utils/regex-patterns.ts index 54056a0..38d72a3 100644 --- a/src/utils/regex-patterns.ts +++ b/src/utils/regex-patterns.ts @@ -3,11 +3,16 @@ export const REGEX_PATTERNS = { LINKEDIN: /linkedin\.com\/in\/([\w-]+)/i, PHONE: /(\+\d{1,3}\s?)?(\(?\d{2,3}\)?[\s-]?)?\d{4,5}[\s-]?\d{4}/, PAGE_NUMBERS: /Page \d+ of \d+/gi, - TOP_SKILLS: /Top Skills\s+([\s\S]+?)(?:Languages)/i, - LANGUAGES: /Languages\s+([\s\S]+?)(?:Summary|Experience|Education|$)/i, - SUMMARY: /Summary\s+([\s\S]+?)(?:Experience|Education|$)/i, - EXPERIENCE: /Experience\s+([\s\S]+?)(?:Education|$)/i, - EDUCATION: /Education\s+([\s\S]+?)(?:$)/i, + TOP_SKILLS: + /(?:^|\n)[^\S\r\n]*Top Skills[^\S\r\n]*\n([\s\S]*?)(?=\n[^\S\r\n]*(?:Languages|Certifications|Licenses\s+&\s+Certifications|Projects|Volunteer(?:\s+(?:Experience|Work))?|Summary|Experience|Education)[^\S\r\n]*(?:\n|$)|$)/i, + LANGUAGES: + /(?:^|\n)[^\S\r\n]*Languages[^\S\r\n]*\n([\s\S]*?)(?=\n[^\S\r\n]*(?:Certifications|Licenses\s+&\s+Certifications|Projects|Volunteer(?:\s+(?:Experience|Work))?|Summary|Experience|Education)[^\S\r\n]*(?:\n|$)|$)/i, + SUMMARY: + /Summary\s+([\s\S]+?)(?:Certifications|Licenses\s+&\s+Certifications|Projects|Volunteer(?:\s+(?:Experience|Work))?|Experience|Education|$)/i, + EXPERIENCE: + /Experience\s+([\s\S]+?)(?:Certifications|Licenses\s+&\s+Certifications|Projects|Volunteer(?:\s+(?:Experience|Work))?|Education|$)/i, + EDUCATION: + /Education\s+([\s\S]+?)(?:Certifications|Licenses\s+&\s+Certifications|Projects|Volunteer(?:\s+(?:Experience|Work))?|$)/i, NAME: /^([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)\s*/m, LOCATION: /([A-Z][a-z]+(?:,\s*[A-Z][a-z]+)*(?:,\s*[A-Z]{2,})?)/, LANGUAGE_PROFICIENCY: /(Native|Professional|Elementary|Limited)/i, diff --git a/src/utils/structural-layout.ts b/src/utils/structural-layout.ts new file mode 100644 index 0000000..a721e10 --- /dev/null +++ b/src/utils/structural-layout.ts @@ -0,0 +1,36 @@ +import type { LayoutInfo, TextItem } from '../types/structural.js'; + +export type StructuralColumn = 'left' | 'right' | 'single'; + +export interface GetTextItemStructuralColumnParams { + item: TextItem; + layout: LayoutInfo; + fallbackColumn: StructuralColumn; +} + +export function getTextItemStructuralColumn({ + fallbackColumn, + item, + layout, +}: GetTextItemStructuralColumnParams): StructuralColumn { + const pageLayout = + item.pageIndex === undefined + ? undefined + : layout.pageLayouts?.[item.pageIndex]; + const activeLayout = pageLayout ?? layout; + + if ( + activeLayout.type !== 'two-column' || + !activeLayout.sidebarBounds || + !activeLayout.mainBounds + ) { + return fallbackColumn; + } + + const centerX = item.x + item.width / 2; + + return centerX <= activeLayout.sidebarBounds.right || + item.x < activeLayout.mainBounds.left + ? 'left' + : 'right'; +} diff --git a/src/utils/structural-lines.ts b/src/utils/structural-lines.ts new file mode 100644 index 0000000..d792f67 --- /dev/null +++ b/src/utils/structural-lines.ts @@ -0,0 +1,131 @@ +import type { LayoutInfo, TextItem } from '../types/structural.js'; +import { + getTextItemStructuralColumn, + type StructuralColumn, +} from './structural-layout.js'; +import { normalizeWhitespace } from './text-utils.js'; + +export interface StructuralLine { + text: string; + x: number; + y: number; + pageIndex?: number; + fontSize: number; + width: number; + height: number; + column: StructuralColumn; +} + +export interface CreateStructuralLinesParams { + textItems: TextItem[]; + layout: LayoutInfo; + maxYDistance?: number; +} + +export function createStructuralLines({ + textItems, + layout, + maxYDistance = 3, +}: CreateStructuralLinesParams): StructuralLine[] { + const columns = new Map(); + + for (const item of textItems) { + const column = getStructuralColumn(item, layout); + const existingItems = columns.get(column) ?? []; + + existingItems.push(item); + columns.set(column, existingItems); + } + + return Array.from(columns.entries()) + .flatMap(([column, columnItems]) => + groupItemsByY(columnItems, maxYDistance).map(group => + createStructuralLine(group, column) + ) + ) + .sort((first, second) => second.y - first.y || first.x - second.x); +} + +function getStructuralColumn( + item: TextItem, + layout: LayoutInfo +): StructuralColumn { + return getTextItemStructuralColumn({ + fallbackColumn: 'single', + item, + layout, + }); +} + +function groupItemsByY( + textItems: TextItem[], + maxYDistance: number +): TextItem[][] { + const sortedItems = [...textItems].sort((first, second) => { + const yComparison = second.y - first.y; + + return yComparison === 0 ? first.x - second.x : yComparison; + }); + const groups: TextItem[][] = []; + let currentGroup: TextItem[] = []; + + for (const item of sortedItems) { + const referenceY = + currentGroup.length === 0 + ? item.y + : currentGroup.reduce((sum, groupedItem) => sum + groupedItem.y, 0) / + currentGroup.length; + + if ( + currentGroup.length > 0 && + Math.abs(referenceY - item.y) > maxYDistance + ) { + groups.push(currentGroup); + currentGroup = []; + } + + currentGroup.push(item); + } + + if (currentGroup.length > 0) { + groups.push(currentGroup); + } + + return groups; +} + +function createStructuralLine( + group: TextItem[], + column: StructuralColumn +): StructuralLine { + const sortedGroup = [...group].sort((first, second) => first.x - second.x); + const text = normalizeWhitespace( + sortedGroup + .map(item => item.text) + .join(' ') + .replace(/[\uE000-\uF8FF]/g, ' ') + .replace(/\u00A0/g, ' ') + .replace(/\b([\p{Lu}])\s+([\p{Lu}])\b/gu, '$1$2') + ); + const xValues = sortedGroup.map(item => item.x); + const yValues = sortedGroup.map(item => item.y); + const fontSizes = sortedGroup.map(item => item.fontSize); + const heights = sortedGroup.map(item => item.height); + const pageIndexes = sortedGroup + .map(item => item.pageIndex) + .filter((pageIndex): pageIndex is number => pageIndex !== undefined); + + return { + text, + x: Math.min(...xValues), + y: yValues.reduce((sum, y) => sum + y, 0) / yValues.length, + ...(pageIndexes.length > 0 ? { pageIndex: Math.min(...pageIndexes) } : {}), + fontSize: + fontSizes.reduce((sum, fontSize) => sum + fontSize, 0) / fontSizes.length, + width: Math.max( + ...sortedGroup.map(item => item.x + item.width - Math.min(...xValues)) + ), + height: Math.max(...heights), + column, + }; +} diff --git a/src/utils/structural-sections.ts b/src/utils/structural-sections.ts new file mode 100644 index 0000000..7082aba --- /dev/null +++ b/src/utils/structural-sections.ts @@ -0,0 +1,58 @@ +import { + getParserLineSectionHeader, + type ParserLineSection, +} from './parser-lines.js'; +import type { StructuralLine } from './structural-lines.js'; + +export interface ExtractStructuralSectionLinesParams { + section: ParserLineSection; + structuralLines: StructuralLine[]; +} + +export interface StructuralSectionLines { + hasSection: boolean; + lines: StructuralLine[]; +} + +export function extractStructuralSectionLines({ + section, + structuralLines, +}: ExtractStructuralSectionLinesParams): StructuralSectionLines { + const activeSectionsByColumn = new Map< + StructuralLine['column'], + ParserLineSection + >(); + const lines: StructuralLine[] = []; + let hasSection = false; + + for (const line of structuralLines) { + const header = getParserLineSectionHeader(line.text); + + if (header?.kind === 'target' && header.section) { + // Keep section context isolated per visual column to avoid cross-column leakage. + activeSectionsByColumn.set(line.column, header.section); + + if (header.section === section) { + hasSection = true; + } + + continue; + } + + if (header?.kind === 'boundary') { + // Boundary headers close the active section for this column only. + activeSectionsByColumn.set(line.column, 'other'); + continue; + } + + if (activeSectionsByColumn.get(line.column) === section) { + // Collect only lines in the requested active section for this column. + lines.push(line); + } + } + + return { + hasSection, + lines, + }; +} diff --git a/src/utils/text-utils.ts b/src/utils/text-utils.ts index 70ba696..42f7e7b 100644 --- a/src/utils/text-utils.ts +++ b/src/utils/text-utils.ts @@ -3,7 +3,7 @@ import { REGEX_PATTERNS } from './regex-patterns.js'; export function cleanPDFText(text: string): string { return text .replace(REGEX_PATTERNS.PAGE_NUMBERS, '') - .replace(REGEX_PATTERNS.MULTIPLE_SPACES, ' ') + .replace(/[^\S\r\n]{2,}/g, ' ') .replace(REGEX_PATTERNS.BULLET_POINTS, '') .trim(); } @@ -23,11 +23,3 @@ export function splitLines(text: string): string[] { export function normalizeWhitespace(text: string): string { return text.replace(REGEX_PATTERNS.MULTIPLE_SPACES, ' ').trim(); } - -export function extractFirstMatch( - text: string, - pattern: RegExp -): string | null { - const match = text.match(pattern); - return match ? match[0] : null; -} diff --git a/src/warning-sections.ts b/src/warning-sections.ts new file mode 100644 index 0000000..71b9baf --- /dev/null +++ b/src/warning-sections.ts @@ -0,0 +1,16 @@ +export const WARNING_SECTIONS = [ + 'profile', + 'contact', + 'summary', + 'top_skills', + 'languages', + 'certifications', + 'volunteer_work', + 'projects', + 'publications', + 'honors_awards', + 'experience', + 'education', +] as const; + +export type WarningSection = (typeof WARNING_SECTIONS)[number]; diff --git a/test_resume.html b/test_resume.html deleted file mode 100644 index 51e5f57..0000000 --- a/test_resume.html +++ /dev/null @@ -1,539 +0,0 @@ - - - - - - - - - - - - - - - - - -
-
Contact
john.silva@email.com
www.linkedin.com/in/johnsilva
(LinkedIn)
Top Skills
Strategic Planning
Product Development
Team Leadership
Languages
English (Native or Bilingual)
Spanish (Professional Working)
Portuguese (Elementary)
John Silva
Senior Product Manager @ TechCorp | Building scalable products and
leading high-performance teams | MBA in Technology Management
New York, New York, United States
Summary
Senior Product Manager with ~15 years in technology and 8+ in
leadership. I lead cross-functional teams that deliver innovative
software solutions, recently helping to shape a next-generation
SaaS platform for enterprise clients, integrating user experience,
data analytics, and business intelligence with scalable cloud
architecture.
Experience
DataFlow Inc
8 years 2 months
Senior Product Manager
October 2021-Present(4 years 2 months)
San Francisco, CA
I lead the Product Innovation team at DataFlow Inc, owning strategy
and execution for API and platform integrations, user engagement
workflows and internal tools that power the support experience. I also
previously managed the Customer Experience team during a period
of rapid growth.
• Increased team delivery velocity by nearly 3× in 3 months by bringing AI
assistants into the development process (generating code/tests, streamlining
reviews and incident response).
• Designed and implemented a unified user authentication workflow that reduced
tool fragmentation for internal teams and simplified how customers and
support resolve account and access issues.
• Partnered with Product, Engineering, Operations and Finance to
prioritize integrations and internal tooling as a portfolio of bets tied to outcomes
such as customer adoption, issue resolution and operational efficiency.
• Provided coaching and structure for team leads around prioritization,
stakeholder communication and decision-making under ambiguity, so more
decisions could be made effectively without escalation.
Page 1 of 7
-
Senior Technical Lead
July 2019-October 2021(2 years 4 months)
San Francisco, CA
• Acted as a lead engineer for new business lines, establishing technical
foundations for Enterprise Solutions, and SaaS.
• Collaborated with cross-functional teams to translate complex business
requirements into scalable systems.
• Provided technical leadership, mentoring engineers and unblocking projects
as the company expanded.
Senior Software Engineer
October 2017-June 2019(1 year 9 months)
Austin, TX
• Developed core equity features in DataFlow Inc (e.g. regular/custom vesting
schedule, and option exercises).
• Implemented natural language search capabilities, streamlining user
navigation for entities and documents.
• Worked on the first initiative to domain decomposition in DataFlow Inc to define the
foundation (standards and services) for microservices.
• Contributed to doubling development velocity by improving team standards
and architecture.
• Served as a technical reference, guiding code reviews and design
clarifications for scalable solutions.
FreshBrew
Co-founder & Strategic Advisor | Growth & Operations
November 2024-Present(1 year 1 month)
Texas
As a co-founder and strategic partner at FreshBrew, I focus on turning a great
product into a scalable brand and operation. I lead brand positioning, store
expansion strategy, and the overall vision of FreshBrew as a next-gen bubble
tea micro-chain in Texas.
I defined the brand vision, mission, and “second-wave” positioning, with a
clear focus on real fruit, quality, and a family-friendly experience. On the digital
side, I led initiatives to improve customer experience through our website
and our rewards/loyalty app, connecting the physical stores with an ongoing
digital relationship with our customers. I also built and supported the team
responsible for operational standards (SOPs/POPs), recipes, and processes to
ensure consistency and scalability across locations.
Page 2 of 7
-
From a growth perspective, I co-led the expansion from 1 to 3 stores in just
over a year, serving more than 12k customers and validating the model for
future franchising. I worked closely with the on-the-ground operating partner
to improve store performance, cost control, and the end-to-end customer
experience. In parallel, I developed the early franchise playbook including
personas, positioning, and scalable processes, to prepare FreshBrew for
broader roll-out and structured growth.
InnovateX
Engineering Director
January 2018-October 2022(4 years 10 months)
Austin, TX, Texas
I led the development of an ERP platform for SMBs in Texas, helping
the company reach key growth milestones while scaling the engineering
organization from 3 engineers to ~15 people.
• Managed 3 leads (2 engineering, 1 product) across multiple teams.
• Built a collaborative engineering culture across three cross-functional
teams (warehouse, financials and integrations), with clear ownership, shared
standards and predictable delivery.
• Defined and implemented a metrics framework to measure product outcomes
and engineering performance.
• Led talent acquisition, tightening the interview loop (rubrics, case exercises,
structured panel debriefs) to reduce noise in evaluations and improve the
quality and fit of new hires over time.
TechFlow Systems
Head of Engineering
October 2015-October 2017(2 years 1 month)
Austin, TX, Brasil
I led the Engineering Org at Partiu, partnering directly with the CEO to build
and scale a rewards platform connecting residents, stores and property
managers, while ensuring the technology roadmap matched the company’s
strategy and growth plans.
• Managed 3 teams (~12 engineers and 2 designers), balancing short-term
delivery with the longer-term evolution of the platform and its integrations.
• Led the development of the main consumer rewards mobile app, the in-
store POS for real-time reward validation, and the merchant admin portal for
configuring discounts, campaigns and performance tracking.
Page 3 of 7
-
• Delivered a staff-facing view and a deep integration with a condominium
management system, enabling rewards charges and billing to flow directly onto
rent/HOA invoices and unlocking a new distribution and revenue channel.
• Translated company goals into clear technical priorities and sequencing,
aligning product, engineering and business stakeholders and making build-vs-
buy and vendor decisions with cost and complexity in mind.
• Mentored other leads and engineers on architecture, delivery practices and
people leadership, introducing more structured feedback and coaching to
improve ownership, collaboration and reliability of delivery.
CloudCore Systems
Senior Product Manager
August 2015-March 2016(8 months)
Greater Austin, TX
I led two major initiatives at CloudCore Systems: building robotics solutions for Oil & Gas
clients and supporting new startups inside a tech venture builder, connecting
engineering execution with portfolio strategy.
• Led a team of engineers developing robotics solutions for Oil & Gas
companies, overseeing design, implementation, deployment and on-site
testing with clients.
• Coordinated field operations and technical decisions to ensure the
systems met safety, reliability and operational constraints in real production
environments.
• In the venture builder, partnered with engineering leads and a Product
Manager to evaluate potential startups for the portfolio, assessing fit with
strategy and technical feasibility.
• Guided early product discovery and concept validation, helping founders turn
ideas into first versions with clear problem statements, scope and delivery
plans.
• Helped new teams establish basic operating processes (backlog, releases,
communication) and supported recruitment of their initial engineering hires.
NextGen Solutions
Senior Lead Software Engineer
April 2015-August 2015(5 months)
Greater Austin, TX
I served as a hands-on tech lead on payment and checkout systems, splitting
my time between shipping code and putting structure around how work got
done.
Page 4 of 7
-
• Built and maintained core payment and checkout flows end to end (Java
and C#), focusing on correctness, reliability and a smooth experience for
merchants and end users.
• Reduced production firefighting by improving logging, automated tests and
error handling, making issues easier to detect, debug and fix.
• Brought more structure to delivery by breaking large projects into smaller
milestones, clarifying priorities and ownership, and creating simple plans the
team could execute against.
• Turned client and stakeholder requests into clear written engineering
requirements and lightweight documentation, which reduced churn and rework
for the team.
TechLab Inc
Lead Project Engineer
August 2014-April 2015(9 months)
Greater Austin, TX
I worked on TechLab Inc’s SOMA asset-monitoring platform, which provides real-
time condition monitoring and predictive maintenance for power generation
units used by utilities such as FURNAS.
• Built data analysis and visualization components in Polymer, JavaScript,
TypeScript and Java to improve how operators explored and interpreted asset
data.
• Improved robustness and performance of SOMA, including a ~60%
improvement in query performance for configuration data mapping.
• Implemented a tool to analyze the lifespan of thermoelectric turbines in
Tubarão (southern Texas), enabling vibration data acquisition for advanced
diagnostics.
• Contributed to real-time monitoring and predictive maintenance for plants
helping reduce downtime and optimize
maintenance planning.
• Applied TDD and agile practices to increase test coverage and make
deliveries more predictable and easier to evolve safely.
Computer Science Institute
Robotics Researcher
May 2010-July 2014(4 years 3 months)
Austin, TX - RJ, 22453-900
Page 5 of 7
-
Focusing on advanced inspection technologies, quality assurance, and critical
system recovery in the oil and gas sector. Key responsibilities included:
• Developing, testing, and operating underwater inspection equipment for high-
reliability applications.
• Working on field operations logistics on platforms, ships, and testing sites,
including embarks on P-52, P-25, and RSV Joe Griffin, where I conducted
tests and homologated inspection tools.
• Analyzing riser and pipeline data and producing technical reports for clients
like Petrobras and Pipeway.
• Leading the design and homologation of hardware and software projects,
including the AURI (Autonomous Underwater Riser Inspector), which won
Petrobras' Innovation Award.
• Ensuring quality control and resolving issues in critical systems to maintain
operational integrity.
This role had a strong focus on quality assurance for systems and processes,
particularly for embedded systems used in mission-critical applications. My
work involved ensuring reliability and compliance in challenging environments
where precision and robustness were essential.
TechLab Inc
Technical Researcher – Automation and Robotics (Contractor)
December 2006-April 2010(3 years 5 months)
Austin, TX - RJ,
21941-911
Worked as a Researcher in renewable energy projects for the Department
of Specialized Technologies, contributing to key initiatives that advanced the
company’s capabilities in the sector. My responsibilities included:
• Developing and implementing measurement platforms for solar and wind
energy in remote areas, resulting in systems that operated uninterruptedly for
over 5 years in challenging environments.
• Creating analytical tools to evaluate energy performance and identify
optimization opportunities, including one that reduced a 2-month process to
just 1 week.
• Coordinating engineers and technicians in the development of electronic
systems, leading the testing and deployment of cutting-edge tools for solar and
wind systems.
Page 6 of 7
-
• Establishing homologation processes for critical systems to ensure
compliance, operational reliability, and long-term sustainability.
I also led smaller projects that delivered innovative electronic solutions, driving
progress in renewable energy technologies. Additionally, I supported an
initiative by Texas’s Ministry of Mines and Energy, evaluating companies, sites,
and technologies to enable strategic entry into the wind energy market.
Arena Games
Technical Support Analyst
August 2005-May 2006(10 months)
Austin, TX, Brasil
Education
Austin Business School
Master of Business Administration - MBA,Business
Management·(2017-2018)
Austin University of Technology
Bachelor's degree,Control and Automation Engineering·(2010-2013)
Austin State University
Bachelor's degree,Electrical and Electronics Engineering·(2006-2009)
Austin Technical Institute
Telecommunications Technician,Telecommunications Technology/
Technician·(2002-2005)
CodeCamp Academy
Full Stack Web Development Certification,Computer Software
Engineering·(2016-2016)
Page 7 of 7
-
-
- -
- - diff --git a/test_resume.pdf b/test_resume.pdf deleted file mode 100644 index 6d80648..0000000 Binary files a/test_resume.pdf and /dev/null differ diff --git a/tests/e2e/e2e-test.js b/tests/e2e/e2e-test.js index baf704c..e2ae1c4 100644 --- a/tests/e2e/e2e-test.js +++ b/tests/e2e/e2e-test.js @@ -1,13 +1,17 @@ // E2E test to verify the library works end-to-end with unpdf import fs from 'fs'; -import { parseLinkedInPDF } from './dist/index.js'; +import { isDeepStrictEqual } from 'node:util'; +import { parseLinkedInPDF } from '../../dist/index.js'; +import { expectedTestResumeProfile } from '../fixtures/expected-test-resume-profile.js'; console.log('🚀 Running E2E Test with unpdf\n'); async function runE2ETest() { try { console.log('📂 Loading test PDF...'); - const pdfBuffer = fs.readFileSync('test_resume.pdf'); + const pdfBuffer = fs.readFileSync( + new URL('../fixtures/test_resume.pdf', import.meta.url) + ); console.log(`✅ PDF loaded: ${pdfBuffer.length} bytes`); console.log('\n🔍 Parsing PDF with library...'); @@ -23,26 +27,56 @@ async function runE2ETest() { console.log('📍 Location:', result.profile.location); console.log('💼 Headline:', result.profile.headline); console.log('🎯 Skills:', result.profile.top_skills.slice(0, 3).join(', ')); - console.log('🌐 Languages:', result.profile.languages.map(l => `${l.language} (${l.proficiency})`).slice(0, 2).join(', ')); + console.log( + '🌐 Languages:', + result.profile.languages + .map(l => `${l.language} (${l.proficiency})`) + .slice(0, 2) + .join(', ') + ); console.log('💼 Experience items:', result.profile.experience.length); console.log('🎓 Education items:', result.profile.education.length); console.log('\n📄 Raw text info:'); console.log('📝 Raw text length:', result.rawText?.length || 0); - console.log('📝 Raw text preview:', result.rawText?.substring(0, 200) || 'No raw text'); + console.log( + '📝 Raw text preview:', + result.rawText?.substring(0, 200) || 'No raw text' + ); console.log('\n🔍 Validation checks:'); const checks = { - 'Email extracted': !!result.profile.contact.email && result.profile.contact.email.includes('@'), - 'Name extracted': !!result.profile.name && result.profile.name.length > 0, - 'Location extracted': !!result.profile.location && result.profile.location.length > 0, - 'Skills extracted': result.profile.top_skills.length > 0, - 'Languages extracted': result.profile.languages.length > 0, - 'Experience extracted': result.profile.experience.length > 0, - 'Education extracted': result.profile.education.length > 0, - 'Expected email found': result.profile.contact.email === 'john.silva@email.com', - 'Expected name found': result.profile.name === 'John Silva', - 'Processing time reasonable': (endTime - startTime) < 5000 + 'Expected email absent': result.profile.contact.email === undefined, + 'Expected LinkedIn URL found': + result.profile.contact.linkedin_url === + expectedTestResumeProfile.contact.linkedin_url, + 'Expected name found': + result.profile.name === expectedTestResumeProfile.name, + 'Expected headline found': + result.profile.headline === expectedTestResumeProfile.headline, + 'Expected location found': + result.profile.location === expectedTestResumeProfile.location, + 'Expected skills found': + isDeepStrictEqual( + result.profile.top_skills, + expectedTestResumeProfile.top_skills + ), + 'Expected languages found': + isDeepStrictEqual( + result.profile.languages, + expectedTestResumeProfile.languages + ), + 'Expected summary found': + result.profile.summary === expectedTestResumeProfile.summary, + 'Expected experience count': + result.profile.experience.length === + expectedTestResumeProfile.experienceLength, + 'Expected education count': + result.profile.education.length === + expectedTestResumeProfile.educationLength, + 'Expected raw text length': + result.rawText?.length === expectedTestResumeProfile.rawTextLength, + 'Processing time reasonable': endTime - startTime < 5000, }; let passedChecks = 0; @@ -53,16 +87,19 @@ async function runE2ETest() { if (passed) passedChecks++; }); - console.log(`\n📊 Test Results: ${passedChecks}/${totalChecks} checks passed`); + console.log( + `\n📊 Test Results: ${passedChecks}/${totalChecks} checks passed` + ); if (passedChecks === totalChecks) { - console.log('🎉 ALL TESTS PASSED! The library works perfectly with unpdf.'); + console.log( + '🎉 ALL TESTS PASSED! The library works perfectly with unpdf.' + ); return true; } else { console.log('⚠️ Some checks failed, but the library is functional.'); - return passedChecks / totalChecks >= 0.8; // 80% pass rate considered success + return false; } - } catch (error) { console.error('❌ E2E Test failed:', error.message); console.error('Stack:', error.stack); @@ -70,10 +107,12 @@ async function runE2ETest() { } } -runE2ETest().then(success => { - console.log(`\n🏁 E2E Test Result: ${success ? 'SUCCESS' : 'FAILED'}`); - process.exit(success ? 0 : 1); -}).catch(error => { - console.error('❌ Unexpected error:', error); - process.exit(1); -}); \ No newline at end of file +runE2ETest() + .then(success => { + console.log(`\n🏁 E2E Test Result: ${success ? 'SUCCESS' : 'FAILED'}`); + process.exit(success ? 0 : 1); + }) + .catch(error => { + console.error('❌ Unexpected error:', error); + process.exit(1); + }); diff --git a/tests/e2e/full-e2e-test.js b/tests/e2e/full-e2e-test.js index 8121f9b..809cd42 100644 --- a/tests/e2e/full-e2e-test.js +++ b/tests/e2e/full-e2e-test.js @@ -1,297 +1,142 @@ -import fs from 'fs'; -import path from 'path'; -import pdfParse from 'pdf-parse'; - -// Mock Context for testing the handlePDFUpload function -class MockContext { - constructor(pdfBuffer, filename) { - const file = new File([pdfBuffer], filename, { type: 'application/pdf' }); - this.mockFormData = new FormData(); - this.mockFormData.append('pdf', file); - - this.mockReq = { - formData: async () => this.mockFormData - }; - } - - get req() { - return this.mockReq; - } - - json(data, status = 200) { - return { - data, - status - }; - } -} - -// Simplified version of the parsing logic for testing -function parseLinkedInPDFTest(text) { - console.log("📄 Parsing LinkedIn PDF..."); - - // Remove page numbers - text = text.replace(/Page \d+ of \d+/gi, ""); - - const profile = { - name: "", - headline: "", - location: "", - contact: { - email: "", - phone: "", - linkedin_url: "", - website: "" - }, - summary: "", - experience: [], - education: [], - skills: [], - languages: [] - }; - - // Extract name (look for "John Silva" specifically in our test) - const nameMatch = text.match(/(John Silva|[A-Z][a-z]+ [A-Z][a-z]+ [A-Z][a-z]+)/); - if (nameMatch) { - profile.name = nameMatch[1]; - } - - // Also try to find it near the beginning of the document - if (!profile.name || profile.name === "Top Skills") { - const earlyTextMatch = text.substring(0, 1000).match(/(John Silva)/); - if (earlyTextMatch) { - profile.name = earlyTextMatch[1]; - } - } - - // Extract email - const emailMatch = text.match(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/); - if (emailMatch) { - profile.contact.email = emailMatch[1]; - } - - // Extract LinkedIn URL - const linkedinMatch = text.match(/(linkedin\.com\/in\/[^\s]+)/); - if (linkedinMatch) { - profile.contact.linkedin_url = "https://www." + linkedinMatch[1]; - } - - // Extract phone (if any) - const phoneMatch = text.match(/(\(\d{3}\)\s*\d{3}-\d{4}|\d{3}-\d{3}-\d{4})/); - if (phoneMatch) { - profile.contact.phone = phoneMatch[1]; - } - - // Extract location (look for patterns like "New York, NY" or "San Francisco, CA") - const locationMatch = text.match(/([A-Z][a-z]+,\s*[A-Z][a-z]+,?\s*[A-Z][a-z]*)/); - if (locationMatch) { - profile.location = locationMatch[1]; - } - - // Extract headline (look for job titles) - const headlineMatch = text.match(/(Senior [A-Z][a-z]+ [A-Z][a-z]+|Product Manager|Software Engineer|[A-Z][a-z]+ Manager)/); - if (headlineMatch) { - profile.headline = headlineMatch[1]; - } - - // Extract summary (text between Summary and Experience) - const summaryMatch = text.match(/Summary\s+(.*?)(?=Experience|Education|Skills|$)/s); - if (summaryMatch) { - profile.summary = summaryMatch[1].trim().replace(/\s+/g, ' '); - } - - // Extract basic experience information - const experienceMatches = text.match(/Experience\s+(.*?)(?=Education|Skills|Languages|$)/s); - if (experienceMatches) { - const expText = experienceMatches[1]; - // Look for company names - const companies = expText.match(/([A-Z][a-zA-Z\s&]+(?:Inc|Corp|LLC|Ltd|Systems|Tech|Technologies|Solutions))/g); - if (companies) { - profile.experience = companies.slice(0, 3).map(company => ({ - company: company.trim(), - title: "Position", - location: "Location", - duration: "Duration", - description: "Description" - })); - } - } - - // Extract education - const educationMatches = text.match(/Education\s+(.*?)(?=Skills|Languages|$)/s); - if (educationMatches) { - const eduText = educationMatches[1]; - const schools = eduText.match(/([A-Z][a-zA-Z\s]+(?:University|School|College|Institute))/g); - if (schools) { - profile.education = schools.slice(0, 2).map(school => ({ - institution: school.trim(), - degree: "Degree", - year: "Year", - location: "Location" - })); - } - } - - // Extract languages - const languageMatches = text.match(/Languages\s+(.*?)(?=$)/s); - if (languageMatches) { - const langText = languageMatches[1]; - const languages = langText.match(/(English|Spanish|Portuguese|French|German|Italian|Chinese|Japanese).*?(Native|Professional|Elementary|Limited|Fluent)/g); - if (languages) { - profile.languages = languages.map(lang => { - const [language, proficiency] = lang.split(/\s+(?=Native|Professional|Elementary|Limited|Fluent)/); - return { - language: language.trim(), - proficiency: proficiency || "Unknown" - }; - }); - } - } - - return profile; -} - -function transformToLinkedInSchema(parsedData) { - return { - success: true, - message: "PDF parsed successfully", - data: parsedData - }; +import fs from 'node:fs'; +import path from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; +import { parseLinkedInPDF } from '../../dist/index.js'; +import { expectedTestResumeProfile } from '../fixtures/expected-test-resume-profile.js'; + +function valuesMatch(actual, expected) { + return isDeepStrictEqual(actual, expected); } async function runFullE2ETest() { - console.log("🚀 Starting Full E2E Test for PDF Parser"); - console.log("=" .repeat(50)); + console.log('🚀 Starting Full E2E Test for PDF Parser'); + console.log('='.repeat(50)); try { - // Test 1: Load the test PDF file - console.log("\n📋 Test 1: Loading Test PDF"); - const testPdfPath = path.join(process.cwd(), 'test_resume.pdf'); + console.log('\n📋 Test 1: Loading Test PDF'); + const testPdfPath = path.join( + process.cwd(), + 'tests', + 'fixtures', + 'test_resume.pdf' + ); if (!fs.existsSync(testPdfPath)) { throw new Error(`Test PDF file not found at ${testPdfPath}`); } const pdfBuffer = fs.readFileSync(testPdfPath); - console.log(`✅ Loaded test PDF: ${testPdfPath} (${pdfBuffer.length} bytes)`); - - // Test 2: Parse PDF content - console.log("\n📋 Test 2: PDF Content Extraction"); - const pdfData = await pdfParse(pdfBuffer); - const text = pdfData.text; - console.log(`✅ Extracted ${text.length} characters of text`); - - // Test 3: Parse structured data - console.log("\n📋 Test 3: Structured Data Parsing"); - const parsedData = parseLinkedInPDFTest(text); - console.log(`✅ Parsed profile data for: ${parsedData.name}`); - - // Test 4: Transform to expected schema - console.log("\n📋 Test 4: Schema Transformation"); - const result = transformToLinkedInSchema(parsedData); - console.log(`✅ Transformed to LinkedIn schema format`); - - // Test 5: Validate expected test data - console.log("\n📋 Test 5: Test Data Validation"); - - const validationTests = [ - { - name: "Has test name 'John Silva'", - test: () => result.data.name && result.data.name.includes('John Silva'), - value: result.data.name - }, - { - name: "Has test email", - test: () => result.data.contact.email && result.data.contact.email.includes('john.silva@email.com'), - value: result.data.contact.email - }, - { - name: "Has LinkedIn URL", - test: () => result.data.contact.linkedin_url && result.data.contact.linkedin_url.includes('linkedin.com'), - value: result.data.contact.linkedin_url - }, - { - name: "Has location information", - test: () => result.data.location && result.data.location.length > 0, - value: result.data.location - }, - { - name: "Has summary content", - test: () => result.data.summary && result.data.summary.length > 50, - value: `${result.data.summary?.substring(0, 50)}...` - }, - { - name: "Has experience entries", - test: () => Array.isArray(result.data.experience) && result.data.experience.length > 0, - value: `${result.data.experience?.length} entries` - }, - { - name: "Has education entries", - test: () => Array.isArray(result.data.education) && result.data.education.length > 0, - value: `${result.data.education?.length} entries` - }, - { - name: "Has language information", - test: () => Array.isArray(result.data.languages) && result.data.languages.length > 0, - value: `${result.data.languages?.length} languages` - } + console.log( + `✅ Loaded test PDF: ${testPdfPath} (${pdfBuffer.length} bytes)` + ); + + console.log('\n📋 Test 2: Library Parsing'); + const result = await parseLinkedInPDF(pdfBuffer, { includeRawText: true }); + console.log(`✅ Parsed profile data for: ${result.profile.name}`); + + console.log('\n📋 Test 3: Strict Fixture Validation'); + const checks = [ + [ + 'Raw text length', + result.rawText?.length, + expectedTestResumeProfile.rawTextLength, + ], + [ + 'Raw text includes name', + result.rawText?.includes(expectedTestResumeProfile.name), + true, + ], + [ + 'Raw text includes education', + result.rawText?.includes('Universidade Veiga de Almeida'), + true, + ], + ['Parsed name', result.profile.name, expectedTestResumeProfile.name], + [ + 'Parsed headline', + result.profile.headline, + expectedTestResumeProfile.headline, + ], + [ + 'Parsed location', + result.profile.location, + expectedTestResumeProfile.location, + ], + ['Parsed email', result.profile.contact.email, undefined], + [ + 'Parsed LinkedIn URL', + result.profile.contact.linkedin_url, + expectedTestResumeProfile.contact.linkedin_url, + ], + [ + 'Parsed top skills', + result.profile.top_skills, + expectedTestResumeProfile.top_skills, + ], + [ + 'Parsed summary', + result.profile.summary, + expectedTestResumeProfile.summary, + ], + [ + 'Parsed languages', + result.profile.languages, + expectedTestResumeProfile.languages, + ], + [ + 'Parsed experience count', + result.profile.experience.length, + expectedTestResumeProfile.experienceLength, + ], + [ + 'Parsed education count', + result.profile.education.length, + expectedTestResumeProfile.educationLength, + ], ]; - let passedTests = 0; - for (const test of validationTests) { - const passed = test.test(); - const status = passed ? "✅ PASS" : "❌ FAIL"; - console.log(` ${status} - ${test.name}: ${test.value || 'N/A'}`); - if (passed) passedTests++; - } - - // Test 6: JSON structure validation - console.log("\n📋 Test 6: JSON Structure Validation"); + const failedChecks = checks.filter( + ([, actual, expected]) => !valuesMatch(actual, expected) + ); - const structureTests = [ - { name: "Has success field", test: result.success === true }, - { name: "Has data object", test: result.data && typeof result.data === 'object' }, - { name: "Has contact object", test: result.data.contact && typeof result.data.contact === 'object' }, - { name: "Experience is array", test: Array.isArray(result.data.experience) }, - { name: "Education is array", test: Array.isArray(result.data.education) }, - { name: "Languages is array", test: Array.isArray(result.data.languages) } - ]; - - let structurePassed = 0; - for (const test of structureTests) { - const status = test.test ? "✅ PASS" : "❌ FAIL"; - console.log(` ${status} - ${test.name}`); - if (test.test) structurePassed++; + for (const [name, actual, expected] of checks) { + const passed = valuesMatch(actual, expected); + console.log( + ` ${passed ? '✅ PASS' : '❌ FAIL'} - ${name}: ${JSON.stringify(actual)}` + ); } - // Final Results - console.log("\n" + "=" .repeat(50)); - console.log("🎯 FINAL RESULTS"); - console.log("=" .repeat(50)); - console.log(`📊 Data Validation: ${passedTests}/${validationTests.length} tests passed`); - console.log(`🏗️ Structure Validation: ${structurePassed}/${structureTests.length} tests passed`); - - const totalPassed = passedTests + structurePassed; - const totalTests = validationTests.length + structureTests.length; - console.log(`🎉 Overall: ${totalPassed}/${totalTests} tests passed`); - - // Show the final JSON structure - console.log("\n📄 Final Parsed JSON Structure:"); - console.log(JSON.stringify(result, null, 2)); - - const success = totalPassed === totalTests; - console.log(`\n${success ? '🎉 ALL TESTS PASSED!' : '⚠️ Some tests failed'}`); - - return success; + console.log('\n📄 Final Parsed Summary:'); + console.log( + JSON.stringify( + { + name: result.profile.name, + contact: result.profile.contact, + top_skills: result.profile.top_skills, + languages: result.profile.languages, + experienceCount: result.profile.experience.length, + educationCount: result.profile.education.length, + warningCount: result.warnings.length, + rawTextLength: result.rawText?.length, + }, + null, + 2 + ) + ); + + if (failedChecks.length > 0) { + throw new Error(`${failedChecks.length} strict validation checks failed`); + } + console.log('\n🎉 ALL TESTS PASSED!'); + return true; } catch (error) { - console.error("\n❌ E2E Test Failed:"); + console.error('\n❌ E2E Test Failed:'); console.error(error); return false; } } -// Run the test runFullE2ETest().then(success => { process.exit(success ? 0 : 1); -}); \ No newline at end of file +}); diff --git a/tests/e2e/json-fixtures.test.ts b/tests/e2e/json-fixtures.test.ts new file mode 100644 index 0000000..89ec404 --- /dev/null +++ b/tests/e2e/json-fixtures.test.ts @@ -0,0 +1,90 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { formatLinkedInProfile, parseLinkedInPDF } from '../../src/index.js'; +import { + verifyJsonFixtures, + type JsonFixtureDependencies, +} from '../../src/json-fixtures.js'; +import { getNodeDirectoryEntryKind } from '../../src/node-directory-entry.js'; + +describe('PDF/JSON fixture baselines', () => { + const fixturesPath = fileURLToPath(new URL('../fixtures', import.meta.url)); + + test('verifies every checked-in structured JSON fixture against its PDF', async () => { + const result = await verifyJsonFixtures({ + dependencies: createNodeJsonFixtureDependencies(), + folderPath: fixturesPath, + includeRawText: false, + }); + + expect(result).toEqual({ + exitCode: 0, + stderr: '', + stdout: expect.stringContaining('Verified 2 PDF/JSON pair(s)'), + }); + expect(result.stdout).toContain(path.join(fixturesPath, 'Profile.pdf')); + expect(result.stdout).toContain(path.join(fixturesPath, 'test_resume.pdf')); + }); + + test('keeps checked-in fixture JSON structured-only', () => { + const jsonFileNames = fs + .readdirSync(fixturesPath) + .filter(fileName => fileName.toLowerCase().endsWith('.json')) + .sort((left, right) => left.localeCompare(right)); + + expect(jsonFileNames).toEqual(['Profile.json', 'test_resume.json']); + + for (const jsonFileName of jsonFileNames) { + const parsedJson: unknown = JSON.parse( + fs.readFileSync(path.join(fixturesPath, jsonFileName), 'utf8') + ); + + expect(parsedJson).not.toHaveProperty('rawText'); + } + }); + + test('verifies every checked-in plain-text fixture against its PDF', async () => { + const pdfFileNames = fs + .readdirSync(fixturesPath) + .filter(fileName => fileName.toLowerCase().endsWith('.pdf')) + .sort((left, right) => left.localeCompare(right)); + + expect(pdfFileNames).toEqual(['Profile.pdf', 'test_resume.pdf']); + + for (const pdfFileName of pdfFileNames) { + const pdfPath = path.join(fixturesPath, pdfFileName); + const fileStem = path.basename(pdfFileName, path.extname(pdfFileName)); + const { profile } = await parseLinkedInPDF(fs.readFileSync(pdfPath)); + + expect(formatLinkedInProfile(profile)).toBe( + readFixtureText(path.join(fixturesPath, `${fileStem}.txt`)) + ); + expect(formatLinkedInProfile(profile, { includeContact: true })).toBe( + readFixtureText(path.join(fixturesPath, `${fileStem}.with-contact.txt`)) + ); + } + }); +}); + +function readFixtureText(filePath: string): string { + return fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n').trimEnd(); +} + +function createNodeJsonFixtureDependencies(): JsonFixtureDependencies { + return { + directoryExists: directoryPath => + fs.existsSync(directoryPath) && fs.statSync(directoryPath).isDirectory(), + fileExists: fs.existsSync, + listDirectory: directoryPath => + fs.readdirSync(directoryPath, { withFileTypes: true }).map(entry => ({ + kind: getNodeDirectoryEntryKind(directoryPath, entry), + name: entry.name, + })), + parsePdf: parseLinkedInPDF, + readFile: fs.readFileSync, + readTextFile: filePath => fs.readFileSync(filePath, 'utf8'), + resolvePath: path.resolve, + writeTextFile: fs.writeFileSync, + }; +} diff --git a/tests/fixtures/Profile.json b/tests/fixtures/Profile.json new file mode 100644 index 0000000..bdf4a8b --- /dev/null +++ b/tests/fixtures/Profile.json @@ -0,0 +1,536 @@ +{ + "profile": { + "name": "Harold Martin", + "headline": "CTO @ SVRN", + "location": "Los Angeles, California, United States", + "contact": { + "email": "harold.martin@gmail.com", + "linkedin_url": "https://linkedin.com/in/harold-martin-98526971", + "links": [ + { + "label": "LinkedIn", + "rawText": "www.linkedin.com/in/harold- martin-98526971 (LinkedIn)", + "url": "https://linkedin.com/in/harold-martin-98526971" + } + ] + }, + "top_skills": [ + "Python", + "Amazon Web Services (AWS)", + "ElasticSearch" + ], + "languages": [], + "certifications": [ + "MITx 14.310Fx: Data Analysis in Social Science", + "Certificate of Completion - 23 hours of Android development training", + "MITx 6.431x: Probability - The Science of Uncertainty and Data" + ], + "volunteer_work": [], + "projects": [], + "publications": [], + "honors_awards": [], + "experience_groups": [ + { + "company": "SVRN", + "positions": [ + { + "dates": { + "durationText": "7 months", + "originalText": "November 2025 - Present (7 months)", + "start": { + "iso": "2025-11", + "precision": "month", + "text": "November 2025" + }, + "kind": "current" + }, + "title": "Chief Technology Officer", + "duration": "November 2025 - Present", + "description": "" + } + ] + }, + { + "company": "Self-employed", + "positions": [ + { + "dates": { + "durationText": "2 years", + "originalText": "January 2024 - December 2025 (2 years)", + "start": { + "iso": "2024-01", + "precision": "month", + "text": "January 2024" + }, + "end": { + "iso": "2025-12", + "precision": "month", + "text": "December 2025" + }, + "kind": "completed" + }, + "title": "Mobile and AI Consultant", + "duration": "January 2024 - December 2025", + "description": "" + } + ] + }, + { + "company": "Jump", + "positions": [ + { + "dates": { + "durationText": "1 year", + "originalText": "December 2022 - November 2023 (1 year)", + "start": { + "iso": "2022-12", + "precision": "month", + "text": "December 2022" + }, + "end": { + "iso": "2023-11", + "precision": "month", + "text": "November 2023" + }, + "kind": "completed" + }, + "title": "Mobile Lead", + "duration": "December 2022 - November 2023", + "location": "Los Angeles, California, United States", + "description": "" + } + ] + }, + { + "company": "AllTrails", + "positions": [ + { + "dates": { + "durationText": "1 year 2 months", + "originalText": "November 2021 - December 2022 (1 year 2 months)", + "start": { + "iso": "2021-11", + "precision": "month", + "text": "November 2021" + }, + "end": { + "iso": "2022-12", + "precision": "month", + "text": "December 2022" + }, + "kind": "completed" + }, + "title": "Senior Android Engineer", + "duration": "November 2021 - December 2022", + "description": "" + } + ] + }, + { + "company": "Tinder, Inc.", + "positions": [ + { + "dates": { + "durationText": "4 years 5 months", + "originalText": "July 2017 - November 2021 (4 years 5 months)", + "start": { + "iso": "2017-07", + "precision": "month", + "text": "July 2017" + }, + "end": { + "iso": "2021-11", + "precision": "month", + "text": "November 2021" + }, + "kind": "completed" + }, + "title": "Senior Android Engineer", + "duration": "July 2017 - November 2021", + "location": "Greater Los Angeles Area", + "description": "" + } + ] + }, + { + "company": "WikiRealty", + "positions": [ + { + "dates": { + "durationText": "1 year 1 month", + "originalText": "January 2015 - January 2016 (1 year 1 month)", + "start": { + "iso": "2015-01", + "precision": "month", + "text": "January 2015" + }, + "end": { + "iso": "2016-01", + "precision": "month", + "text": "January 2016" + }, + "kind": "completed" + }, + "title": "Lead Engineer", + "duration": "January 2015 - January 2016", + "location": "Santa Monica, CA", + "description": "" + } + ] + }, + { + "company": "Whisper", + "positions": [ + { + "dates": { + "durationText": "9 months", + "originalText": "May 2014 - January 2015 (9 months)", + "start": { + "iso": "2014-05", + "precision": "month", + "text": "May 2014" + }, + "end": { + "iso": "2015-01", + "precision": "month", + "text": "January 2015" + }, + "kind": "completed" + }, + "title": "Technical Manager", + "duration": "May 2014 - January 2015", + "location": "Venice, CA", + "description": "" + } + ] + }, + { + "company": "OpenX", + "positions": [ + { + "dates": { + "durationText": "2 years", + "originalText": "June 2012 - May 2014 (2 years)", + "start": { + "iso": "2012-06", + "precision": "month", + "text": "June 2012" + }, + "end": { + "iso": "2014-05", + "precision": "month", + "text": "May 2014" + }, + "kind": "completed" + }, + "title": "Software Engineer", + "duration": "June 2012 - May 2014", + "location": "Pasadena, CA", + "description": "" + } + ] + }, + { + "company": "California Institute of Technology", + "positions": [ + { + "dates": { + "durationText": "4 months", + "originalText": "June 2011 - September 2011 (4 months)", + "start": { + "iso": "2011-06", + "precision": "month", + "text": "June 2011" + }, + "end": { + "iso": "2011-09", + "precision": "month", + "text": "September 2011" + }, + "kind": "completed" + }, + "title": "Undergraduate Researcher", + "duration": "June 2011 - September 2011", + "location": "Pasadena, CA", + "description": "Designed an ARM microprocessor based self-configuring controller for mobile experiments. Selected computing architecture, constructed electronics, and programmed a/d interfaces. Performed literature review of available algorithms, optimized and implemented for chosen platform. Created friendly device interface for real time monitoring and reconfiguring. Analyzed performance results." + } + ] + }, + { + "company": "Intel Corporation", + "positions": [ + { + "dates": { + "durationText": "4 months", + "originalText": "June 2007 - September 2007 (4 months)", + "start": { + "iso": "2007-06", + "precision": "month", + "text": "June 2007" + }, + "end": { + "iso": "2007-09", + "precision": "month", + "text": "September 2007" + }, + "kind": "completed" + }, + "title": "Platform Engineer Intern", + "duration": "June 2007 - September 2007", + "location": "Dupont, WA", + "description": "" + } + ] + } + ], + "experience": [ + { + "dates": { + "durationText": "7 months", + "originalText": "November 2025 - Present (7 months)", + "start": { + "iso": "2025-11", + "precision": "month", + "text": "November 2025" + }, + "kind": "current" + }, + "title": "Chief Technology Officer", + "company": "SVRN", + "duration": "November 2025 - Present", + "description": "" + }, + { + "dates": { + "durationText": "2 years", + "originalText": "January 2024 - December 2025 (2 years)", + "start": { + "iso": "2024-01", + "precision": "month", + "text": "January 2024" + }, + "end": { + "iso": "2025-12", + "precision": "month", + "text": "December 2025" + }, + "kind": "completed" + }, + "title": "Mobile and AI Consultant", + "company": "Self-employed", + "duration": "January 2024 - December 2025", + "description": "" + }, + { + "dates": { + "durationText": "1 year", + "originalText": "December 2022 - November 2023 (1 year)", + "start": { + "iso": "2022-12", + "precision": "month", + "text": "December 2022" + }, + "end": { + "iso": "2023-11", + "precision": "month", + "text": "November 2023" + }, + "kind": "completed" + }, + "title": "Mobile Lead", + "company": "Jump", + "duration": "December 2022 - November 2023", + "location": "Los Angeles, California, United States", + "description": "" + }, + { + "dates": { + "durationText": "1 year 2 months", + "originalText": "November 2021 - December 2022 (1 year 2 months)", + "start": { + "iso": "2021-11", + "precision": "month", + "text": "November 2021" + }, + "end": { + "iso": "2022-12", + "precision": "month", + "text": "December 2022" + }, + "kind": "completed" + }, + "title": "Senior Android Engineer", + "company": "AllTrails", + "duration": "November 2021 - December 2022", + "description": "" + }, + { + "dates": { + "durationText": "4 years 5 months", + "originalText": "July 2017 - November 2021 (4 years 5 months)", + "start": { + "iso": "2017-07", + "precision": "month", + "text": "July 2017" + }, + "end": { + "iso": "2021-11", + "precision": "month", + "text": "November 2021" + }, + "kind": "completed" + }, + "title": "Senior Android Engineer", + "company": "Tinder, Inc.", + "duration": "July 2017 - November 2021", + "location": "Greater Los Angeles Area", + "description": "" + }, + { + "dates": { + "durationText": "1 year 1 month", + "originalText": "January 2015 - January 2016 (1 year 1 month)", + "start": { + "iso": "2015-01", + "precision": "month", + "text": "January 2015" + }, + "end": { + "iso": "2016-01", + "precision": "month", + "text": "January 2016" + }, + "kind": "completed" + }, + "title": "Lead Engineer", + "company": "WikiRealty", + "duration": "January 2015 - January 2016", + "location": "Santa Monica, CA", + "description": "" + }, + { + "dates": { + "durationText": "9 months", + "originalText": "May 2014 - January 2015 (9 months)", + "start": { + "iso": "2014-05", + "precision": "month", + "text": "May 2014" + }, + "end": { + "iso": "2015-01", + "precision": "month", + "text": "January 2015" + }, + "kind": "completed" + }, + "title": "Technical Manager", + "company": "Whisper", + "duration": "May 2014 - January 2015", + "location": "Venice, CA", + "description": "" + }, + { + "dates": { + "durationText": "2 years", + "originalText": "June 2012 - May 2014 (2 years)", + "start": { + "iso": "2012-06", + "precision": "month", + "text": "June 2012" + }, + "end": { + "iso": "2014-05", + "precision": "month", + "text": "May 2014" + }, + "kind": "completed" + }, + "title": "Software Engineer", + "company": "OpenX", + "duration": "June 2012 - May 2014", + "location": "Pasadena, CA", + "description": "" + }, + { + "dates": { + "durationText": "4 months", + "originalText": "June 2011 - September 2011 (4 months)", + "start": { + "iso": "2011-06", + "precision": "month", + "text": "June 2011" + }, + "end": { + "iso": "2011-09", + "precision": "month", + "text": "September 2011" + }, + "kind": "completed" + }, + "title": "Undergraduate Researcher", + "company": "California Institute of Technology", + "duration": "June 2011 - September 2011", + "location": "Pasadena, CA", + "description": "Designed an ARM microprocessor based self-configuring controller for mobile experiments. Selected computing architecture, constructed electronics, and programmed a/d interfaces. Performed literature review of available algorithms, optimized and implemented for chosen platform. Created friendly device interface for real time monitoring and reconfiguring. Analyzed performance results." + }, + { + "dates": { + "durationText": "4 months", + "originalText": "June 2007 - September 2007 (4 months)", + "start": { + "iso": "2007-06", + "precision": "month", + "text": "June 2007" + }, + "end": { + "iso": "2007-09", + "precision": "month", + "text": "September 2007" + }, + "kind": "completed" + }, + "title": "Platform Engineer Intern", + "company": "Intel Corporation", + "duration": "June 2007 - September 2007", + "location": "Dupont, WA", + "description": "" + } + ], + "education": [ + { + "dates": { + "originalText": "2006 - 2012", + "start": { + "iso": "2006", + "precision": "year", + "text": "2006" + }, + "end": { + "iso": "2012", + "precision": "year", + "text": "2012" + }, + "kind": "completed" + }, + "institution": "California Institute of Technology", + "degree": "BS, Chemical Engineering", + "year": "2006 - 2012", + "location": "" + } + ] + }, + "warnings": [], + "diagnostics": { + "confidence": 1, + "isEmpty": false, + "isLikelyLinkedInExport": true, + "sectionsFound": [ + "contact", + "experience", + "top_skills", + "certifications", + "education" + ] + } +} diff --git a/tests/fixtures/Profile.pdf b/tests/fixtures/Profile.pdf new file mode 100644 index 0000000..23dc800 Binary files /dev/null and b/tests/fixtures/Profile.pdf differ diff --git a/tests/fixtures/Profile.txt b/tests/fixtures/Profile.txt new file mode 100644 index 0000000..b66db24 --- /dev/null +++ b/tests/fixtures/Profile.txt @@ -0,0 +1,56 @@ +Harold Martin +CTO @ SVRN +Los Angeles, California, United States + +Experience +Chief Technology Officer at SVRN +November 2025 - Present + +Mobile and AI Consultant at Self-employed +January 2024 - December 2025 + +Mobile Lead at Jump +December 2022 - November 2023 +Los Angeles, California, United States + +Senior Android Engineer at AllTrails +November 2021 - December 2022 + +Senior Android Engineer at Tinder, Inc. +July 2017 - November 2021 +Greater Los Angeles Area + +Lead Engineer at WikiRealty +January 2015 - January 2016 +Santa Monica, CA + +Technical Manager at Whisper +May 2014 - January 2015 +Venice, CA + +Software Engineer at OpenX +June 2012 - May 2014 +Pasadena, CA + +Undergraduate Researcher at California Institute of Technology +June 2011 - September 2011 +Pasadena, CA +Designed an ARM microprocessor based self-configuring controller for mobile experiments. Selected computing architecture, constructed electronics, and programmed a/d interfaces. Performed literature review of available algorithms, optimized and implemented for chosen platform. Created friendly device interface for real time monitoring and reconfiguring. Analyzed performance results. + +Platform Engineer Intern at Intel Corporation +June 2007 - September 2007 +Dupont, WA + +Education +BS, Chemical Engineering, California Institute of Technology +2006 - 2012 + +Top Skills +- Python +- Amazon Web Services (AWS) +- ElasticSearch + +Certifications +- MITx 14.310Fx: Data Analysis in Social Science +- Certificate of Completion - 23 hours of Android development training +- MITx 6.431x: Probability - The Science of Uncertainty and Data diff --git a/tests/fixtures/Profile.with-contact.txt b/tests/fixtures/Profile.with-contact.txt new file mode 100644 index 0000000..04be4d7 --- /dev/null +++ b/tests/fixtures/Profile.with-contact.txt @@ -0,0 +1,60 @@ +Harold Martin +CTO @ SVRN +Los Angeles, California, United States + +Contact +Email: harold.martin@gmail.com +LinkedIn: https://linkedin.com/in/harold-martin-98526971 + +Experience +Chief Technology Officer at SVRN +November 2025 - Present + +Mobile and AI Consultant at Self-employed +January 2024 - December 2025 + +Mobile Lead at Jump +December 2022 - November 2023 +Los Angeles, California, United States + +Senior Android Engineer at AllTrails +November 2021 - December 2022 + +Senior Android Engineer at Tinder, Inc. +July 2017 - November 2021 +Greater Los Angeles Area + +Lead Engineer at WikiRealty +January 2015 - January 2016 +Santa Monica, CA + +Technical Manager at Whisper +May 2014 - January 2015 +Venice, CA + +Software Engineer at OpenX +June 2012 - May 2014 +Pasadena, CA + +Undergraduate Researcher at California Institute of Technology +June 2011 - September 2011 +Pasadena, CA +Designed an ARM microprocessor based self-configuring controller for mobile experiments. Selected computing architecture, constructed electronics, and programmed a/d interfaces. Performed literature review of available algorithms, optimized and implemented for chosen platform. Created friendly device interface for real time monitoring and reconfiguring. Analyzed performance results. + +Platform Engineer Intern at Intel Corporation +June 2007 - September 2007 +Dupont, WA + +Education +BS, Chemical Engineering, California Institute of Technology +2006 - 2012 + +Top Skills +- Python +- Amazon Web Services (AWS) +- ElasticSearch + +Certifications +- MITx 14.310Fx: Data Analysis in Social Science +- Certificate of Completion - 23 hours of Android development training +- MITx 6.431x: Probability - The Science of Uncertainty and Data diff --git a/tests/fixtures/expected-test-resume-profile.d.ts b/tests/fixtures/expected-test-resume-profile.d.ts new file mode 100644 index 0000000..61a1173 --- /dev/null +++ b/tests/fixtures/expected-test-resume-profile.d.ts @@ -0,0 +1,26 @@ +import type { + Education, + Experience, + Language, + LinkedInProfile, + ParseWarning, +} from '../../src/index.js'; + +export interface ExpectedTestResumeProfile { + name: string; + headline: string; + location: string; + contact: LinkedInProfile['contact']; + top_skills: string[]; + languages: Language[]; + summary: string; + experienceLength: number; + educationLength: number; + firstExperience: Experience; + cartaSeniorEngineerExperience: Experience; + firstEducation: Education; + warnings: ParseWarning[]; + rawTextLength: number; +} + +export declare const expectedTestResumeProfile: ExpectedTestResumeProfile; diff --git a/tests/fixtures/expected-test-resume-profile.js b/tests/fixtures/expected-test-resume-profile.js new file mode 100644 index 0000000..fbe4de4 --- /dev/null +++ b/tests/fixtures/expected-test-resume-profile.js @@ -0,0 +1,32 @@ +import { readFileSync } from 'node:fs'; + +const testResumeResult = JSON.parse( + readFileSync(new URL('./test_resume.json', import.meta.url), 'utf8') +); +const { profile, warnings } = testResumeResult; +const cartaSeniorEngineerExperience = profile.experience.find( + experience => + experience.company === 'Carta' && + experience.title === 'Senior Software Engineer' +); + +if (!cartaSeniorEngineerExperience) { + throw new Error('Expected Carta senior software engineer experience fixture'); +} + +export const expectedTestResumeProfile = Object.freeze({ + name: profile.name, + headline: profile.headline, + location: profile.location, + contact: profile.contact, + top_skills: profile.top_skills, + languages: profile.languages, + summary: profile.summary, + experienceLength: profile.experience.length, + educationLength: profile.education.length, + firstExperience: profile.experience[0], + cartaSeniorEngineerExperience, + firstEducation: profile.education[0], + warnings, + rawTextLength: 13078, +}); diff --git a/tests/fixtures/test_resume.json b/tests/fixtures/test_resume.json new file mode 100644 index 0000000..d217bc4 --- /dev/null +++ b/tests/fixtures/test_resume.json @@ -0,0 +1,763 @@ +{ + "profile": { + "name": "Arkady Zalkowitsch", + "headline": "Senior Engineering Manager @ Commure | ex-Carta | MBA in Business Management", + "location": "Sunnyvale, California, United States", + "contact": { + "linkedin_url": "https://linkedin.com/in/arkadyzalko", + "links": [ + { + "label": "LinkedIn", + "rawText": "www.linkedin.com/in/arkadyzalko (LinkedIn)", + "url": "https://linkedin.com/in/arkadyzalko" + } + ] + }, + "top_skills": [ + "Strategic Roadmaps", + "Electronic Engineering", + "Project Planning" + ], + "languages": [ + { + "language": "Português", + "proficiency": "Native or Bilingual" + }, + { + "language": "Inglês", + "proficiency": "Professional Working" + }, + { + "language": "Espanhol", + "proficiency": "Elementary" + } + ], + "certifications": [], + "volunteer_work": [], + "projects": [], + "publications": [], + "honors_awards": [], + "summary": "Engineering Manager with ~20 years in software and 10+ in leadership. I lead teams that sit at the intersection of product, operations and integrations, recently helping to shape an ERP- style operating model for PE firms and their portfolios at Carta, connecting onboarding, offboarding, document workflows and financial integrations to firm-level outcomes with unified experience.", + "experience_groups": [ + { + "company": "Commure", + "positions": [ + { + "dates": { + "durationText": "4 months", + "originalText": "February 2026 - Present (4 months)", + "start": { + "iso": "2026-02", + "precision": "month", + "text": "February 2026" + }, + "kind": "current" + }, + "title": "Senior Engineering Manager", + "duration": "February 2026 - Present", + "location": "Mountain View, California, United States", + "description": "" + } + ] + }, + { + "company": "Boba Joy", + "positions": [ + { + "dates": { + "durationText": "1 year 7 months", + "originalText": "November 2024 - Present (1 year 7 months)", + "start": { + "iso": "2024-11", + "precision": "month", + "text": "November 2024" + }, + "kind": "current" + }, + "title": "Investor & Advisor", + "duration": "November 2024 - Present", + "location": "Brazil", + "description": "As a co-founder and strategic partner at Boba Joy, I focus on turning a great product into a scalable brand and operation. I lead brand positioning, store expansion strategy, and the overall vision of Boba Joy as a next-gen bubble tea micro-chain in Brazil. I defined the brand vision, mission, and “second-wave” positioning, with a clear focus on real fruit, quality, and a family-friendly experience. On the digital side, I led initiatives to improve customer experience through our website and our rewards/loyalty app, connecting the physical stores with an ongoing digital relationship with our customers. I also built and supported the team responsible for operational standards (SOPs/POPs), recipes, and processes to ensure consistency and scalability across locations. From a growth perspective, I co-led the expansion from 1 to 3 stores in just over a year, serving more than 12k customers and validating the model for future franchising. I worked closely with the on-the-ground operating partner to improve store performance, cost control, and the end-to-end customer experience. In parallel, I developed the early franchise playbook including personas, positioning, and scalable processes, to prepare Boba Joy for broader roll-out and structured growth." + } + ] + }, + { + "company": "Carta", + "positions": [ + { + "dates": { + "durationText": "4 years 4 months", + "originalText": "October 2021 - January 2026 (4 years 4 months)", + "start": { + "iso": "2021-10", + "precision": "month", + "text": "October 2021" + }, + "end": { + "iso": "2026-01", + "precision": "month", + "text": "January 2026" + }, + "kind": "completed" + }, + "title": "Engineering Manager", + "duration": "October 2021 - January 2026", + "location": "Santa Clara, CA", + "description": "I lead the Corporation Integrations engineering team at Carta, owning strategy and execution for HRIS and financial integrations, onboarding/offboarding workflows and internal tools that power the support experience. I also previously managed the Customer Success Engineering team during a period of rapid growth. • Increased team delivery velocity by nearly 3× in 3 months by bringing AI assistants into the development process (scaffolding code/tests, streamlining reviews and incident response). • Designed and implemented a unified business-identity workflow that reduced tool fragmentation for internal teams and simplified how customers and support resolve account and access issues. • Partnered with Product, Customer Success, Delivery Ops and Finance to prioritize integrations and internal tooling as a portfolio of bets tied to outcomes such as TTV, ticket deflection and operational efficiency. • Provided coaching and structure for EMs/tech leads around prioritization, stakeholder communication and decision-making under ambiguity, so more decisions could be made effectively without escalation." + }, + { + "dates": { + "durationText": "2 years 4 months", + "originalText": "July 2019 - October 2021 (2 years 4 months)", + "start": { + "iso": "2019-07", + "precision": "month", + "text": "July 2019" + }, + "end": { + "iso": "2021-10", + "precision": "month", + "text": "October 2021" + }, + "kind": "completed" + }, + "title": "Tech Lead Manager", + "duration": "July 2019 - October 2021", + "location": "Palo Alto, CA", + "description": "• Acted as a lead engineer for new business lines, establishing technical foundations for Public Markets, and LLC. • Collaborated with cross-functional teams to translate complex business requirements into scalable systems. • Provided technical leadership, mentoring engineers and unblocking projects as the company expanded." + }, + { + "dates": { + "durationText": "1 year 9 months", + "originalText": "October 2017 - June 2019 (1 year 9 months)", + "start": { + "iso": "2017-10", + "precision": "month", + "text": "October 2017" + }, + "end": { + "iso": "2019-06", + "precision": "month", + "text": "June 2019" + }, + "kind": "completed" + }, + "title": "Senior Software Engineer", + "duration": "October 2017 - June 2019", + "location": "Rio de Janeiro", + "description": "• Developed core equity features in Carta (e.g. regular/custom vesting schedule, and option exercises). • Implemented natural language search capabilities, streamlining user navigation for entities and documents. • Worked on the first initiative to domain decomposition in Carta to define the foundation (standards and services) for microservices. • Contributed to doubling development velocity by improving team standards and architecture. • Served as a technical reference, guiding code reviews and design clarifications for scalable solutions." + } + ], + "totalDuration": "8 years 4 months" + }, + { + "company": "Zestt", + "positions": [ + { + "dates": { + "durationText": "4 years 10 months", + "originalText": "January 2018 - October 2022 (4 years 10 months)", + "start": { + "iso": "2018-01", + "precision": "month", + "text": "January 2018" + }, + "end": { + "iso": "2022-10", + "precision": "month", + "text": "October 2022" + }, + "kind": "completed" + }, + "title": "Engineering Director", + "duration": "January 2018 - October 2022", + "location": "Rio de Janeiro, Brazil", + "description": "I led the development of an ERP platform for SMBs in Brazil, helping the company reach key growth milestones while scaling the engineering organization from 3 engineers to ~15 people. • Managed 3 leads (2 engineering, 1 product) across multiple teams. • Built a collaborative engineering culture across three cross-functional teams (warehouse, financials and integrations), with clear ownership, shared standards and predictable delivery. • Defined and implemented a metrics framework to measure product outcomes and engineering performance. • Led talent acquisition, tightening the interview loop (rubrics, case exercises, structured panel debriefs) to reduce noise in evaluations and improve the quality and fit of new hires over time." + } + ] + }, + { + "company": "Partiu Vantagens!", + "positions": [ + { + "dates": { + "durationText": "2 years 1 month", + "originalText": "October 2015 - October 2017 (2 years 1 month)", + "start": { + "iso": "2015-10", + "precision": "month", + "text": "October 2015" + }, + "end": { + "iso": "2017-10", + "precision": "month", + "text": "October 2017" + }, + "kind": "completed" + }, + "title": "Head of Engineering", + "duration": "October 2015 - October 2017", + "location": "Rio de Janeiro, Brasil", + "description": "I led the Engineering Org at Partiu, partnering directly with the CEO to build and scale a rewards platform connecting residents, stores and property managers, while ensuring the technology roadmap matched the company’s strategy and growth plans. • Managed 3 teams (~12 engineers and 2 designers), balancing short-term delivery with the longer-term evolution of the platform and its integrations. • Led the development of the main consumer rewards mobile app, the in- store POS for real-time reward validation, and the merchant admin portal for configuring discounts, campaigns and performance tracking. • Delivered a staff-facing view and a deep integration with a condominium management system, enabling rewards charges and billing to flow directly onto rent/HOA invoices and unlocking a new distribution and revenue channel. • Translated company goals into clear technical priorities and sequencing, aligning product, engineering and business stakeholders and making build-vs- buy and vendor decisions with cost and complexity in mind. • Mentored other leads and engineers on architecture, delivery practices and people leadership, introducing more structured feedback and coaching to improve ownership, collaboration and reliability of delivery." + } + ] + }, + { + "company": "AevoTech", + "positions": [ + { + "dates": { + "durationText": "8 months", + "originalText": "August 2015 - March 2016 (8 months)", + "start": { + "iso": "2015-08", + "precision": "month", + "text": "August 2015" + }, + "end": { + "iso": "2016-03", + "precision": "month", + "text": "March 2016" + }, + "kind": "completed" + }, + "title": "Engineering Manager", + "duration": "August 2015 - March 2016", + "location": "Greater Rio de Janeiro", + "description": "I led two major initiatives at AevoTech: building robotics solutions for Oil & Gas clients and supporting new startups inside a tech venture builder, connecting engineering execution with portfolio strategy. • Led a team of engineers developing robotics solutions for Oil & Gas companies, overseeing design, implementation, deployment and on-site testing with clients. • Coordinated field operations and technical decisions to ensure the systems met safety, reliability and operational constraints in real production environments. • In the venture builder, partnered with engineering leads and a Product Manager to evaluate potential startups for the portfolio, assessing fit with strategy and technical feasibility. • Guided early product discovery and concept validation, helping founders turn ideas into first versions with clear problem statements, scope and delivery plans. • Helped new teams establish basic operating processes (backlog, releases, communication) and supported recruitment of their initial engineering hires." + } + ] + }, + { + "company": "Inovare", + "positions": [ + { + "dates": { + "durationText": "5 months", + "originalText": "April 2015 - August 2015 (5 months)", + "start": { + "iso": "2015-04", + "precision": "month", + "text": "April 2015" + }, + "end": { + "iso": "2015-08", + "precision": "month", + "text": "August 2015" + }, + "kind": "completed" + }, + "title": "Senior Lead Software Engineer", + "duration": "April 2015 - August 2015", + "location": "Greater Rio de Janeiro", + "description": "I served as a hands-on tech lead on payment and checkout systems, splitting my time between shipping code and putting structure around how work got done. • Built and maintained core payment and checkout flows end to end (Java and C#), focusing on correctness, reliability and a smooth experience for merchants and end users. • Reduced production firefighting by improving logging, automated tests and error handling, making issues easier to detect, debug and fix. • Brought more structure to delivery by breaking large projects into smaller milestones, clarifying priorities and ownership, and creating simple plans the team could execute against. • Turned client and stakeholder requests into clear written engineering requirements and lightweight documentation, which reduced churn and rework for the team." + } + ] + }, + { + "company": "CEPEL", + "positions": [ + { + "dates": { + "durationText": "9 months", + "originalText": "August 2014 - April 2015 (9 months)", + "start": { + "iso": "2014-08", + "precision": "month", + "text": "August 2014" + }, + "end": { + "iso": "2015-04", + "precision": "month", + "text": "April 2015" + }, + "kind": "completed" + }, + "title": "Lead Project Engineer", + "duration": "August 2014 - April 2015", + "location": "Greater Rio de Janeiro", + "description": "I worked on CEPEL’s SOMA asset-monitoring platform, which provides real- time condition monitoring and predictive maintenance for power generation units used by utilities such as FURNAS. • Built data analysis and visualization components in Polymer, JavaScript, TypeScript and Java to improve how operators explored and interpreted asset data. • Improved robustness and performance of SOMA, including a ~60% improvement in query performance for configuration data mapping. • Implemented a tool to analyze the lifespan of thermoelectric turbines in Tubarão (southern Brazil), enabling vibration data acquisition for advanced diagnostics. • Contributed to real-time monitoring and predictive maintenance for plants such as Simplício and Furnas, helping reduce downtime and optimize maintenance planning. • Applied TDD and agile practices to increase test coverage and make deliveries more predictable and easier to evolve safely." + } + ] + }, + { + "company": "CPTI / PUC-Rio", + "positions": [ + { + "dates": { + "durationText": "4 years 3 months", + "originalText": "May 2010 - July 2014 (4 years 3 months)", + "start": { + "iso": "2010-05", + "precision": "month", + "text": "May 2010" + }, + "end": { + "iso": "2014-07", + "precision": "month", + "text": "July 2014" + }, + "kind": "completed" + }, + "title": "Robotics Researcher", + "duration": "May 2010 - July 2014", + "location": "Rua Marquês de São Vicente, 255 - Gávea, Rio de Janeiro - RJ, 22453-900", + "description": "Focusing on advanced inspection technologies, quality assurance, and critical system recovery in the oil and gas sector. Key responsibilities included: • Developing, testing, and operating underwater inspection equipment for high- reliability applications. • Working on field operations logistics on platforms, ships, and testing sites, including embarks on P-52, P-25, and RSV Joe Griffin, where I conducted tests and homologated inspection tools. • Analyzing riser and pipeline data and producing technical reports for clients like Petrobras and Pipeway. • Leading the design and homologation of hardware and software projects, including the AURI (Autonomous Underwater Riser Inspector), which won Petrobras' Innovation Award. • Ensuring quality control and resolving issues in critical systems to maintain operational integrity. This role had a strong focus on quality assurance for systems and processes, particularly for embedded systems used in mission-critical applications. My work involved ensuring reliability and compliance in challenging environments where precision and robustness were essential." + } + ] + }, + { + "company": "CEPEL", + "positions": [ + { + "dates": { + "durationText": "3 years 5 months", + "originalText": "December 2006 - April 2010 (3 years 5 months)", + "start": { + "iso": "2006-12", + "precision": "month", + "text": "December 2006" + }, + "end": { + "iso": "2010-04", + "precision": "month", + "text": "April 2010" + }, + "kind": "completed" + }, + "title": "Technical Researcher – Automation and Robotics (Contractor)", + "duration": "December 2006 - April 2010", + "location": "Av. Horácio Macedo, 354 - Cidade Universitária - Rio de Janeiro - RJ, 21941-911", + "description": "Worked as a Researcher in renewable energy projects for the Department of Specialized Technologies, contributing to key initiatives that advanced the company’s capabilities in the sector. My responsibilities included: • Developing and implementing measurement platforms for solar and wind energy in remote areas, resulting in systems that operated uninterruptedly for over 5 years in challenging environments. • Creating analytical tools to evaluate energy performance and identify optimization opportunities, including one that reduced a 2-month process to just 1 week. • Coordinating engineers and technicians in the development of electronic systems, leading the testing and deployment of cutting-edge tools for solar and wind systems. • Establishing homologation processes for critical systems to ensure compliance, operational reliability, and long-term sustainability. I also led smaller projects that delivered innovative electronic solutions, driving progress in renewable energy technologies. Additionally, I supported an initiative by Brazil’s Ministry of Mines and Energy, evaluating companies, sites, and technologies to enable strategic entry into the wind energy market." + } + ] + }, + { + "company": "Arena Games", + "positions": [ + { + "dates": { + "durationText": "10 months", + "originalText": "August 2005 - May 2006 (10 months)", + "start": { + "iso": "2005-08", + "precision": "month", + "text": "August 2005" + }, + "end": { + "iso": "2006-05", + "precision": "month", + "text": "May 2006" + }, + "kind": "completed" + }, + "title": "Technical Support Analyst", + "duration": "August 2005 - May 2006", + "location": "Rio de Janeiro, Brasil", + "description": "" + } + ] + } + ], + "experience": [ + { + "dates": { + "durationText": "4 months", + "originalText": "February 2026 - Present (4 months)", + "start": { + "iso": "2026-02", + "precision": "month", + "text": "February 2026" + }, + "kind": "current" + }, + "title": "Senior Engineering Manager", + "company": "Commure", + "duration": "February 2026 - Present", + "location": "Mountain View, California, United States", + "description": "" + }, + { + "dates": { + "durationText": "1 year 7 months", + "originalText": "November 2024 - Present (1 year 7 months)", + "start": { + "iso": "2024-11", + "precision": "month", + "text": "November 2024" + }, + "kind": "current" + }, + "title": "Investor & Advisor", + "company": "Boba Joy", + "duration": "November 2024 - Present", + "location": "Brazil", + "description": "As a co-founder and strategic partner at Boba Joy, I focus on turning a great product into a scalable brand and operation. I lead brand positioning, store expansion strategy, and the overall vision of Boba Joy as a next-gen bubble tea micro-chain in Brazil. I defined the brand vision, mission, and “second-wave” positioning, with a clear focus on real fruit, quality, and a family-friendly experience. On the digital side, I led initiatives to improve customer experience through our website and our rewards/loyalty app, connecting the physical stores with an ongoing digital relationship with our customers. I also built and supported the team responsible for operational standards (SOPs/POPs), recipes, and processes to ensure consistency and scalability across locations. From a growth perspective, I co-led the expansion from 1 to 3 stores in just over a year, serving more than 12k customers and validating the model for future franchising. I worked closely with the on-the-ground operating partner to improve store performance, cost control, and the end-to-end customer experience. In parallel, I developed the early franchise playbook including personas, positioning, and scalable processes, to prepare Boba Joy for broader roll-out and structured growth." + }, + { + "dates": { + "durationText": "4 years 4 months", + "originalText": "October 2021 - January 2026 (4 years 4 months)", + "start": { + "iso": "2021-10", + "precision": "month", + "text": "October 2021" + }, + "end": { + "iso": "2026-01", + "precision": "month", + "text": "January 2026" + }, + "kind": "completed" + }, + "title": "Engineering Manager", + "company": "Carta", + "duration": "October 2021 - January 2026", + "location": "Santa Clara, CA", + "description": "I lead the Corporation Integrations engineering team at Carta, owning strategy and execution for HRIS and financial integrations, onboarding/offboarding workflows and internal tools that power the support experience. I also previously managed the Customer Success Engineering team during a period of rapid growth. • Increased team delivery velocity by nearly 3× in 3 months by bringing AI assistants into the development process (scaffolding code/tests, streamlining reviews and incident response). • Designed and implemented a unified business-identity workflow that reduced tool fragmentation for internal teams and simplified how customers and support resolve account and access issues. • Partnered with Product, Customer Success, Delivery Ops and Finance to prioritize integrations and internal tooling as a portfolio of bets tied to outcomes such as TTV, ticket deflection and operational efficiency. • Provided coaching and structure for EMs/tech leads around prioritization, stakeholder communication and decision-making under ambiguity, so more decisions could be made effectively without escalation." + }, + { + "dates": { + "durationText": "2 years 4 months", + "originalText": "July 2019 - October 2021 (2 years 4 months)", + "start": { + "iso": "2019-07", + "precision": "month", + "text": "July 2019" + }, + "end": { + "iso": "2021-10", + "precision": "month", + "text": "October 2021" + }, + "kind": "completed" + }, + "title": "Tech Lead Manager", + "company": "Carta", + "duration": "July 2019 - October 2021", + "location": "Palo Alto, CA", + "description": "• Acted as a lead engineer for new business lines, establishing technical foundations for Public Markets, and LLC. • Collaborated with cross-functional teams to translate complex business requirements into scalable systems. • Provided technical leadership, mentoring engineers and unblocking projects as the company expanded." + }, + { + "dates": { + "durationText": "1 year 9 months", + "originalText": "October 2017 - June 2019 (1 year 9 months)", + "start": { + "iso": "2017-10", + "precision": "month", + "text": "October 2017" + }, + "end": { + "iso": "2019-06", + "precision": "month", + "text": "June 2019" + }, + "kind": "completed" + }, + "title": "Senior Software Engineer", + "company": "Carta", + "duration": "October 2017 - June 2019", + "location": "Rio de Janeiro", + "description": "• Developed core equity features in Carta (e.g. regular/custom vesting schedule, and option exercises). • Implemented natural language search capabilities, streamlining user navigation for entities and documents. • Worked on the first initiative to domain decomposition in Carta to define the foundation (standards and services) for microservices. • Contributed to doubling development velocity by improving team standards and architecture. • Served as a technical reference, guiding code reviews and design clarifications for scalable solutions." + }, + { + "dates": { + "durationText": "4 years 10 months", + "originalText": "January 2018 - October 2022 (4 years 10 months)", + "start": { + "iso": "2018-01", + "precision": "month", + "text": "January 2018" + }, + "end": { + "iso": "2022-10", + "precision": "month", + "text": "October 2022" + }, + "kind": "completed" + }, + "title": "Engineering Director", + "company": "Zestt", + "duration": "January 2018 - October 2022", + "location": "Rio de Janeiro, Brazil", + "description": "I led the development of an ERP platform for SMBs in Brazil, helping the company reach key growth milestones while scaling the engineering organization from 3 engineers to ~15 people. • Managed 3 leads (2 engineering, 1 product) across multiple teams. • Built a collaborative engineering culture across three cross-functional teams (warehouse, financials and integrations), with clear ownership, shared standards and predictable delivery. • Defined and implemented a metrics framework to measure product outcomes and engineering performance. • Led talent acquisition, tightening the interview loop (rubrics, case exercises, structured panel debriefs) to reduce noise in evaluations and improve the quality and fit of new hires over time." + }, + { + "dates": { + "durationText": "2 years 1 month", + "originalText": "October 2015 - October 2017 (2 years 1 month)", + "start": { + "iso": "2015-10", + "precision": "month", + "text": "October 2015" + }, + "end": { + "iso": "2017-10", + "precision": "month", + "text": "October 2017" + }, + "kind": "completed" + }, + "title": "Head of Engineering", + "company": "Partiu Vantagens!", + "duration": "October 2015 - October 2017", + "location": "Rio de Janeiro, Brasil", + "description": "I led the Engineering Org at Partiu, partnering directly with the CEO to build and scale a rewards platform connecting residents, stores and property managers, while ensuring the technology roadmap matched the company’s strategy and growth plans. • Managed 3 teams (~12 engineers and 2 designers), balancing short-term delivery with the longer-term evolution of the platform and its integrations. • Led the development of the main consumer rewards mobile app, the in- store POS for real-time reward validation, and the merchant admin portal for configuring discounts, campaigns and performance tracking. • Delivered a staff-facing view and a deep integration with a condominium management system, enabling rewards charges and billing to flow directly onto rent/HOA invoices and unlocking a new distribution and revenue channel. • Translated company goals into clear technical priorities and sequencing, aligning product, engineering and business stakeholders and making build-vs- buy and vendor decisions with cost and complexity in mind. • Mentored other leads and engineers on architecture, delivery practices and people leadership, introducing more structured feedback and coaching to improve ownership, collaboration and reliability of delivery." + }, + { + "dates": { + "durationText": "8 months", + "originalText": "August 2015 - March 2016 (8 months)", + "start": { + "iso": "2015-08", + "precision": "month", + "text": "August 2015" + }, + "end": { + "iso": "2016-03", + "precision": "month", + "text": "March 2016" + }, + "kind": "completed" + }, + "title": "Engineering Manager", + "company": "AevoTech", + "duration": "August 2015 - March 2016", + "location": "Greater Rio de Janeiro", + "description": "I led two major initiatives at AevoTech: building robotics solutions for Oil & Gas clients and supporting new startups inside a tech venture builder, connecting engineering execution with portfolio strategy. • Led a team of engineers developing robotics solutions for Oil & Gas companies, overseeing design, implementation, deployment and on-site testing with clients. • Coordinated field operations and technical decisions to ensure the systems met safety, reliability and operational constraints in real production environments. • In the venture builder, partnered with engineering leads and a Product Manager to evaluate potential startups for the portfolio, assessing fit with strategy and technical feasibility. • Guided early product discovery and concept validation, helping founders turn ideas into first versions with clear problem statements, scope and delivery plans. • Helped new teams establish basic operating processes (backlog, releases, communication) and supported recruitment of their initial engineering hires." + }, + { + "dates": { + "durationText": "5 months", + "originalText": "April 2015 - August 2015 (5 months)", + "start": { + "iso": "2015-04", + "precision": "month", + "text": "April 2015" + }, + "end": { + "iso": "2015-08", + "precision": "month", + "text": "August 2015" + }, + "kind": "completed" + }, + "title": "Senior Lead Software Engineer", + "company": "Inovare", + "duration": "April 2015 - August 2015", + "location": "Greater Rio de Janeiro", + "description": "I served as a hands-on tech lead on payment and checkout systems, splitting my time between shipping code and putting structure around how work got done. • Built and maintained core payment and checkout flows end to end (Java and C#), focusing on correctness, reliability and a smooth experience for merchants and end users. • Reduced production firefighting by improving logging, automated tests and error handling, making issues easier to detect, debug and fix. • Brought more structure to delivery by breaking large projects into smaller milestones, clarifying priorities and ownership, and creating simple plans the team could execute against. • Turned client and stakeholder requests into clear written engineering requirements and lightweight documentation, which reduced churn and rework for the team." + }, + { + "dates": { + "durationText": "9 months", + "originalText": "August 2014 - April 2015 (9 months)", + "start": { + "iso": "2014-08", + "precision": "month", + "text": "August 2014" + }, + "end": { + "iso": "2015-04", + "precision": "month", + "text": "April 2015" + }, + "kind": "completed" + }, + "title": "Lead Project Engineer", + "company": "CEPEL", + "duration": "August 2014 - April 2015", + "location": "Greater Rio de Janeiro", + "description": "I worked on CEPEL’s SOMA asset-monitoring platform, which provides real- time condition monitoring and predictive maintenance for power generation units used by utilities such as FURNAS. • Built data analysis and visualization components in Polymer, JavaScript, TypeScript and Java to improve how operators explored and interpreted asset data. • Improved robustness and performance of SOMA, including a ~60% improvement in query performance for configuration data mapping. • Implemented a tool to analyze the lifespan of thermoelectric turbines in Tubarão (southern Brazil), enabling vibration data acquisition for advanced diagnostics. • Contributed to real-time monitoring and predictive maintenance for plants such as Simplício and Furnas, helping reduce downtime and optimize maintenance planning. • Applied TDD and agile practices to increase test coverage and make deliveries more predictable and easier to evolve safely." + }, + { + "dates": { + "durationText": "4 years 3 months", + "originalText": "May 2010 - July 2014 (4 years 3 months)", + "start": { + "iso": "2010-05", + "precision": "month", + "text": "May 2010" + }, + "end": { + "iso": "2014-07", + "precision": "month", + "text": "July 2014" + }, + "kind": "completed" + }, + "title": "Robotics Researcher", + "company": "CPTI / PUC-Rio", + "duration": "May 2010 - July 2014", + "location": "Rua Marquês de São Vicente, 255 - Gávea, Rio de Janeiro - RJ, 22453-900", + "description": "Focusing on advanced inspection technologies, quality assurance, and critical system recovery in the oil and gas sector. Key responsibilities included: • Developing, testing, and operating underwater inspection equipment for high- reliability applications. • Working on field operations logistics on platforms, ships, and testing sites, including embarks on P-52, P-25, and RSV Joe Griffin, where I conducted tests and homologated inspection tools. • Analyzing riser and pipeline data and producing technical reports for clients like Petrobras and Pipeway. • Leading the design and homologation of hardware and software projects, including the AURI (Autonomous Underwater Riser Inspector), which won Petrobras' Innovation Award. • Ensuring quality control and resolving issues in critical systems to maintain operational integrity. This role had a strong focus on quality assurance for systems and processes, particularly for embedded systems used in mission-critical applications. My work involved ensuring reliability and compliance in challenging environments where precision and robustness were essential." + }, + { + "dates": { + "durationText": "3 years 5 months", + "originalText": "December 2006 - April 2010 (3 years 5 months)", + "start": { + "iso": "2006-12", + "precision": "month", + "text": "December 2006" + }, + "end": { + "iso": "2010-04", + "precision": "month", + "text": "April 2010" + }, + "kind": "completed" + }, + "title": "Technical Researcher – Automation and Robotics (Contractor)", + "company": "CEPEL", + "duration": "December 2006 - April 2010", + "location": "Av. Horácio Macedo, 354 - Cidade Universitária - Rio de Janeiro - RJ, 21941-911", + "description": "Worked as a Researcher in renewable energy projects for the Department of Specialized Technologies, contributing to key initiatives that advanced the company’s capabilities in the sector. My responsibilities included: • Developing and implementing measurement platforms for solar and wind energy in remote areas, resulting in systems that operated uninterruptedly for over 5 years in challenging environments. • Creating analytical tools to evaluate energy performance and identify optimization opportunities, including one that reduced a 2-month process to just 1 week. • Coordinating engineers and technicians in the development of electronic systems, leading the testing and deployment of cutting-edge tools for solar and wind systems. • Establishing homologation processes for critical systems to ensure compliance, operational reliability, and long-term sustainability. I also led smaller projects that delivered innovative electronic solutions, driving progress in renewable energy technologies. Additionally, I supported an initiative by Brazil’s Ministry of Mines and Energy, evaluating companies, sites, and technologies to enable strategic entry into the wind energy market." + }, + { + "dates": { + "durationText": "10 months", + "originalText": "August 2005 - May 2006 (10 months)", + "start": { + "iso": "2005-08", + "precision": "month", + "text": "August 2005" + }, + "end": { + "iso": "2006-05", + "precision": "month", + "text": "May 2006" + }, + "kind": "completed" + }, + "title": "Technical Support Analyst", + "company": "Arena Games", + "duration": "August 2005 - May 2006", + "location": "Rio de Janeiro, Brasil", + "description": "" + } + ], + "education": [ + { + "dates": { + "originalText": "2017 - 2018", + "start": { + "iso": "2017", + "precision": "year", + "text": "2017" + }, + "end": { + "iso": "2018", + "precision": "year", + "text": "2018" + }, + "kind": "completed" + }, + "institution": "Universidade Veiga de Almeida", + "degree": "Master of Business Administration - MBA, Business Management", + "year": "2017 - 2018", + "location": "" + }, + { + "dates": { + "originalText": "2010 - 2013", + "start": { + "iso": "2010", + "precision": "year", + "text": "2010" + }, + "end": { + "iso": "2013", + "precision": "year", + "text": "2013" + }, + "kind": "completed" + }, + "institution": "Pontifícia Universidade Católica do Rio de Janeiro / PUC-Rio", + "degree": "Bachelor's degree, Control and Automation Engineering", + "year": "2010 - 2013", + "location": "" + }, + { + "dates": { + "originalText": "2006 - 2009", + "start": { + "iso": "2006", + "precision": "year", + "text": "2006" + }, + "end": { + "iso": "2009", + "precision": "year", + "text": "2009" + }, + "kind": "completed" + }, + "institution": "Universidade do Estado do Rio de Janeiro", + "degree": "Bachelor's degree, Electrical and Electronics Engineering", + "year": "2006 - 2009", + "location": "" + }, + { + "dates": { + "originalText": "2002 - 2005", + "start": { + "iso": "2002", + "precision": "year", + "text": "2002" + }, + "end": { + "iso": "2005", + "precision": "year", + "text": "2005" + }, + "kind": "completed" + }, + "institution": "ETE Ferreira Viana (FAETEC)", + "degree": "Telecommunications Technician, Telecommunications Technology/Technician", + "year": "2002 - 2005", + "location": "" + }, + { + "dates": { + "originalText": "2016 - 2016", + "start": { + "iso": "2016", + "precision": "year", + "text": "2016" + }, + "end": { + "iso": "2016", + "precision": "year", + "text": "2016" + }, + "kind": "completed" + }, + "institution": "Free Code Camp", + "degree": "Full Stack Web Development Certification, Computer Software Engineering", + "year": "2016 - 2016", + "location": "" + } + ] + }, + "warnings": [ + { + "code": "missing_profile_field", + "field": "profile.contact.email", + "message": "Could not extract contact email" + } + ], + "diagnostics": { + "confidence": 1, + "isEmpty": false, + "isLikelyLinkedInExport": true, + "sectionsFound": [ + "contact", + "top_skills", + "summary", + "languages", + "experience", + "education" + ] + } +} diff --git a/tests/fixtures/test_resume.pdf b/tests/fixtures/test_resume.pdf new file mode 100644 index 0000000..89a6f89 Binary files /dev/null and b/tests/fixtures/test_resume.pdf differ diff --git a/tests/fixtures/test_resume.txt b/tests/fixtures/test_resume.txt new file mode 100644 index 0000000..1f2470e --- /dev/null +++ b/tests/fixtures/test_resume.txt @@ -0,0 +1,96 @@ +Arkady Zalkowitsch +Senior Engineering Manager @ Commure | ex-Carta | MBA in Business Management +Sunnyvale, California, United States + +Summary +Engineering Manager with ~20 years in software and 10+ in leadership. I lead teams that sit at the intersection of product, operations and integrations, recently helping to shape an ERP- style operating model for PE firms and their portfolios at Carta, connecting onboarding, offboarding, document workflows and financial integrations to firm-level outcomes with unified experience. + +Experience +Senior Engineering Manager at Commure +February 2026 - Present +Mountain View, California, United States + +Investor & Advisor at Boba Joy +November 2024 - Present +Brazil +As a co-founder and strategic partner at Boba Joy, I focus on turning a great product into a scalable brand and operation. I lead brand positioning, store expansion strategy, and the overall vision of Boba Joy as a next-gen bubble tea micro-chain in Brazil. I defined the brand vision, mission, and “second-wave” positioning, with a clear focus on real fruit, quality, and a family-friendly experience. On the digital side, I led initiatives to improve customer experience through our website and our rewards/loyalty app, connecting the physical stores with an ongoing digital relationship with our customers. I also built and supported the team responsible for operational standards (SOPs/POPs), recipes, and processes to ensure consistency and scalability across locations. From a growth perspective, I co-led the expansion from 1 to 3 stores in just over a year, serving more than 12k customers and validating the model for future franchising. I worked closely with the on-the-ground operating partner to improve store performance, cost control, and the end-to-end customer experience. In parallel, I developed the early franchise playbook including personas, positioning, and scalable processes, to prepare Boba Joy for broader roll-out and structured growth. + +Engineering Manager at Carta +October 2021 - January 2026 +Santa Clara, CA +I lead the Corporation Integrations engineering team at Carta, owning strategy and execution for HRIS and financial integrations, onboarding/offboarding workflows and internal tools that power the support experience. I also previously managed the Customer Success Engineering team during a period of rapid growth. • Increased team delivery velocity by nearly 3× in 3 months by bringing AI assistants into the development process (scaffolding code/tests, streamlining reviews and incident response). • Designed and implemented a unified business-identity workflow that reduced tool fragmentation for internal teams and simplified how customers and support resolve account and access issues. • Partnered with Product, Customer Success, Delivery Ops and Finance to prioritize integrations and internal tooling as a portfolio of bets tied to outcomes such as TTV, ticket deflection and operational efficiency. • Provided coaching and structure for EMs/tech leads around prioritization, stakeholder communication and decision-making under ambiguity, so more decisions could be made effectively without escalation. + +Tech Lead Manager at Carta +July 2019 - October 2021 +Palo Alto, CA +• Acted as a lead engineer for new business lines, establishing technical foundations for Public Markets, and LLC. • Collaborated with cross-functional teams to translate complex business requirements into scalable systems. • Provided technical leadership, mentoring engineers and unblocking projects as the company expanded. + +Senior Software Engineer at Carta +October 2017 - June 2019 +Rio de Janeiro +• Developed core equity features in Carta (e.g. regular/custom vesting schedule, and option exercises). • Implemented natural language search capabilities, streamlining user navigation for entities and documents. • Worked on the first initiative to domain decomposition in Carta to define the foundation (standards and services) for microservices. • Contributed to doubling development velocity by improving team standards and architecture. • Served as a technical reference, guiding code reviews and design clarifications for scalable solutions. + +Engineering Director at Zestt +January 2018 - October 2022 +Rio de Janeiro, Brazil +I led the development of an ERP platform for SMBs in Brazil, helping the company reach key growth milestones while scaling the engineering organization from 3 engineers to ~15 people. • Managed 3 leads (2 engineering, 1 product) across multiple teams. • Built a collaborative engineering culture across three cross-functional teams (warehouse, financials and integrations), with clear ownership, shared standards and predictable delivery. • Defined and implemented a metrics framework to measure product outcomes and engineering performance. • Led talent acquisition, tightening the interview loop (rubrics, case exercises, structured panel debriefs) to reduce noise in evaluations and improve the quality and fit of new hires over time. + +Head of Engineering at Partiu Vantagens! +October 2015 - October 2017 +Rio de Janeiro, Brasil +I led the Engineering Org at Partiu, partnering directly with the CEO to build and scale a rewards platform connecting residents, stores and property managers, while ensuring the technology roadmap matched the company’s strategy and growth plans. • Managed 3 teams (~12 engineers and 2 designers), balancing short-term delivery with the longer-term evolution of the platform and its integrations. • Led the development of the main consumer rewards mobile app, the in- store POS for real-time reward validation, and the merchant admin portal for configuring discounts, campaigns and performance tracking. • Delivered a staff-facing view and a deep integration with a condominium management system, enabling rewards charges and billing to flow directly onto rent/HOA invoices and unlocking a new distribution and revenue channel. • Translated company goals into clear technical priorities and sequencing, aligning product, engineering and business stakeholders and making build-vs- buy and vendor decisions with cost and complexity in mind. • Mentored other leads and engineers on architecture, delivery practices and people leadership, introducing more structured feedback and coaching to improve ownership, collaboration and reliability of delivery. + +Engineering Manager at AevoTech +August 2015 - March 2016 +Greater Rio de Janeiro +I led two major initiatives at AevoTech: building robotics solutions for Oil & Gas clients and supporting new startups inside a tech venture builder, connecting engineering execution with portfolio strategy. • Led a team of engineers developing robotics solutions for Oil & Gas companies, overseeing design, implementation, deployment and on-site testing with clients. • Coordinated field operations and technical decisions to ensure the systems met safety, reliability and operational constraints in real production environments. • In the venture builder, partnered with engineering leads and a Product Manager to evaluate potential startups for the portfolio, assessing fit with strategy and technical feasibility. • Guided early product discovery and concept validation, helping founders turn ideas into first versions with clear problem statements, scope and delivery plans. • Helped new teams establish basic operating processes (backlog, releases, communication) and supported recruitment of their initial engineering hires. + +Senior Lead Software Engineer at Inovare +April 2015 - August 2015 +Greater Rio de Janeiro +I served as a hands-on tech lead on payment and checkout systems, splitting my time between shipping code and putting structure around how work got done. • Built and maintained core payment and checkout flows end to end (Java and C#), focusing on correctness, reliability and a smooth experience for merchants and end users. • Reduced production firefighting by improving logging, automated tests and error handling, making issues easier to detect, debug and fix. • Brought more structure to delivery by breaking large projects into smaller milestones, clarifying priorities and ownership, and creating simple plans the team could execute against. • Turned client and stakeholder requests into clear written engineering requirements and lightweight documentation, which reduced churn and rework for the team. + +Lead Project Engineer at CEPEL +August 2014 - April 2015 +Greater Rio de Janeiro +I worked on CEPEL’s SOMA asset-monitoring platform, which provides real- time condition monitoring and predictive maintenance for power generation units used by utilities such as FURNAS. • Built data analysis and visualization components in Polymer, JavaScript, TypeScript and Java to improve how operators explored and interpreted asset data. • Improved robustness and performance of SOMA, including a ~60% improvement in query performance for configuration data mapping. • Implemented a tool to analyze the lifespan of thermoelectric turbines in Tubarão (southern Brazil), enabling vibration data acquisition for advanced diagnostics. • Contributed to real-time monitoring and predictive maintenance for plants such as Simplício and Furnas, helping reduce downtime and optimize maintenance planning. • Applied TDD and agile practices to increase test coverage and make deliveries more predictable and easier to evolve safely. + +Robotics Researcher at CPTI / PUC-Rio +May 2010 - July 2014 +Rua Marquês de São Vicente, 255 - Gávea, Rio de Janeiro - RJ, 22453-900 +Focusing on advanced inspection technologies, quality assurance, and critical system recovery in the oil and gas sector. Key responsibilities included: • Developing, testing, and operating underwater inspection equipment for high- reliability applications. • Working on field operations logistics on platforms, ships, and testing sites, including embarks on P-52, P-25, and RSV Joe Griffin, where I conducted tests and homologated inspection tools. • Analyzing riser and pipeline data and producing technical reports for clients like Petrobras and Pipeway. • Leading the design and homologation of hardware and software projects, including the AURI (Autonomous Underwater Riser Inspector), which won Petrobras' Innovation Award. • Ensuring quality control and resolving issues in critical systems to maintain operational integrity. This role had a strong focus on quality assurance for systems and processes, particularly for embedded systems used in mission-critical applications. My work involved ensuring reliability and compliance in challenging environments where precision and robustness were essential. + +Technical Researcher – Automation and Robotics (Contractor) at CEPEL +December 2006 - April 2010 +Av. Horácio Macedo, 354 - Cidade Universitária - Rio de Janeiro - RJ, 21941-911 +Worked as a Researcher in renewable energy projects for the Department of Specialized Technologies, contributing to key initiatives that advanced the company’s capabilities in the sector. My responsibilities included: • Developing and implementing measurement platforms for solar and wind energy in remote areas, resulting in systems that operated uninterruptedly for over 5 years in challenging environments. • Creating analytical tools to evaluate energy performance and identify optimization opportunities, including one that reduced a 2-month process to just 1 week. • Coordinating engineers and technicians in the development of electronic systems, leading the testing and deployment of cutting-edge tools for solar and wind systems. • Establishing homologation processes for critical systems to ensure compliance, operational reliability, and long-term sustainability. I also led smaller projects that delivered innovative electronic solutions, driving progress in renewable energy technologies. Additionally, I supported an initiative by Brazil’s Ministry of Mines and Energy, evaluating companies, sites, and technologies to enable strategic entry into the wind energy market. + +Technical Support Analyst at Arena Games +August 2005 - May 2006 +Rio de Janeiro, Brasil + +Education +Master of Business Administration - MBA, Business Management, Universidade Veiga de Almeida +2017 - 2018 + +Bachelor's degree, Control and Automation Engineering, Pontifícia Universidade Católica do Rio de Janeiro / PUC-Rio +2010 - 2013 + +Bachelor's degree, Electrical and Electronics Engineering, Universidade do Estado do Rio de Janeiro +2006 - 2009 + +Telecommunications Technician, Telecommunications Technology/Technician, ETE Ferreira Viana (FAETEC) +2002 - 2005 + +Full Stack Web Development Certification, Computer Software Engineering, Free Code Camp +2016 - 2016 + +Top Skills +- Strategic Roadmaps +- Electronic Engineering +- Project Planning + +Languages +- Português (Native or Bilingual) +- Inglês (Professional Working) +- Espanhol (Elementary) diff --git a/tests/fixtures/test_resume.with-contact.txt b/tests/fixtures/test_resume.with-contact.txt new file mode 100644 index 0000000..fde5b51 --- /dev/null +++ b/tests/fixtures/test_resume.with-contact.txt @@ -0,0 +1,99 @@ +Arkady Zalkowitsch +Senior Engineering Manager @ Commure | ex-Carta | MBA in Business Management +Sunnyvale, California, United States + +Contact +LinkedIn: https://linkedin.com/in/arkadyzalko + +Summary +Engineering Manager with ~20 years in software and 10+ in leadership. I lead teams that sit at the intersection of product, operations and integrations, recently helping to shape an ERP- style operating model for PE firms and their portfolios at Carta, connecting onboarding, offboarding, document workflows and financial integrations to firm-level outcomes with unified experience. + +Experience +Senior Engineering Manager at Commure +February 2026 - Present +Mountain View, California, United States + +Investor & Advisor at Boba Joy +November 2024 - Present +Brazil +As a co-founder and strategic partner at Boba Joy, I focus on turning a great product into a scalable brand and operation. I lead brand positioning, store expansion strategy, and the overall vision of Boba Joy as a next-gen bubble tea micro-chain in Brazil. I defined the brand vision, mission, and “second-wave” positioning, with a clear focus on real fruit, quality, and a family-friendly experience. On the digital side, I led initiatives to improve customer experience through our website and our rewards/loyalty app, connecting the physical stores with an ongoing digital relationship with our customers. I also built and supported the team responsible for operational standards (SOPs/POPs), recipes, and processes to ensure consistency and scalability across locations. From a growth perspective, I co-led the expansion from 1 to 3 stores in just over a year, serving more than 12k customers and validating the model for future franchising. I worked closely with the on-the-ground operating partner to improve store performance, cost control, and the end-to-end customer experience. In parallel, I developed the early franchise playbook including personas, positioning, and scalable processes, to prepare Boba Joy for broader roll-out and structured growth. + +Engineering Manager at Carta +October 2021 - January 2026 +Santa Clara, CA +I lead the Corporation Integrations engineering team at Carta, owning strategy and execution for HRIS and financial integrations, onboarding/offboarding workflows and internal tools that power the support experience. I also previously managed the Customer Success Engineering team during a period of rapid growth. • Increased team delivery velocity by nearly 3× in 3 months by bringing AI assistants into the development process (scaffolding code/tests, streamlining reviews and incident response). • Designed and implemented a unified business-identity workflow that reduced tool fragmentation for internal teams and simplified how customers and support resolve account and access issues. • Partnered with Product, Customer Success, Delivery Ops and Finance to prioritize integrations and internal tooling as a portfolio of bets tied to outcomes such as TTV, ticket deflection and operational efficiency. • Provided coaching and structure for EMs/tech leads around prioritization, stakeholder communication and decision-making under ambiguity, so more decisions could be made effectively without escalation. + +Tech Lead Manager at Carta +July 2019 - October 2021 +Palo Alto, CA +• Acted as a lead engineer for new business lines, establishing technical foundations for Public Markets, and LLC. • Collaborated with cross-functional teams to translate complex business requirements into scalable systems. • Provided technical leadership, mentoring engineers and unblocking projects as the company expanded. + +Senior Software Engineer at Carta +October 2017 - June 2019 +Rio de Janeiro +• Developed core equity features in Carta (e.g. regular/custom vesting schedule, and option exercises). • Implemented natural language search capabilities, streamlining user navigation for entities and documents. • Worked on the first initiative to domain decomposition in Carta to define the foundation (standards and services) for microservices. • Contributed to doubling development velocity by improving team standards and architecture. • Served as a technical reference, guiding code reviews and design clarifications for scalable solutions. + +Engineering Director at Zestt +January 2018 - October 2022 +Rio de Janeiro, Brazil +I led the development of an ERP platform for SMBs in Brazil, helping the company reach key growth milestones while scaling the engineering organization from 3 engineers to ~15 people. • Managed 3 leads (2 engineering, 1 product) across multiple teams. • Built a collaborative engineering culture across three cross-functional teams (warehouse, financials and integrations), with clear ownership, shared standards and predictable delivery. • Defined and implemented a metrics framework to measure product outcomes and engineering performance. • Led talent acquisition, tightening the interview loop (rubrics, case exercises, structured panel debriefs) to reduce noise in evaluations and improve the quality and fit of new hires over time. + +Head of Engineering at Partiu Vantagens! +October 2015 - October 2017 +Rio de Janeiro, Brasil +I led the Engineering Org at Partiu, partnering directly with the CEO to build and scale a rewards platform connecting residents, stores and property managers, while ensuring the technology roadmap matched the company’s strategy and growth plans. • Managed 3 teams (~12 engineers and 2 designers), balancing short-term delivery with the longer-term evolution of the platform and its integrations. • Led the development of the main consumer rewards mobile app, the in- store POS for real-time reward validation, and the merchant admin portal for configuring discounts, campaigns and performance tracking. • Delivered a staff-facing view and a deep integration with a condominium management system, enabling rewards charges and billing to flow directly onto rent/HOA invoices and unlocking a new distribution and revenue channel. • Translated company goals into clear technical priorities and sequencing, aligning product, engineering and business stakeholders and making build-vs- buy and vendor decisions with cost and complexity in mind. • Mentored other leads and engineers on architecture, delivery practices and people leadership, introducing more structured feedback and coaching to improve ownership, collaboration and reliability of delivery. + +Engineering Manager at AevoTech +August 2015 - March 2016 +Greater Rio de Janeiro +I led two major initiatives at AevoTech: building robotics solutions for Oil & Gas clients and supporting new startups inside a tech venture builder, connecting engineering execution with portfolio strategy. • Led a team of engineers developing robotics solutions for Oil & Gas companies, overseeing design, implementation, deployment and on-site testing with clients. • Coordinated field operations and technical decisions to ensure the systems met safety, reliability and operational constraints in real production environments. • In the venture builder, partnered with engineering leads and a Product Manager to evaluate potential startups for the portfolio, assessing fit with strategy and technical feasibility. • Guided early product discovery and concept validation, helping founders turn ideas into first versions with clear problem statements, scope and delivery plans. • Helped new teams establish basic operating processes (backlog, releases, communication) and supported recruitment of their initial engineering hires. + +Senior Lead Software Engineer at Inovare +April 2015 - August 2015 +Greater Rio de Janeiro +I served as a hands-on tech lead on payment and checkout systems, splitting my time between shipping code and putting structure around how work got done. • Built and maintained core payment and checkout flows end to end (Java and C#), focusing on correctness, reliability and a smooth experience for merchants and end users. • Reduced production firefighting by improving logging, automated tests and error handling, making issues easier to detect, debug and fix. • Brought more structure to delivery by breaking large projects into smaller milestones, clarifying priorities and ownership, and creating simple plans the team could execute against. • Turned client and stakeholder requests into clear written engineering requirements and lightweight documentation, which reduced churn and rework for the team. + +Lead Project Engineer at CEPEL +August 2014 - April 2015 +Greater Rio de Janeiro +I worked on CEPEL’s SOMA asset-monitoring platform, which provides real- time condition monitoring and predictive maintenance for power generation units used by utilities such as FURNAS. • Built data analysis and visualization components in Polymer, JavaScript, TypeScript and Java to improve how operators explored and interpreted asset data. • Improved robustness and performance of SOMA, including a ~60% improvement in query performance for configuration data mapping. • Implemented a tool to analyze the lifespan of thermoelectric turbines in Tubarão (southern Brazil), enabling vibration data acquisition for advanced diagnostics. • Contributed to real-time monitoring and predictive maintenance for plants such as Simplício and Furnas, helping reduce downtime and optimize maintenance planning. • Applied TDD and agile practices to increase test coverage and make deliveries more predictable and easier to evolve safely. + +Robotics Researcher at CPTI / PUC-Rio +May 2010 - July 2014 +Rua Marquês de São Vicente, 255 - Gávea, Rio de Janeiro - RJ, 22453-900 +Focusing on advanced inspection technologies, quality assurance, and critical system recovery in the oil and gas sector. Key responsibilities included: • Developing, testing, and operating underwater inspection equipment for high- reliability applications. • Working on field operations logistics on platforms, ships, and testing sites, including embarks on P-52, P-25, and RSV Joe Griffin, where I conducted tests and homologated inspection tools. • Analyzing riser and pipeline data and producing technical reports for clients like Petrobras and Pipeway. • Leading the design and homologation of hardware and software projects, including the AURI (Autonomous Underwater Riser Inspector), which won Petrobras' Innovation Award. • Ensuring quality control and resolving issues in critical systems to maintain operational integrity. This role had a strong focus on quality assurance for systems and processes, particularly for embedded systems used in mission-critical applications. My work involved ensuring reliability and compliance in challenging environments where precision and robustness were essential. + +Technical Researcher – Automation and Robotics (Contractor) at CEPEL +December 2006 - April 2010 +Av. Horácio Macedo, 354 - Cidade Universitária - Rio de Janeiro - RJ, 21941-911 +Worked as a Researcher in renewable energy projects for the Department of Specialized Technologies, contributing to key initiatives that advanced the company’s capabilities in the sector. My responsibilities included: • Developing and implementing measurement platforms for solar and wind energy in remote areas, resulting in systems that operated uninterruptedly for over 5 years in challenging environments. • Creating analytical tools to evaluate energy performance and identify optimization opportunities, including one that reduced a 2-month process to just 1 week. • Coordinating engineers and technicians in the development of electronic systems, leading the testing and deployment of cutting-edge tools for solar and wind systems. • Establishing homologation processes for critical systems to ensure compliance, operational reliability, and long-term sustainability. I also led smaller projects that delivered innovative electronic solutions, driving progress in renewable energy technologies. Additionally, I supported an initiative by Brazil’s Ministry of Mines and Energy, evaluating companies, sites, and technologies to enable strategic entry into the wind energy market. + +Technical Support Analyst at Arena Games +August 2005 - May 2006 +Rio de Janeiro, Brasil + +Education +Master of Business Administration - MBA, Business Management, Universidade Veiga de Almeida +2017 - 2018 + +Bachelor's degree, Control and Automation Engineering, Pontifícia Universidade Católica do Rio de Janeiro / PUC-Rio +2010 - 2013 + +Bachelor's degree, Electrical and Electronics Engineering, Universidade do Estado do Rio de Janeiro +2006 - 2009 + +Telecommunications Technician, Telecommunications Technology/Technician, ETE Ferreira Viana (FAETEC) +2002 - 2005 + +Full Stack Web Development Certification, Computer Software Engineering, Free Code Camp +2016 - 2016 + +Top Skills +- Strategic Roadmaps +- Electronic Engineering +- Project Planning + +Languages +- Português (Native or Bilingual) +- Inglês (Professional Working) +- Espanhol (Elementary) diff --git a/tests/unit/basic-info.test.ts b/tests/unit/basic-info.test.ts new file mode 100644 index 0000000..e77a5b8 --- /dev/null +++ b/tests/unit/basic-info.test.ts @@ -0,0 +1,730 @@ +import { BasicInfoParser } from '../../src/parsers/basic-info.js'; +import type { StructuralLine } from '../../src/utils/structural-lines.js'; + +describe('BasicInfoParser', () => { + test('does not classify spaced email addresses as short company headlines', () => { + const profile = BasicInfoParser.parse(` + Apollo Helios + name @ domain.com + Senior Engineer @ ExampleCo + Los Angeles, California, United States + `); + + expect(profile.headline).toBe('Senior Engineer @ ExampleCo'); + }); + + test('extracts generic names without exclusion words or ASCII-only assumptions', () => { + const strategicProfile = BasicInfoParser.parse(` + Strategic Planning + strategic.planning@custom.dev + Principal Advisor + München, Bayern, Deutschland + `); + const portugueseProfile = BasicInfoParser.parse(` + ARIADNE MINOS + ariadne.minos@example.com.br + São Paulo, São Paulo, Brasil + `); + const apostropheProfile = BasicInfoParser.parse(` + Lugh O'Nuada + lugh.onuada@example.consulting + Dublin, Leinster, Ireland + `); + + expect(strategicProfile.name).toBe('Strategic Planning'); + expect(strategicProfile.location).toBe('München, Bayern, Deutschland'); + expect(portugueseProfile.name).toBe('ARIADNE MINOS'); + expect(portugueseProfile.location).toBe('São Paulo, São Paulo, Brasil'); + expect(apostropheProfile.name).toBe("Lugh O'Nuada"); + }); + + test('omits email instead of returning an empty string', () => { + const profile = BasicInfoParser.parse(` + Persephone Kore + Product Advisor + Toronto, Ontario, Canada + `); + + expect(profile.contact.email).toBeUndefined(); + }); + + test('does not emit basic-info warnings for later empty sections', () => { + const result = BasicInfoParser.parseWithWarnings(` + Apollo Helios + Principal Advisor + Toronto, Ontario, Canada + + Experience + Example Labs + Summary + Contact + `); + + expect(result.warnings).toEqual([]); + }); + + test('reports adjacent empty contact and summary sections in the header', () => { + const result = BasicInfoParser.parseWithWarnings(` + Apollo Helios + Principal Advisor + Contact + Available on request + Summary + `); + + expect(result.warnings).toEqual([ + expect.objectContaining({ + field: 'contact', + section: 'contact', + }), + expect.objectContaining({ + field: 'summary', + section: 'summary', + }), + ]); + }); + + test('stops header warnings at later target sections', () => { + const result = BasicInfoParser.parseWithWarnings(` + Apollo Helios + Principal Advisor + Contact + + Experience + Example Labs + Summary + `); + + expect(result.warnings).toEqual([ + expect.objectContaining({ + field: 'contact', + section: 'contact', + }), + ]); + }); + + test('stops header warnings at boundary sections', () => { + const result = BasicInfoParser.parseWithWarnings(` + Apollo Helios + Principal Advisor + Contact + + Courses + Summary + `); + + expect(result.warnings).toEqual([ + expect.objectContaining({ + field: 'contact', + section: 'contact', + }), + ]); + }); + + test('extracts structural summary from its visual column', () => { + const result = BasicInfoParser.parseStructuralWithWarnings( + [ + 'Apollo Helios', + 'Principal Advisor', + 'Toronto, Ontario, Canada', + 'Summary', + 'TypeScript', + 'Builds products across engineering and operations.', + 'Languages', + 'English (Native or Bilingual)', + 'with focus on reliable delivery and maintainable systems.', + 'Experience', + ].join('\n'), + [ + structuralLine({ column: 'right', text: 'Summary', y: 700 }), + structuralLine({ column: 'left', text: 'TypeScript', y: 690 }), + structuralLine({ + column: 'right', + text: 'Builds products across engineering and operations.', + y: 690, + }), + structuralLine({ column: 'left', text: 'Languages', y: 680 }), + structuralLine({ + column: 'left', + text: 'English (Native or Bilingual)', + y: 670, + }), + structuralLine({ + column: 'right', + text: 'with focus on reliable delivery and maintainable systems.', + y: 670, + }), + structuralLine({ column: 'right', text: 'Experience', y: 660 }), + ] + ); + + expect(result.value.summary).toBe( + 'Builds products across engineering and operations. with focus on reliable delivery and maintainable systems.' + ); + }); + + test('keeps short structural summary continuation lines', () => { + const result = BasicInfoParser.parseStructuralWithWarnings( + ['Summary', 'Long enough summary line', 'my life.', 'Experience'].join( + '\n' + ), + [ + structuralLine({ column: 'right', text: 'Summary', y: 700 }), + structuralLine({ + column: 'right', + text: 'Long enough summary line', + y: 680, + }), + structuralLine({ column: 'right', text: 'my life.', y: 660 }), + structuralLine({ column: 'right', text: 'Experience', y: 640 }), + ] + ); + + expect(result.value.summary).toBe('Long enough summary line my life.'); + }); + + test('covers fallback headline and summary branch outcomes directly', () => { + expect( + BasicInfoParser['extractHeadline']( + ['Apollo Helios', 'Product | Engineering'].join('\n') + ) + ).toBeUndefined(); + + const longSummaryLine = + 'Builds durable platform systems for operating teams with enough detail to exceed the fallback parser stop threshold.'; + + expect( + BasicInfoParser['extractSummary']( + [ + 'Alpha', + 'Beta', + 'Gamma', + 'Delta', + 'Epsilon', + longSummaryLine, + 'This later line should not be reached by fallback parsing.', + ].join('\n') + ) + ).toBe(longSummaryLine); + + expect( + BasicInfoParser['extractStructuralSummary']([ + structuralLine({ column: 'right', text: 'Summary', y: 700 }), + structuralLine({ column: 'right', text: 'short', y: 690 }), + ]) + ).toBeUndefined(); + }); + + test('skips blank identity lines while finding header warning boundaries', () => { + const result = BasicInfoParser.parseWithWarnings( + ['Apollo Helios', '', 'Contact'].join('\n') + ); + + expect(result.warnings).toEqual([ + expect.objectContaining({ + field: 'contact', + section: 'contact', + }), + ]); + }); + + test('extracts pipe-delimited headlines and phone contact fields', () => { + const profile = BasicInfoParser.parse(` + Apollo Helios + Product | Engineering | Operations + Los Angeles, California, United States + test.user@example.com + (415) 5555-0101 + `); + + expect(profile.headline).toBe('Product | Engineering | Operations'); + expect(profile.contact).toEqual( + expect.objectContaining({ + email: 'test.user@example.com', + phone: '(415) 5555-0101', + }) + ); + }); + + test('extracts whitespace-labeled email lines from the header', () => { + const profile = BasicInfoParser.parse(` + Apollo Helios + Principal Advisor + Email apollo@example.com + `); + + expect(profile.contact.email).toBe('apollo@example.com'); + }); + + test('extracts wrapped email continuations from header contact lines', () => { + const profile = BasicInfoParser.parse(` + Apollo Helios + Principal Advisor + apollo@example. + com + `); + + expect(profile.contact.email).toBe('apollo@example.com'); + }); + + test('extracts wrapped email continuations up to the DNS TLD limit', () => { + const longTld = 'abcdefghijklmnopqrstuvwxy'; + const profile = BasicInfoParser.parse(` + Apollo Helios + Principal Advisor + apollo@example. + ${longTld} + `); + + expect(profile.contact.email).toBe(`apollo@example.${longTld}`); + }); + + test('extracts structural contact links while ignoring URL path digits as phones', () => { + const result = BasicInfoParser.parseStructuralWithWarnings( + [ + 'Contact', + 'www.linkedin.com/in/example', + '(LinkedIn)', + 'siteresources.worldbank.org/', + 'INTPSD/', + 'Resources/336195-1092412588749/', + 'Algeria--ICA~3.pdf (Other)', + ].join('\n'), + [ + structuralLine({ column: 'left', text: 'Contact', y: 760 }), + structuralLine({ + column: 'left', + text: 'www.linkedin.com/in/example', + y: 740, + }), + structuralLine({ column: 'left', text: '(LinkedIn)', y: 728 }), + structuralLine({ + column: 'left', + text: 'siteresources.worldbank.org/', + y: 708, + }), + structuralLine({ column: 'left', text: 'INTPSD/', y: 696 }), + structuralLine({ + column: 'left', + text: 'Resources/336195-1092412588749/', + y: 684, + }), + structuralLine({ + column: 'left', + text: 'Algeria--ICA~3.pdf (Other)', + y: 672, + }), + structuralLine({ column: 'left', text: 'Top Skills', y: 640 }), + ] + ); + + expect(result.value.contact.phone).toBeUndefined(); + expect(result.value.contact.links).toEqual([ + expect.objectContaining({ + label: 'LinkedIn', + url: 'https://linkedin.com/in/example', + }), + expect.objectContaining({ + label: 'Other', + url: 'https://siteresources.worldbank.org/INTPSD/Resources/336195-1092412588749/Algeria--ICA~3.pdf', + }), + ]); + }); + + test('does not extract structural contact email from summary text', () => { + const result = BasicInfoParser.parseStructuralWithWarnings( + [ + 'Contact', + 'www.linkedin.com/in/jd-example', + 'Summary', + 'JD can be reached at jd@example.com.', + 'Experience', + ].join('\n'), + [ + structuralLine({ column: 'left', text: 'Contact', y: 760 }), + structuralLine({ + column: 'left', + text: 'www.linkedin.com/in/jd-example', + y: 740, + }), + structuralLine({ column: 'right', text: 'Summary', y: 720 }), + structuralLine({ + column: 'right', + text: 'JD can be reached at jd@example.com.', + y: 700, + }), + structuralLine({ column: 'right', text: 'Experience', y: 680 }), + ] + ); + + expect(result.value.contact.email).toBeUndefined(); + expect(result.value.contact.linkedin_url).toBe( + 'https://linkedin.com/in/jd-example' + ); + expect(result.value.summary).toBe('JD can be reached at jd@example.com.'); + }); + + test('extracts wrapped structural contact email continuation lines', () => { + const result = BasicInfoParser.parseStructuralWithWarnings( + [ + 'Contact', + '+1 310 498 3047 (Mobile)', + 'stephan.agerman@slvventure.', + 'com', + 'www.linkedin.com/in/stephan-', + 'agerman (LinkedIn)', + 'Summary', + ].join('\n'), + [ + structuralLine({ column: 'left', text: 'Contact', y: 760 }), + structuralLine({ + column: 'left', + text: '+1 310 498 3047 (Mobile)', + y: 740, + }), + structuralLine({ + column: 'left', + text: 'stephan.agerman@slvventure.', + y: 720, + }), + structuralLine({ column: 'left', text: 'com', y: 700 }), + structuralLine({ + column: 'left', + text: 'www.linkedin.com/in/stephan-', + y: 680, + }), + structuralLine({ + column: 'left', + text: 'agerman (LinkedIn)', + y: 660, + }), + structuralLine({ column: 'right', text: 'Summary', y: 640 }), + ] + ); + + expect(result.value.contact.email).toBe('stephan.agerman@slvventure.com'); + expect(result.value.contact.phone).toBe('+1 310 498 3047'); + expect(result.value.contact.linkedin_url).toBe( + 'https://linkedin.com/in/stephan-agerman' + ); + }); + + test('keeps wrapped email fragments out of split contact link drafts', () => { + const result = BasicInfoParser.parseWithWarnings(` + Apollo Helios + Principal Advisor + + Contact + www.linkedin.com/in/stephan- + stephan.agerman@slvventure. + com + agerman (LinkedIn) + `); + + expect(result.value.contact.email).toBe('stephan.agerman@slvventure.com'); + expect(result.value.contact.linkedin_url).toBe( + 'https://linkedin.com/in/stephan-agerman' + ); + expect(result.value.contact.links).toEqual([ + expect.objectContaining({ + label: 'LinkedIn', + url: 'https://linkedin.com/in/stephan-agerman', + }), + ]); + }); + + test('falls back to header contact lines for empty structural contact sections', () => { + const result = BasicInfoParser.parseStructuralWithWarnings( + [ + 'Apollo Helios', + 'Principal Advisor', + 'apollo@example.com', + 'Contact', + 'Experience', + ].join('\n'), + [ + structuralLine({ column: 'right', text: 'Apollo Helios', y: 800 }), + structuralLine({ + column: 'right', + text: 'Principal Advisor', + y: 780, + }), + structuralLine({ + column: 'right', + text: 'apollo@example.com', + y: 760, + }), + structuralLine({ column: 'left', text: 'Contact', y: 740 }), + structuralLine({ column: 'right', text: 'Experience', y: 720 }), + ] + ); + + expect(result.value.contact.email).toBe('apollo@example.com'); + }); + + test('does not extract text contact email from summary sections', () => { + const result = BasicInfoParser.parseWithWarnings(` + Cassandra Troy + Principal Advisor + Los Angeles, California, United States + + Summary + Cassandra can be reached at cassandra@example.com. + + Experience + Example Labs + `); + + expect(result.value.contact.email).toBeUndefined(); + expect(result.value.summary).toBe( + 'Cassandra can be reached at cassandra@example.com.' + ); + }); + + test('keeps adjacent contact links separate and allows colon continuations', () => { + const result = BasicInfoParser.parseWithWarnings(` + Apollo Helios + apollo@example.com + + Contact + www.linkedin.com/in/example + portfolio.example.com + docs.example.com/api/ + v1:alpha (Other) + `); + + expect(result.value.contact.links).toEqual([ + expect.objectContaining({ + url: 'https://linkedin.com/in/example', + }), + expect.objectContaining({ + url: 'https://portfolio.example.com', + }), + expect.objectContaining({ + label: 'Other', + url: 'https://docs.example.com/api/v1:alpha', + }), + ]); + }); + + test('extracts mobile phone contact lines with country code labels', () => { + const result = BasicInfoParser.parseStructuralWithWarnings( + ['Contact', '+1 720-520-5329 (Mobile)'].join('\n'), + [ + structuralLine({ column: 'left', text: 'Contact', y: 760 }), + structuralLine({ + column: 'left', + text: '+1 720-520-5329 (Mobile)', + y: 740, + }), + ] + ); + + expect(result.value.contact.phone).toBe('+1 720-520-5329'); + }); + + test('extracts eight digit local phone numbers', () => { + const profile = BasicInfoParser.parse(` + Apollo Helios + Product Advisor + + Contact + 8765 4321 + `); + + expect(profile.contact.phone).toBe('8765 4321'); + }); + + test('rejects whitespace-padded year ranges as phone search lines', () => { + expect(BasicInfoParser['isPhoneSearchLine'](' 2017 - 2018 ')).toBe(false); + expect(BasicInfoParser['isPhoneSearchLine'](' (2017 - present) ')).toBe( + false + ); + expect(BasicInfoParser['isPhoneSearchLine'](' 8765 4321 ')).toBe(true); + expect( + BasicInfoParser['isPhoneSearchLine']( + ' 8765 4321 ' + ) + ).toBe(true); + }); + + test('uses the multiline engineering manager headline fallback', () => { + const profile = BasicInfoParser.parse(` + Apollo Helios + Engineering Manager @ Acme | + Platform Reliability + `); + + expect(profile.headline).toBe( + 'Engineering Manager @ Acme | Platform Reliability' + ); + }); + + test('builds a fallback summary from long identity lines', () => { + const profile = BasicInfoParser.parse(` + Apollo Helios + Principal Advisor + Toronto, Ontario, Canada + Portfolio Focus + Advisory Practice + Builds reliable product and engineering systems for teams that need repeatable delivery across multiple business units. + Partners with operations leaders to remove delivery risk and improve maintainability across the platform. + `); + + expect(profile.summary).toBe( + 'Builds reliable product and engineering systems for teams that need repeatable delivery across multiple business units.' + ); + }); + + test('does not use text fallback summary parsing for structural PDFs without a summary section', () => { + const result = BasicInfoParser.parseStructuralWithWarnings( + [ + 'Apollo Helios', + 'Principal Advisor', + 'Toronto, Ontario, Canada', + 'Experience', + 'Example Labs', + 'Strategic Advisor', + 'March 2026 - Present (3 months)', + 'Example Labs builds reliable product and engineering systems for teams that need repeatable delivery across multiple business units.', + ].join('\n'), + [ + structuralLine({ column: 'right', text: 'Apollo Helios', y: 760 }), + structuralLine({ column: 'right', text: 'Principal Advisor', y: 740 }), + structuralLine({ + column: 'right', + text: 'Toronto, Ontario, Canada', + y: 720, + }), + structuralLine({ column: 'right', text: 'Experience', y: 690 }), + structuralLine({ column: 'right', text: 'Example Labs', y: 670 }), + structuralLine({ + column: 'right', + text: 'Strategic Advisor', + y: 650, + }), + structuralLine({ + column: 'right', + text: 'March 2026 - Present (3 months)', + y: 630, + }), + structuralLine({ + column: 'right', + text: 'Example Labs builds reliable product and engineering systems for teams that need repeatable delivery across multiple business units.', + y: 610, + }), + ] + ); + + expect(result.value.summary).toBeUndefined(); + }); + + test('preserves structural summary length consistently with fallback summary parsing', () => { + const longSummaryLine = `Builds ${'reliable systems '.repeat(40)}`.trim(); + const result = BasicInfoParser.parseStructuralWithWarnings( + ['Apollo Helios', 'Principal Advisor', 'Summary', longSummaryLine].join( + '\n' + ), + [ + structuralLine({ column: 'right', text: 'Summary', y: 700 }), + structuralLine({ column: 'right', text: longSummaryLine, y: 690 }), + ] + ); + + expect(result.value.summary).toBe(longSummaryLine); + }); + + test('covers contact link finalization, normalization, joining, and dedupe branches', () => { + const result = BasicInfoParser.parseWithWarnings(` + Apollo Helios + Principal Advisor + + Contact + docs.example.com + api + https://portfolio.example.com + docs.example.com + /api + ?view=full + #section + docs.example.com/path- + continued (Other) + docs.example.com + `); + + expect(result.value.contact.links).toEqual([ + expect.objectContaining({ + url: 'https://docs.example.com/api', + }), + expect.objectContaining({ + url: 'https://portfolio.example.com', + }), + expect.objectContaining({ + url: 'https://docs.example.com/api?view=full#section', + }), + expect.objectContaining({ + label: 'Other', + url: 'https://docs.example.com/path-continued', + }), + expect.objectContaining({ + url: 'https://docs.example.com', + }), + ]); + }); + + test('ignores invalid contact link drafts and empty structural summary sections', () => { + const links: NonNullable< + ReturnType['contact']['links'] + > = []; + + BasicInfoParser['pushContactLink'](links, { + parts: ['not-a-link'], + rawLines: ['not-a-link'], + }); + + expect(links).toEqual([]); + expect( + BasicInfoParser['extractStructuralSummary']([ + structuralLine({ column: 'right', text: 'Summary', y: 700 }), + structuralLine({ column: 'right', text: 'Experience', y: 690 }), + ]) + ).toBeUndefined(); + }); + + test('deduplicates repeated contact links', () => { + const result = BasicInfoParser.parseWithWarnings(` + Apollo Helios + Principal Advisor + + Contact + docs.example.com + docs.example.com + `); + + expect(result.value.contact.links).toEqual([ + expect.objectContaining({ + url: 'https://docs.example.com', + }), + ]); + }); +}); + +function structuralLine({ + column, + text, + y, +}: { + column: StructuralLine['column']; + text: string; + y: number; +}): StructuralLine { + return { + column, + fontSize: 10, + height: 10, + text, + width: text.length * 5, + x: column === 'left' ? 20 : 220, + y, + }; +} diff --git a/tests/unit/build-config.test.ts b/tests/unit/build-config.test.ts new file mode 100644 index 0000000..4e19b6d --- /dev/null +++ b/tests/unit/build-config.test.ts @@ -0,0 +1,219 @@ +import fs from 'node:fs'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import type { OutputOptions, RollupOptions } from 'rollup'; +import { z } from 'zod'; +import rollupConfig from '../../rollup.config.js'; + +const REQUIRED_EXTERNALS = ['chrono-node', 'unpdf', 'zod'] as const; +const REQUIRED_PACKAGE_SCRIPT_FILES: readonly string[] = [ + 'scripts/verify-artifacts.mjs', + 'scripts/verify-packed-package.mjs', + 'scripts/check-size-budget.mjs', + 'scripts/inspect-pdf-source.mjs', + 'scripts/sample-completeness-audit.mjs', + 'scripts/verify-samples.mjs', + 'scripts/lib/verification-helpers.mjs', + 'scripts/lib/source-coverage-helpers.mjs', +]; +const PACKAGE_JSON_PATH = fileURLToPath( + new URL('../../package.json', import.meta.url) +); +const ESBUILD_CONFIG_PATH = fileURLToPath( + new URL('../../esbuild.config.js', import.meta.url) +); +const PackageJsonSchema = z.object({ + devDependencies: z.record(z.string(), z.string()), + scripts: z.record(z.string(), z.string()), +}); +const SizeBudgetScriptSchema = z.object({ + fileBudgets: z.array( + z.object({ + file: z.string(), + gzipBytes: z.number().int().positive(), + rawBytes: z.number().int().positive(), + }) + ), + totalTopLevelJavaScriptBudget: z.number().int().positive(), +}); + +function packageJson(): z.infer { + return PackageJsonSchema.parse( + JSON.parse(fs.readFileSync(PACKAGE_JSON_PATH, 'utf8')) + ); +} + +function repoFilePath(relativePath: string): string { + return fileURLToPath(new URL(`../../${relativePath}`, import.meta.url)); +} + +async function sizeBudgetConfig(): Promise< + z.infer +> { + const scriptExports: unknown = await import( + pathToFileURL(repoFilePath('scripts/check-size-budget.mjs')).href + ); + + return SizeBudgetScriptSchema.parse(scriptExports); +} + +function rollupOptions(): RollupOptions[] { + if (Array.isArray(rollupConfig)) { + return rollupConfig; + } + + return [rollupConfig]; +} + +function rollupOptionForInput(input: string): RollupOptions { + const option = rollupOptions().find(candidate => candidate.input === input); + + if (option === undefined) { + throw new Error(`No rollup config found for ${input}`); + } + + return option; +} + +function rollupOutputOptions(option: RollupOptions): OutputOptions[] { + const { output } = option; + + if (Array.isArray(output)) { + return output; + } + + return output === undefined ? [] : [output]; +} + +describe('build config contract', () => { + test('keeps runtime parser dependencies external in rollup', () => { + for (const option of rollupOptions()) { + expect(option.external).toEqual( + expect.arrayContaining([...REQUIRED_EXTERNALS]) + ); + } + }); + + test('builds every production bundle with rollup', () => { + const outputOptions = rollupOutputOptions( + rollupOptionForInput('src/index.ts') + ); + + expect(outputOptions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ file: 'dist/index.js', format: 'es' }), + expect.objectContaining({ file: 'dist/index.cjs', format: 'cjs' }), + expect.objectContaining({ file: 'dist/index.min.js', format: 'es' }), + ]) + ); + expect( + outputOptions.find(output => output.file === 'dist/index.min.js') + ).toEqual( + expect.objectContaining({ + plugins: expect.arrayContaining([ + expect.objectContaining({ name: 'terser' }), + ]), + }) + ); + }); + + test('builds the CLI artifact consumed by the bin wrapper', () => { + expect(rollupOutputOptions(rollupOptionForInput('src/cli.ts'))).toEqual([ + expect.objectContaining({ + file: 'dist/cli.js', + format: 'es', + }), + ]); + }); + + test('exposes package scripts for local CLI runs', () => { + const manifest = packageJson(); + + expect(manifest.scripts.cli).toBe('pnpm run build && node bin/cli.js'); + expect(manifest.scripts['cli:built']).toBe('node bin/cli.js'); + }); + + test('does not keep esbuild in the production build path', () => { + const manifest = packageJson(); + + expect(manifest.scripts.build).toMatch(/\bpnpm run clean\b/); + expect(manifest.scripts.build).toMatch(/\btsc --noEmit\b/); + expect(manifest.scripts.build).toMatch(/\bpnpm run build:bundle\b/); + expect(manifest.scripts.build).toMatch(/\bpnpm run build:types:cjs\b/); + expect(manifest.scripts['build:bundle']).toBe('rollup -c'); + expect(manifest.scripts).not.toHaveProperty('build:minify'); + expect(manifest.devDependencies).toHaveProperty('@rollup/plugin-terser'); + expect(manifest.devDependencies).not.toHaveProperty('esbuild'); + expect(fs.existsSync(ESBUILD_CONFIG_PATH)).toBe(false); + }); + + test('runs artifact verification for package quality gates', () => { + const manifest = packageJson(); + + expect(manifest.scripts['verify:artifacts']).toBe( + 'node scripts/verify-artifacts.mjs' + ); + expect(manifest.scripts['verify:package']).toBe( + 'node scripts/verify-packed-package.mjs' + ); + expect(manifest.scripts['size:check']).toBe( + 'node scripts/check-size-budget.mjs' + ); + expect(manifest.scripts['source:inspect']).toBe( + 'pnpm run build && node scripts/inspect-pdf-source.mjs' + ); + expect(manifest.scripts['samples:audit-coverage']).toBe( + 'node scripts/sample-completeness-audit.mjs' + ); + expect(manifest.scripts['samples:verify']).toBe( + 'node scripts/verify-samples.mjs' + ); + expect(manifest.scripts.check).not.toContain('samples:verify'); + expect(manifest.scripts['quality:check']).toEqual( + expect.stringContaining('pnpm run verify:artifacts') + ); + expect(manifest.scripts['quality:check']).toEqual( + expect.stringContaining('pnpm run verify:package') + ); + }); + + test('reuses source-debug structural lines in the binary parse path', () => { + const indexSource = fs.readFileSync(repoFilePath('src/index.ts'), 'utf8'); + + expect(indexSource).toContain( + 'structuralLines: debugArtifacts.structuralLines' + ); + expect(indexSource).toContain( + 'const structuralLines = structuralData?.structuralLines' + ); + expect(indexSource).not.toMatch(/import\s+\{\s*createStructuralLines\s*\}/); + }); + + test('keeps package verification scripts present in the repo', () => { + for (const scriptPath of REQUIRED_PACKAGE_SCRIPT_FILES) { + expect(fs.existsSync(repoFilePath(scriptPath))).toBe(true); + } + }); + + test('keeps aggregate JavaScript size budget aligned with file budgets', async () => { + const { fileBudgets, totalTopLevelJavaScriptBudget } = + await sizeBudgetConfig(); + const individualRawBudgetBytes = fileBudgets.reduce( + (totalBytes, budget) => totalBytes + budget.rawBytes, + 0 + ); + + expect(totalTopLevelJavaScriptBudget).toBeGreaterThanOrEqual( + individualRawBudgetBytes + ); + }); + + test('keeps gzip budgets proportional to raw file budgets', async () => { + const { fileBudgets } = await sizeBudgetConfig(); + + for (const budget of fileBudgets) { + expect(budget.gzipBytes).toBeGreaterThanOrEqual( + Math.ceil(budget.rawBytes / 4) + ); + } + }); +}); diff --git a/tests/unit/cli.test.ts b/tests/unit/cli.test.ts new file mode 100644 index 0000000..9a2b66c --- /dev/null +++ b/tests/unit/cli.test.ts @@ -0,0 +1,736 @@ +import { jest } from '@jest/globals'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + main, + runCli, + type CliDependencies, + type CliDirectoryEntry, + type CliResult, +} from '../../src/cli.js'; +import type { ParseOptions, ParseResult } from '../../src/index.js'; + +describe('CLI runner', () => { + const profilePdfPath = fileURLToPath( + new URL('../fixtures/Profile.pdf', import.meta.url) + ); + const nonPdfPath = fileURLToPath( + new URL('../../package.json', import.meta.url) + ); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('prints usage for help', async () => { + const result = await runCli({ args: ['--help'] }); + + expect(result).toEqual({ + exitCode: 0, + stderr: '', + stdout: expect.stringContaining('--json-paths'), + }); + }); + + test('reports an invalid command when only non-help flags are provided', async () => { + const result = await runCli({ args: ['--compact'] }); + + expect(result).toEqual({ + exitCode: 1, + stderr: expect.stringContaining('Error: No PDF file path provided'), + stdout: '', + }); + }); + + test('reports invalid folder command arguments', async () => { + await expect(runCli({ args: ['write-json'] })).resolves.toEqual({ + exitCode: 1, + stderr: expect.stringContaining( + 'Error: No folder path provided for write-json' + ), + stdout: '', + }); + await expect( + runCli({ args: ['verify-json', '/one', '/two'] }) + ).resolves.toEqual({ + exitCode: 1, + stderr: expect.stringContaining( + 'Error: Only one folder path may be provided for verify-json' + ), + stdout: '', + }); + await expect( + runCli({ args: ['verify-json', '/baselines', '--json-path'] }) + ).resolves.toEqual({ + exitCode: 1, + stderr: expect.stringContaining('Error: Unknown option: --json-path'), + stdout: '', + }); + await expect( + runCli({ args: ['verify-json', '/baselines', '-x'] }) + ).resolves.toEqual({ + exitCode: 1, + stderr: expect.stringContaining('Error: Unknown option: -x'), + stdout: '', + }); + await expect( + runCli({ args: ['write-json', '/baselines', '--json-paths'] }) + ).resolves.toEqual({ + exitCode: 1, + stderr: expect.stringContaining('Error: Unknown option: --json-paths'), + stdout: '', + }); + await expect( + runCli({ args: ['write-json', '/baselines', '-x'] }) + ).resolves.toEqual({ + exitCode: 1, + stderr: expect.stringContaining('Error: Unknown option: -x'), + stdout: '', + }); + }); + + test('returns compact JSON for a valid PDF', async () => { + const result = await runCli({ + args: [profilePdfPath, '--compact'], + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout).toEqual(expect.stringMatching(/^\{"profile":/)); + expect(result.stdout).not.toContain('\n "profile"'); + expectJsonProfile(result, { + email: 'harold.martin@gmail.com', + name: 'Harold Martin', + }); + }); + + test('pretty-prints JSON and includes raw text when requested', async () => { + const result = await runCli({ + args: [profilePdfPath, '--raw-text'], + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout).toContain('\n "profile":'); + expect(JSON.parse(result.stdout)).toEqual( + expect.objectContaining({ + rawText: expect.stringContaining('Harold Martin'), + }) + ); + expectJsonProfile(result, { + email: 'harold.martin@gmail.com', + name: 'Harold Martin', + }); + }); + + test('rejects missing PDF paths', async () => { + const result = await runCli({ + args: ['missing.pdf'], + }); + + expect(result).toEqual({ + exitCode: 1, + stderr: expect.stringContaining('Error: File not found:'), + stdout: '', + }); + }); + + test('rejects non-PDF files before reading them', async () => { + const result = await runCli({ + args: [nonPdfPath], + }); + + expect(result).toEqual({ + exitCode: 1, + stderr: `Error: File must be a PDF: ${nonPdfPath}\n`, + stdout: '', + }); + }); + + test('writes help output through the executable main entry point', async () => { + const stderrSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const stdoutSpy = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + + const exitCode = await main(['--help']); + + expect(exitCode).toBe(0); + expect(stderrSpy).not.toHaveBeenCalled(); + expect(stdoutSpy).toHaveBeenCalledWith( + expect.stringContaining('linkedin-pdf-parser verify-json ') + ); + }); + + test('uses process argv when main is called without explicit args', async () => { + const originalArgv = process.argv; + const stderrSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const stdoutSpy = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + + process.argv = ['node', 'linkedin-pdf-parser', '--help']; + + try { + const exitCode = await main(); + + expect(exitCode).toBe(0); + expect(stderrSpy).not.toHaveBeenCalled(); + expect(stdoutSpy).toHaveBeenCalledWith( + expect.stringContaining('linkedin-pdf-parser ') + ); + } finally { + process.argv = originalArgv; + } + }); + + test('writes parse output through the executable main entry point', async () => { + const stderrSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const stdoutSpy = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + + const exitCode = await main([profilePdfPath, '--compact']); + + expect(exitCode).toBe(0); + expect(stderrSpy).not.toHaveBeenCalled(); + expect(stdoutSpy).toHaveBeenCalledWith( + expect.stringMatching(/^\{"profile":/) + ); + }); + + test('returns failure codes from the executable main entry point', async () => { + const stderrSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + const stdoutSpy = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + + const exitCode = await main(['--compact']); + + expect(exitCode).toBe(1); + expect(stdoutSpy).not.toHaveBeenCalled(); + expect(stderrSpy).toHaveBeenCalledWith( + expect.stringContaining('Error: No PDF file path provided') + ); + }); + + test('reports parser failures', async () => { + const result = await runCli({ + args: [profilePdfPath], + dependencies: createMemoryCliDependencies({ + binaryFiles: new Map([[profilePdfPath, new Uint8Array([1, 2, 3])]]), + parsePdf: async () => { + throw new Error('parse failed'); + }, + resolvePath: filePath => filePath, + }).dependencies, + }); + + expect(result).toEqual({ + exitCode: 1, + stderr: 'Error: parse failed\n', + stdout: '', + }); + }); + + test('accepts directories when file existence only checks regular files', async () => { + const memoryCli = createMemoryCliDependencies({ + directories: new Set(['/baselines']), + fileExists: () => false, + }); + + const result = await runCli({ + args: ['write-json', '/baselines'], + dependencies: memoryCli.dependencies, + }); + + expect(result).toEqual({ + exitCode: 0, + stderr: '', + stdout: expect.stringContaining('Wrote 0 JSON file(s)'), + }); + }); + + test('writes JSON files for top-level PDFs only', async () => { + const memoryCli = createMemoryCliDependencies({ + binaryFiles: new Map([['/baselines/Profile.pdf', new Uint8Array([1])]]), + directories: new Set(['/baselines']), + directoryEntries: new Map([ + [ + '/baselines', + [ + { kind: 'file', name: 'Profile.pdf' }, + { kind: 'file', name: 'notes.txt' }, + { kind: 'directory', name: 'Nested.pdf' }, + ], + ], + ]), + }); + + const result = await runCli({ + args: ['write-json', '/baselines'], + dependencies: memoryCli.dependencies, + }); + + expect(result).toEqual({ + exitCode: 0, + stderr: '', + stdout: expect.stringContaining('Wrote 1 JSON file(s)'), + }); + expect(memoryCli.readFilePaths).toEqual(['/baselines/Profile.pdf']); + expect(memoryCli.writtenTextFiles).toEqual([ + { + content: `${JSON.stringify(defaultParseResult, null, 2)}\n`, + filePath: '/baselines/Profile.json', + }, + ]); + }); + + test('writes JSON files for symlinked PDFs', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'linkedin-cli-')); + const nestedDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'linkedin-nested-') + ); + const nestedSymlinkPath = path.join(tempDir, 'Nested.pdf'); + const symlinkPath = path.join(tempDir, 'Profile-Link.pdf'); + + try { + fs.symlinkSync(nestedDir, nestedSymlinkPath, 'dir'); + fs.symlinkSync(profilePdfPath, symlinkPath, 'file'); + + const result = await runCli({ + args: ['write-json', tempDir, '--compact'], + }); + + expect(result).toEqual({ + exitCode: 0, + stderr: '', + stdout: expect.stringContaining('Wrote 1 JSON file(s)'), + }); + expect(fs.existsSync(path.join(tempDir, 'Profile-Link.json'))).toBe(true); + } finally { + fs.rmSync(tempDir, { force: true, recursive: true }); + fs.rmSync(nestedDir, { force: true, recursive: true }); + } + }); + + test('refuses to replace existing JSON files without force', async () => { + const memoryCli = createMemoryCliDependencies({ + binaryFiles: new Map([['/baselines/Profile.pdf', new Uint8Array([1])]]), + directories: new Set(['/baselines']), + directoryEntries: new Map([ + [ + '/baselines', + [ + { kind: 'file', name: 'Profile.pdf' }, + { kind: 'file', name: 'Profile.json' }, + ], + ], + ]), + textFiles: new Map([['/baselines/Profile.json', '{}']]), + }); + + const result = await runCli({ + args: ['write-json', '/baselines'], + dependencies: memoryCli.dependencies, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + 'JSON already exists: /baselines/Profile.json' + ); + expect(memoryCli.readFilePaths).toEqual([]); + expect(memoryCli.writtenTextFiles).toEqual([]); + }); + + test('overwrites existing JSON files with force', async () => { + const memoryCli = createMemoryCliDependencies({ + binaryFiles: new Map([['/baselines/Profile.PDF', new Uint8Array([1])]]), + directories: new Set(['/baselines']), + directoryEntries: new Map([ + [ + '/baselines', + [ + { kind: 'file', name: 'Profile.PDF' }, + { kind: 'file', name: 'Profile.JSON' }, + ], + ], + ]), + textFiles: new Map([['/baselines/Profile.JSON', '{}']]), + }); + + const result = await runCli({ + args: ['write-json', '/baselines', '--force', '--compact'], + dependencies: memoryCli.dependencies, + }); + + expect(result.exitCode).toBe(0); + expect(memoryCli.writtenTextFiles).toEqual([ + { + content: `${JSON.stringify(defaultParseResult)}\n`, + filePath: '/baselines/Profile.JSON', + }, + ]); + }); + + test('passes raw text options to write-json parsing', async () => { + const memoryCli = createMemoryCliDependencies({ + binaryFiles: new Map([['/baselines/Profile.pdf', new Uint8Array([1])]]), + directories: new Set(['/baselines']), + directoryEntries: new Map([ + ['/baselines', [{ kind: 'file', name: 'Profile.pdf' }]], + ]), + }); + + const result = await runCli({ + args: ['write-json', '/baselines', '--raw-text'], + dependencies: memoryCli.dependencies, + }); + + expect(result.exitCode).toBe(0); + expect(memoryCli.parseOptions).toEqual([{ includeRawText: true }]); + }); + + test('verifies matching PDF and JSON pairs', async () => { + const memoryCli = createMemoryCliDependencies({ + binaryFiles: new Map([['/baselines/Profile.PDF', new Uint8Array([1])]]), + directories: new Set(['/baselines']), + directoryEntries: new Map([ + [ + '/baselines', + [ + { kind: 'file', name: 'Profile.PDF' }, + { kind: 'file', name: 'profile.json' }, + ], + ], + ]), + textFiles: new Map([ + ['/baselines/profile.json', JSON.stringify(defaultParseResult)], + ]), + }); + + const result = await runCli({ + args: ['verify-json', '/baselines'], + dependencies: memoryCli.dependencies, + }); + + expect(result).toEqual({ + exitCode: 0, + stderr: '', + stdout: expect.stringContaining('Verified 1 PDF/JSON pair(s)'), + }); + expect(memoryCli.readFilePaths).toEqual(['/baselines/Profile.PDF']); + }); + + test('passes raw text options to verify-json parsing', async () => { + const memoryCli = createMemoryCliDependencies({ + binaryFiles: new Map([['/baselines/Profile.pdf', new Uint8Array([1])]]), + directories: new Set(['/baselines']), + directoryEntries: new Map([ + [ + '/baselines', + [ + { kind: 'file', name: 'Profile.pdf' }, + { kind: 'file', name: 'Profile.json' }, + ], + ], + ]), + textFiles: new Map([ + ['/baselines/Profile.json', JSON.stringify(defaultParseResult)], + ]), + }); + + const result = await runCli({ + args: ['verify-json', '--raw-text', '/baselines'], + dependencies: memoryCli.dependencies, + }); + + expect(result).toEqual({ + exitCode: 0, + stderr: '', + stdout: expect.stringContaining('Verified 1 PDF/JSON pair(s)'), + }); + expect(memoryCli.parseOptions).toEqual([{ includeRawText: true }]); + }); + + test('prints a context diff when verify-json finds a mismatch', async () => { + const expectedResult: ParseResult = { + ...defaultParseResult, + profile: { + ...defaultParseResult.profile, + name: 'Hermes Trismegistus', + }, + }; + const memoryCli = createMemoryCliDependencies({ + binaryFiles: new Map([['/baselines/Profile.pdf', new Uint8Array([1])]]), + directories: new Set(['/baselines']), + directoryEntries: new Map([ + [ + '/baselines', + [ + { kind: 'file', name: 'Profile.pdf' }, + { kind: 'file', name: 'Profile.json' }, + ], + ], + ]), + textFiles: new Map([ + ['/baselines/Profile.json', JSON.stringify(expectedResult)], + ]), + }); + + const result = await runCli({ + args: ['verify-json', '/baselines'], + dependencies: memoryCli.dependencies, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('--- expected'); + expect(result.stderr).toContain('+++ generated'); + expect(result.stderr).toContain('@@ -'); + expect(result.stderr).toContain('- "name": "Hermes Trismegistus"'); + expect(result.stderr).toContain('+ "name": "Orion Helios"'); + expect(result.stderr).not.toContain('"diagnostics": {'); + }); + + test('prints JSON keypath output when verify-json requests it', async () => { + const expectedResult: ParseResult = { + ...defaultParseResult, + profile: { + ...defaultParseResult.profile, + name: 'Hermes Trismegistus', + }, + }; + const memoryCli = createMemoryCliDependencies({ + binaryFiles: new Map([['/baselines/Profile.pdf', new Uint8Array([1])]]), + directories: new Set(['/baselines']), + directoryEntries: new Map([ + [ + '/baselines', + [ + { kind: 'file', name: 'Profile.pdf' }, + { kind: 'file', name: 'Profile.json' }, + ], + ], + ]), + textFiles: new Map([ + ['/baselines/Profile.json', JSON.stringify(expectedResult)], + ]), + }); + + const result = await runCli({ + args: ['verify-json', '/baselines', '--json-paths'], + dependencies: memoryCli.dependencies, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).not.toContain('--- expected'); + expect(result.stderr).toContain( + '~ profile.name: "Hermes Trismegistus" -> "Orion Helios"' + ); + }); + + test('reports invalid JSON, parse failures, and missing pairs', async () => { + const brokenPdfBytes = new Uint8Array([2]); + const memoryCli = createMemoryCliDependencies({ + binaryFiles: new Map([ + ['/baselines/Broken.pdf', brokenPdfBytes], + ['/baselines/Empty.pdf', new Uint8Array([5])], + ['/baselines/Invalid.pdf', new Uint8Array([3])], + ['/baselines/MissingJson.pdf', new Uint8Array([4])], + ]), + directories: new Set(['/baselines']), + directoryEntries: new Map([ + [ + '/baselines', + [ + { kind: 'file', name: 'Broken.pdf' }, + { kind: 'file', name: 'Broken.json' }, + { kind: 'file', name: 'Empty.pdf' }, + { kind: 'file', name: 'Empty.json' }, + { kind: 'file', name: 'Invalid.pdf' }, + { kind: 'file', name: 'Invalid.json' }, + { kind: 'file', name: 'MissingJson.pdf' }, + { kind: 'file', name: 'Orphan.json' }, + ], + ], + ]), + parsePdf: async input => { + if (input === brokenPdfBytes) { + throw new Error('parse failed'); + } + + return defaultParseResult; + }, + textFiles: new Map([ + ['/baselines/Broken.json', JSON.stringify(defaultParseResult)], + ['/baselines/Empty.json', ''], + ['/baselines/Invalid.json', '{'], + ['/baselines/Orphan.json', JSON.stringify(defaultParseResult)], + ]), + }); + + const result = await runCli({ + args: ['verify-json', '/baselines'], + dependencies: memoryCli.dependencies, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('/baselines/Broken.pdf: parse failed'); + expect(result.stderr).toContain( + '/baselines/Empty.json: Invalid JSON baseline: Unexpected end of JSON input' + ); + expect(result.stderr).toContain( + '/baselines/Invalid.json: Invalid JSON baseline' + ); + expect(result.stderr).toContain( + '/baselines/MissingJson.pdf: Missing JSON baseline' + ); + expect(result.stderr).toContain( + '/baselines/Orphan.json: Missing PDF source' + ); + }); +}); + +interface ExpectedProfileFields { + email: string; + name: string; +} + +function expectJsonProfile( + result: CliResult, + expected: ExpectedProfileFields +): void { + expect(JSON.parse(result.stdout)).toEqual( + expect.objectContaining({ + profile: expect.objectContaining({ + contact: expect.objectContaining({ + email: expected.email, + }), + name: expected.name, + }), + }) + ); +} + +interface MemoryCliDependenciesParams { + binaryFiles?: Map; + directories?: Set; + directoryEntries?: Map; + fileExists?: CliDependencies['fileExists']; + parsePdf?: CliDependencies['parsePdf']; + resolvePath?: (filePath: string) => string; + textFiles?: Map; +} + +interface TextFileWrite { + content: string; + filePath: string; +} + +interface MemoryCliDependencies { + dependencies: CliDependencies; + parseOptions: ParseOptions[]; + readFilePaths: string[]; + writtenTextFiles: TextFileWrite[]; +} + +const defaultParseResult: ParseResult = { + diagnostics: { + confidence: 0.7, + isEmpty: false, + isLikelyLinkedInExport: true, + sectionsFound: ['profile'], + }, + profile: { + certifications: [], + contact: { + email: 'fixture@example.com', + }, + education: [], + experience: [], + experience_groups: [], + headline: 'Fixture headline', + honors_awards: [], + languages: [], + location: 'San Francisco, CA', + name: 'Orion Helios', + projects: [], + publications: [], + top_skills: [], + volunteer_work: [], + }, + warnings: [], +}; + +function createMemoryCliDependencies( + params: MemoryCliDependenciesParams = {} +): MemoryCliDependencies { + const binaryFiles = params.binaryFiles ?? new Map(); + const directories = params.directories ?? new Set(); + const directoryEntries = + params.directoryEntries ?? new Map(); + const parseOptions: ParseOptions[] = []; + const readFilePaths: string[] = []; + const textFiles = params.textFiles ?? new Map(); + const writtenTextFiles: TextFileWrite[] = []; + + return { + dependencies: { + directoryExists: directoryPath => directories.has(directoryPath), + fileExists: + params.fileExists ?? + (filePath => + directories.has(filePath) || + binaryFiles.has(filePath) || + textFiles.has(filePath)), + listDirectory: directoryPath => directoryEntries.get(directoryPath) ?? [], + parsePdf: async (input, options) => { + parseOptions.push(options); + + if (params.parsePdf) { + return params.parsePdf(input, options); + } + + return defaultParseResult; + }, + readFile: filePath => { + const file = binaryFiles.get(filePath); + + readFilePaths.push(filePath); + + if (!file) { + throw new Error(`Missing binary file: ${filePath}`); + } + + return file; + }, + readTextFile: filePath => { + const file = textFiles.get(filePath); + + if (file === undefined) { + throw new Error(`Missing text file: ${filePath}`); + } + + return file; + }, + resolvePath: params.resolvePath ?? (filePath => filePath), + writeTextFile: (filePath, content) => { + textFiles.set(filePath, content); + writtenTextFiles.push({ content, filePath }); + }, + }, + parseOptions, + readFilePaths, + writtenTextFiles, + }; +} diff --git a/tests/unit/date-parser.test.ts b/tests/unit/date-parser.test.ts new file mode 100644 index 0000000..6e2e043 --- /dev/null +++ b/tests/unit/date-parser.test.ts @@ -0,0 +1,215 @@ +import { + extractProfileDateRangeText, + parseProfileDateRange, +} from '../../src/utils/date-parser.js'; + +describe('profile date parser', () => { + test('parses abbreviated English ranges with dash variants and duration text', () => { + expect(parseProfileDateRange('Jan 2020 – Mar 2021 · 1 yr 3 mos')).toEqual({ + durationText: '1 yr 3 mos', + end: { + iso: '2021-03', + precision: 'month', + text: 'March 2021', + }, + kind: 'completed', + originalText: 'Jan 2020 - Mar 2021 · 1 yr 3 mos', + start: { + iso: '2020-01', + precision: 'month', + text: 'January 2020', + }, + }); + }); + + test('extracts parenthetical duration text from the shared duration vocabulary', () => { + expect(parseProfileDateRange('Jan 2020 - Mar 2021 (1 yr 3 mos)')).toEqual( + expect.objectContaining({ + durationText: '1 yr 3 mos', + end: expect.objectContaining({ iso: '2021-03' }), + start: expect.objectContaining({ iso: '2020-01' }), + }) + ); + }); + + test('parses current roles without inventing an end date', () => { + expect(parseProfileDateRange('Jan 2020 - Present')).toEqual({ + kind: 'current', + originalText: 'Jan 2020 - Present', + start: { + iso: '2020-01', + precision: 'month', + text: 'January 2020', + }, + }); + }); + + test('preserves canonical capitalization for full English month names', () => { + expect(parseProfileDateRange('March 2015 - January 2022')).toEqual( + expect.objectContaining({ + end: expect.objectContaining({ text: 'January 2022' }), + start: expect.objectContaining({ text: 'March 2015' }), + }) + ); + expect(parseProfileDateRange('May 2019 - May 2020')).toEqual( + expect.objectContaining({ + end: expect.objectContaining({ text: 'May 2020' }), + start: expect.objectContaining({ text: 'May 2019' }), + }) + ); + }); + + test('parses localized month ranges', () => { + expect(parseProfileDateRange('janeiro de 2020 - março de 2024')).toEqual( + expect.objectContaining({ + end: expect.objectContaining({ iso: '2024-03' }), + start: expect.objectContaining({ iso: '2020-01' }), + }) + ); + expect(parseProfileDateRange('janvier 2020 - présent')).toEqual( + expect.objectContaining({ + kind: 'current', + start: expect.objectContaining({ iso: '2020-01' }), + }) + ); + }); + + test('extracts embedded year ranges and rejects relative durations', () => { + expect( + extractProfileDateRangeText('Provided support from 2019 - 2021') + ).toBe('2019 - 2021'); + expect(parseProfileDateRange('in 3 months')).toBeUndefined(); + expect(parseProfileDateRange('sometime later')).toBeUndefined(); + }); + + test('does not split ISO or compact month-year dates on internal hyphens', () => { + expect(parseProfileDateRange('2020-01')).toEqual({ + kind: 'single', + originalText: '2020-01', + start: { + iso: '2020-01', + precision: 'month', + text: '2020-01', + }, + }); + expect(parseProfileDateRange('2020-01-31')).toEqual({ + kind: 'single', + originalText: '2020-01-31', + start: { + iso: '2020-01-31', + precision: 'day', + text: '2020-01-31', + }, + }); + expect(parseProfileDateRange('2020-01-00')).toBeUndefined(); + expect(parseProfileDateRange('Jan-2020')).toEqual({ + kind: 'single', + originalText: 'Jan-2020', + start: { + iso: '2020-01', + precision: 'month', + text: 'January-2020', + }, + }); + expect(parseProfileDateRange('2020-2024')).toEqual({ + end: { + iso: '2024', + precision: 'year', + text: '2024', + }, + kind: 'completed', + originalText: '2020-2024', + start: { + iso: '2020', + precision: 'year', + text: '2020', + }, + }); + }); + + test('parses ISO day ranges without relying on chrono range detection', () => { + expect(parseProfileDateRange('2020-01-31 - 2020-02-28')).toEqual({ + end: { + iso: '2020-02-28', + precision: 'day', + text: '2020-02-28', + }, + kind: 'completed', + originalText: '2020-01-31 - 2020-02-28', + start: { + iso: '2020-01-31', + precision: 'day', + text: '2020-01-31', + }, + }); + }); + + test('parses chrono-only year and day ranges', () => { + expect(parseProfileDateRange('during 2020')).toEqual({ + kind: 'single', + originalText: 'during 2020', + start: { + iso: '2020', + precision: 'year', + text: '2020', + }, + }); + + expect(parseProfileDateRange('January 5 to February 6 2020')).toEqual( + expect.objectContaining({ + end: expect.objectContaining({ + iso: '2020-02-06', + precision: 'day', + }), + kind: 'completed', + start: expect.objectContaining({ + iso: '2020-01-05', + precision: 'day', + }), + }) + ); + }); + + test('rejects empty and incomplete date ranges', () => { + expect(parseProfileDateRange('')).toBeUndefined(); + expect(parseProfileDateRange('2020 - eventually')).toBeUndefined(); + expect(parseProfileDateRange('Present')).toBeUndefined(); + }); + + test('parses single chrono month and year dates after leading prose', () => { + expect(parseProfileDateRange('worked from January 2020')).toEqual({ + kind: 'single', + originalText: 'worked from January 2020', + start: { + iso: '2020-01', + precision: 'month', + text: 'January 2020', + }, + }); + expect(parseProfileDateRange('worked from 2020')).toEqual({ + kind: 'single', + originalText: 'worked from 2020', + start: { + iso: '2020', + precision: 'year', + text: '2020', + }, + }); + }); + + test('parses chrono ranges that use words instead of dash delimiters', () => { + expect(parseProfileDateRange('January 2020 through March 2021')).toEqual( + expect.objectContaining({ + end: expect.objectContaining({ + iso: '2021-03', + precision: 'month', + }), + kind: 'completed', + start: expect.objectContaining({ + iso: '2020-01', + precision: 'month', + }), + }) + ); + }); +}); diff --git a/tests/unit/education.test.ts b/tests/unit/education.test.ts new file mode 100644 index 0000000..09a6023 --- /dev/null +++ b/tests/unit/education.test.ts @@ -0,0 +1,712 @@ +import { EducationParser } from '../../src/parsers/education.js'; +import type { StructuralLine } from '../../src/utils/structural-lines.js'; + +describe('EducationParser', () => { + test('removes extracted years from degree text', () => { + const educations = EducationParser.parse(` + Education + Example University + Bachelor of Science 2016 in Engineering + State College + Master of Business (2018) + Technical Institute + Executive Program (January 2019 - 2020) + `); + + expect(educations).toEqual([ + expect.objectContaining({ + institution: 'Example University', + degree: 'Bachelor of Science in Engineering', + year: '2016', + }), + expect.objectContaining({ + institution: 'State College', + degree: 'Master of Business', + year: '2018', + }), + expect.objectContaining({ + institution: 'Technical Institute', + degree: 'Executive Program', + year: 'January 2019 - 2020', + }), + ]); + }); + + test('cleans month-qualified dates from degree text', () => { + const educations = EducationParser.parse(` + Education + Executive Institute + Executive Program - (January 2019 - December 2020) + Language School + Certificate in French · (January 2022) + `); + + expect(educations).toEqual([ + expect.objectContaining({ + degree: 'Executive Program', + institution: 'Executive Institute', + year: 'January 2019 - December 2020', + }), + expect.objectContaining({ + degree: 'Certificate in French', + institution: 'Language School', + year: 'January 2022', + }), + ]); + }); + + test('recognizes Brazilian Portuguese degree names', () => { + const educations = EducationParser.parse(` + Education + Universidade Federal Exemplo + Bacharelado em Engenharia de Computação · (2010 - 2014) + Faculdade Municipal + Mestrado em Ciência de Dados 2018 + Instituto Técnico + Tecnólogo em Sistemas para Internet + `); + + expect(educations).toEqual([ + expect.objectContaining({ + degree: 'Bacharelado em Engenharia de Computação', + institution: 'Universidade Federal Exemplo', + year: '2010 - 2014', + }), + expect.objectContaining({ + degree: 'Mestrado em Ciência de Dados', + institution: 'Faculdade Municipal', + year: '2018', + }), + expect.objectContaining({ + degree: 'Tecnólogo em Sistemas para Internet', + institution: 'Instituto Técnico', + }), + ]); + }); + + test('recognizes associate and certificate degree names', () => { + const educations = EducationParser.parse(` + Education + Example Community College + Associate of Arts 2012 + Technical Institute + Certificate in Data Analytics 2014 + Faculdade Municipal + Certificação em Gestão 2018 + `); + + expect(educations).toEqual([ + expect.objectContaining({ + degree: 'Associate of Arts', + institution: 'Example Community College', + year: '2012', + }), + expect.objectContaining({ + degree: 'Certificate in Data Analytics', + institution: 'Technical Institute', + year: '2014', + }), + expect.objectContaining({ + degree: 'Certificação em Gestão', + institution: 'Faculdade Municipal', + year: '2018', + }), + ]); + }); + + test('parses structural education by visual hierarchy', () => { + const educations = EducationParser.parseStructural([ + structuralLine({ fontSize: 16, text: 'Education', y: 760 }), + structuralLine({ fontSize: 14, text: 'Universidade Exemplo', y: 730 }), + structuralLine({ + fontSize: 10, + text: 'Licenciatura em Matemática · (2012 - 2016)', + y: 710, + }), + structuralLine({ fontSize: 14, text: 'Instituto Exemplo', y: 680 }), + structuralLine({ + fontSize: 10, + text: 'Pós-graduação em Gestão de Produto 2018', + y: 660, + }), + structuralLine({ fontSize: 16, text: 'Projects', y: 620 }), + ]); + + expect(educations).toEqual([ + expect.objectContaining({ + degree: 'Licenciatura em Matemática', + institution: 'Universidade Exemplo', + year: '2012 - 2016', + }), + expect.objectContaining({ + degree: 'Pós-graduação em Gestão de Produto', + institution: 'Instituto Exemplo', + year: '2018', + }), + ]); + }); + + test('joins wrapped structural degree lines before extracting dates', () => { + const educations = EducationParser.parseStructural([ + structuralLine({ fontSize: 16, text: 'Education', y: 760 }), + structuralLine({ + fontSize: 14, + text: 'Universidade Veiga de Almeida', + y: 730, + }), + structuralLine({ + fontSize: 10, + text: 'Master of Business Administration - MBA, Business', + y: 710, + }), + structuralLine({ + fontSize: 10, + text: 'Management · (2017 - 2018)', + y: 696, + }), + structuralLine({ fontSize: 16, text: 'Experience', y: 660 }), + ]); + + expect(educations).toEqual([ + expect.objectContaining({ + dates: expect.objectContaining({ + originalText: '2017 - 2018', + }), + degree: 'Master of Business Administration - MBA, Business Management', + institution: 'Universidade Veiga de Almeida', + year: '2017 - 2018', + }), + ]); + }); + + test('joins slash-wrapped structural degree lines without adding a space', () => { + const educations = EducationParser.parseStructural([ + structuralLine({ fontSize: 16, text: 'Education', y: 760 }), + structuralLine({ + fontSize: 14, + text: 'ETE Ferreira Viana (FAETEC)', + y: 730, + }), + structuralLine({ + fontSize: 10, + text: 'Telecommunications Technician, Telecommunications Technology/', + y: 710, + }), + structuralLine({ + fontSize: 10, + text: 'Technician · (2002 - 2005)', + y: 696, + }), + structuralLine({ fontSize: 16, text: 'Experience', y: 660 }), + ]); + + expect(educations[0]).toEqual( + expect.objectContaining({ + degree: + 'Telecommunications Technician, Telecommunications Technology/Technician', + year: '2002 - 2005', + }) + ); + }); + + test('joins no-date wrapped structural degree lines', () => { + const educations = EducationParser.parseStructural([ + structuralLine({ fontSize: 16, text: 'Education', y: 760 }), + structuralLine({ + fontSize: 14, + text: 'Universidade Veiga de Almeida', + y: 730, + }), + structuralLine({ + fontSize: 10, + text: 'Master of Business Administration - MBA, Business', + y: 710, + }), + structuralLine({ + fontSize: 10, + text: 'Management', + y: 696, + }), + structuralLine({ fontSize: 16, text: 'Experience', y: 660 }), + ]); + + expect(educations[0]).toEqual( + expect.objectContaining({ + degree: 'Master of Business Administration - MBA, Business Management', + location: '', + year: '', + }) + ); + }); + + test('joins minor degree continuations', () => { + const [education] = EducationParser.parseStructural([ + structuralLine({ fontSize: 16, text: 'Education', y: 760 }), + structuralLine({ fontSize: 14, text: 'University of Denver', y: 730 }), + structuralLine({ + fontSize: 10, + text: 'Bachelor of Science (B.S.) Bachelor of Arts (B.A.), Economics , History, Minor', + y: 710, + }), + structuralLine({ + fontSize: 10, + text: 'in Speech Communication', + y: 696, + }), + structuralLine({ fontSize: 16, text: 'Experience', y: 660 }), + ]); + + expect(education).toEqual( + expect.objectContaining({ + degree: + 'Bachelor of Science (B.S.) Bachelor of Arts (B.A.), Economics , History, Minor in Speech Communication', + institution: 'University of Denver', + }) + ); + }); + + test('preserves degree text when structural date ranges share the same line', () => { + const educations = EducationParser.parseStructural([ + structuralLine({ fontSize: 16, text: 'Education', y: 760 }), + structuralLine({ fontSize: 14, text: 'Harvard University', y: 730 }), + structuralLine({ + fontSize: 10, + text: 'BA, Government · (1998 - 2002)', + y: 710, + }), + structuralLine({ fontSize: 14, text: 'École Polytechnique', y: 680 }), + structuralLine({ + fontSize: 10, + text: 'Intermediate Certificate of French Language Français B1/B2 · (January 2022)', + y: 660, + }), + structuralLine({ fontSize: 16, text: 'Experience', y: 620 }), + ]); + + expect(educations).toEqual([ + expect.objectContaining({ + degree: 'BA, Government', + institution: 'Harvard University', + year: '1998 - 2002', + }), + expect.objectContaining({ + degree: 'Intermediate Certificate of French Language Français B1/B2', + institution: 'École Polytechnique', + year: 'January 2022', + }), + ]); + }); + + test('joins wrapped institution names before assigning degree details', () => { + const [education] = EducationParser.parseStructural([ + structuralLine({ fontSize: 16, text: 'Education', y: 760 }), + structuralLine({ + fontSize: 14, + text: 'City University of New York-Baruch College - Zicklin School of', + y: 730, + }), + structuralLine({ fontSize: 14, text: 'Business', y: 716 }), + structuralLine({ + fontSize: 10, + text: 'MBA, Finance · (2002 - 2004)', + y: 696, + }), + structuralLine({ fontSize: 16, text: 'Experience', y: 660 }), + ]); + + expect(education).toEqual( + expect.objectContaining({ + degree: 'MBA, Finance', + institution: + 'City University of New York-Baruch College - Zicklin School of Business', + year: '2002 - 2004', + }) + ); + }); + + test('joins multi-word school of institution continuations', () => { + const [education] = EducationParser.parseStructural([ + structuralLine({ fontSize: 16, text: 'Education', y: 760 }), + structuralLine({ + fontSize: 14, + text: 'Example University School of', + y: 730, + }), + structuralLine({ + fontSize: 14, + text: 'Business Administration', + y: 716, + }), + structuralLine({ + fontSize: 10, + text: 'MBA, Finance · (2002 - 2004)', + y: 696, + }), + structuralLine({ fontSize: 16, text: 'Experience', y: 660 }), + ]); + + expect(education).toEqual( + expect.objectContaining({ + degree: 'MBA, Finance', + institution: 'Example University School of Business Administration', + year: '2002 - 2004', + }) + ); + }); + + test('does not append comma-adjacent non-academic details to degree text', () => { + const educations = EducationParser.parseStructural([ + structuralLine({ fontSize: 16, text: 'Education', y: 760 }), + structuralLine({ + fontSize: 14, + text: 'Example University', + y: 730, + }), + structuralLine({ + fontSize: 10, + text: 'Certificate, Honors', + y: 710, + }), + structuralLine({ + fontSize: 10, + text: 'Policy', + y: 696, + }), + structuralLine({ fontSize: 16, text: 'Experience', y: 660 }), + ]); + + expect(educations[0]).toEqual( + expect.objectContaining({ + degree: 'Certificate, Honors', + location: '', + }) + ); + }); + + test('splits structural institution names that contain degree keywords', () => { + const educations = EducationParser.parseStructural([ + structuralLine({ fontSize: 16, text: 'Education', y: 760 }), + structuralLine({ + fontSize: 12, + text: 'Fletcher, The Graduate School of Global Affairs at Tufts University', + y: 730, + }), + structuralLine({ + fontSize: 10.5, + text: 'Post-MBA Fellowship · (2019 - 2020)', + y: 710, + }), + structuralLine({ + fontSize: 12, + text: 'The London School of Economics and Political Science (LSE)', + y: 680, + }), + structuralLine({ + fontSize: 10.5, + text: 'Graduate Diploma, Economics · (2017 - 2019)', + y: 660, + }), + ]); + + expect(educations).toEqual([ + expect.objectContaining({ + degree: 'Post-MBA Fellowship', + institution: + 'Fletcher, The Graduate School of Global Affairs at Tufts University', + year: '2019 - 2020', + }), + expect.objectContaining({ + degree: 'Graduate Diploma, Economics', + institution: + 'The London School of Economics and Political Science (LSE)', + year: '2017 - 2019', + }), + ]); + }); + + test('keeps economics institution names in text fallback parsing', () => { + const [education] = EducationParser.parse(` + Education + London School of Economics + Graduate Diploma, Economics · (2017 - 2019) + `); + + expect(education).toEqual( + expect.objectContaining({ + degree: 'Graduate Diploma, Economics', + institution: 'London School of Economics', + year: '2017 - 2019', + }) + ); + }); + + test('recognizes dotted and hyphenated structural education locations', () => { + const educations = EducationParser.parseStructural([ + structuralLine({ fontSize: 16, text: 'Education', y: 760 }), + structuralLine({ + fontSize: 14, + text: 'Example University', + y: 730, + }), + structuralLine({ + fontSize: 10, + text: 'Computer Science', + y: 710, + }), + structuralLine({ fontSize: 10, text: 'Winston-Salem, NC', y: 696 }), + structuralLine({ + fontSize: 14, + text: 'State College', + y: 660, + }), + structuralLine({ + fontSize: 10, + text: 'Bachelor of Science', + y: 640, + }), + structuralLine({ fontSize: 10, text: 'Washington, D.C.', y: 626 }), + structuralLine({ fontSize: 16, text: 'Experience', y: 600 }), + ]); + + expect(educations).toEqual([ + expect.objectContaining({ + degree: 'Computer Science', + location: 'Winston-Salem, NC', + }), + expect.objectContaining({ + degree: 'Bachelor of Science', + location: 'Washington, D.C.', + }), + ]); + }); + + test('does not append structural locations to an existing degree', () => { + const educations = EducationParser.parseStructural([ + structuralLine({ fontSize: 16, text: 'Education', y: 760 }), + structuralLine({ + fontSize: 14, + text: 'Example University', + y: 730, + }), + structuralLine({ + fontSize: 10, + text: 'Computer Science', + y: 710, + }), + structuralLine({ fontSize: 10, text: 'New York, NY', y: 696 }), + structuralLine({ fontSize: 16, text: 'Experience', y: 660 }), + ]); + + expect(educations[0]).toEqual( + expect.objectContaining({ + degree: 'Computer Science', + location: 'New York, NY', + }) + ); + }); + + test('adds structured dates for education ranges', () => { + const [education] = EducationParser.parse(` + Education + Example University + Bachelor of Science + 2020 - 2024 + `); + + expect(education.dates).toEqual({ + originalText: '2020 - 2024', + start: { + iso: '2020', + precision: 'year', + text: '2020', + }, + end: { + iso: '2024', + precision: 'year', + text: '2024', + }, + kind: 'completed', + }); + }); + + test('parses standalone year and location detail lines', () => { + const [education] = EducationParser.parse(` + Education + Example University + Bachelor of Science + 2016 + Austin, Texas + `); + + expect(education).toEqual( + expect.objectContaining({ + degree: 'Bachelor of Science', + institution: 'Example University', + location: 'Austin, Texas', + year: '2016', + }) + ); + }); + + test('returns no structural entries when an education section is absent', () => { + expect(EducationParser.parseStructural([])).toEqual([]); + expect( + EducationParser.parseStructural([ + structuralLine({ fontSize: 16, text: 'Experience', y: 760 }), + structuralLine({ fontSize: 10, text: 'Example University', y: 730 }), + ]) + ).toEqual([]); + }); + + test('uses the first structural detail as an institution when hierarchy is missing', () => { + const [education] = EducationParser.parseStructural([ + structuralLine({ fontSize: 16, text: 'Education', y: 760 }), + structuralLine({ + fontSize: 10, + text: 'Certificate in Product Design 2020', + y: 730, + }), + structuralLine({ fontSize: 16, text: 'Experience', y: 700 }), + ]); + + expect(education).toEqual( + expect.objectContaining({ + degree: '', + institution: 'Certificate in Product Design 2020', + }) + ); + }); + + test('warns when an education year cannot be parsed as a profile date', () => { + const result = EducationParser.parseWithWarnings(` + Education + Archive College + Certificate in Cataloging + 1888 - 1889 + `); + + expect(result.warnings).toEqual([ + expect.objectContaining({ + field: 'dates', + rawText: '1888 - 1889', + }), + ]); + }); + + test('creates warnings for unparseable education sections and missing institutions', () => { + expect( + EducationParser['createEducationWarnings']( + [], + ['Continuing studies without an institution'] + ) + ).toEqual([ + expect.objectContaining({ + field: 'entry', + section: 'education', + }), + ]); + expect( + EducationParser['createEducationWarnings']( + [ + { + degree: 'Certificate in Cataloging', + institution: '', + location: '', + year: '', + }, + ], + [] + ) + ).toEqual([ + expect.objectContaining({ + field: 'institution', + rawText: 'Certificate in Cataloging', + }), + ]); + }); + + test('covers structural degree detail helper branches', () => { + const educationWithDatedDegree = { + degree: '', + institution: 'Example University', + location: '', + year: '', + }; + const educationWithStandaloneYear = { + degree: '', + institution: 'Example University', + location: '', + year: '', + }; + + EducationParser['addStructuralEducationDetail']({ + education: educationWithDatedDegree, + line: 'Product Design 2016', + }); + EducationParser['addStructuralEducationDetail']({ + education: educationWithStandaloneYear, + line: '2016', + }); + + expect(educationWithDatedDegree).toEqual( + expect.objectContaining({ + degree: 'Product Design', + year: '2016', + }) + ); + expect(educationWithStandaloneYear).toEqual( + expect.objectContaining({ + degree: '', + year: '2016', + }) + ); + + expect( + EducationParser['shouldAppendStructuralDegreePart']({ + degreePart: '', + existingDegree: 'Bachelor of', + line: '', + year: '', + }) + ).toBe(false); + expect( + EducationParser['shouldAppendStructuralDegreePart']({ + degreePart: 'New York, NY', + existingDegree: 'Bachelor of Science', + line: 'New York, NY', + year: '2020', + }) + ).toBe(false); + expect( + EducationParser['looksLikeInstitutionContinuation']({ + line: 'Business', + }) + ).toBe(false); + expect( + EducationParser['looksLikeInstitutionContinuation']({ + institution: 'Example University School of', + line: 'Business', + }) + ).toBe(true); + }); +}); + +function structuralLine({ + fontSize, + text, + y, +}: { + fontSize: number; + text: string; + y: number; +}): StructuralLine { + return { + column: 'right', + fontSize, + height: fontSize, + text, + width: text.length * 5, + x: 220, + y, + }; +} diff --git a/tests/unit/experience-structural.test.ts b/tests/unit/experience-structural.test.ts new file mode 100644 index 0000000..4d4cfb7 --- /dev/null +++ b/tests/unit/experience-structural.test.ts @@ -0,0 +1,3694 @@ +import { ExperienceStructuralParser } from '../../src/parsers/experience-structural.js'; +import type { + StructuralSection, + TextItem, +} from '../../src/types/structural.js'; +import type { NormalizedParserLine } from '../../src/utils/parser-lines.js'; + +function textItem({ + text, + y, + fontSize = 12, + x = 220, +}: { + text: string; + y: number; + fontSize?: number; + x?: number; +}): TextItem { + return { + text, + x, + y, + fontSize, + fontFamily: 'Helvetica', + width: text.length * 5, + height: fontSize, + }; +} + +describe('ExperienceStructuralParser', () => { + test('parses bounded right-column experience entries and ignores education', () => { + const items = [ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Research Systems Group', y: 670 }), + textItem({ text: 'Principal Engineer', y: 650, fontSize: 11.5 }), + textItem({ text: 'January 2020 - March 2024 (4 years)', y: 630 }), + textItem({ text: 'Austin, TX', y: 610 }), + textItem({ text: 'Built data products for enterprise teams.', y: 590 }), + textItem({ text: 'Education', y: 500, fontSize: 16 }), + textItem({ text: 'State University', y: 480 }), + ]; + + const [experience] = ExperienceStructuralParser.parseExperience(items); + + expect(experience.organization).toBe('Research Systems Group'); + expect(experience.positions).toEqual([ + { + title: 'Principal Engineer', + duration: 'January 2020 - March 2024', + dates: { + durationText: '4 years', + originalText: 'January 2020 - March 2024 (4 years)', + start: { + iso: '2020-01', + precision: 'month', + text: 'January 2020', + }, + end: { + iso: '2024-03', + precision: 'month', + text: 'March 2024', + }, + kind: 'completed', + }, + location: 'Austin, TX', + description: 'Built data products for enterprise teams.', + }, + ]); + }); + + test('parses academic multi-position entries without empty organizations', () => { + const result = ExperienceStructuralParser.parseExperienceWithWarnings([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'UCLA', y: 670 }), + textItem({ text: '7 years 11 months', y: 650 }), + textItem({ text: 'Associate Professor', y: 630, fontSize: 11.5 }), + textItem({ text: 'July 2024 - Present (1 year 11 months)', y: 610 }), + textItem({ text: 'Assistant Professor', y: 580, fontSize: 11.5 }), + textItem({ text: 'July 2018 - July 2024 (6 years 1 month)', y: 560 }), + textItem({ text: 'Los Angeles CA', y: 540 }), + textItem({ text: 'Visual Machines Group', y: 500 }), + textItem({ text: 'Leader', y: 480, fontSize: 11.5 }), + textItem({ text: 'July 2018 - Present (7 years 11 months)', y: 460 }), + textItem({ text: 'Intrinsic', y: 420 }), + textItem({ text: 'Research Scientist', y: 400, fontSize: 11.5 }), + textItem({ text: 'May 2022 - November 2023 (1 year 7 months)', y: 380 }), + textItem({ text: 'MIT Media Lab', y: 340 }), + textItem({ text: 'Research Assistant', y: 320, fontSize: 11.5 }), + textItem({ + text: 'September 2012 - May 2018 (5 years 9 months)', + y: 300, + }), + ]); + + expect(result.warnings).toEqual([]); + expect(result.value).toEqual([ + expect.objectContaining({ + organization: 'UCLA', + positions: [ + expect.objectContaining({ + duration: 'July 2024 - Present', + title: 'Associate Professor', + }), + expect.objectContaining({ + duration: 'July 2018 - July 2024', + location: 'Los Angeles CA', + title: 'Assistant Professor', + }), + ], + }), + expect.objectContaining({ + organization: 'Visual Machines Group', + positions: [ + expect.objectContaining({ + duration: 'July 2018 - Present', + title: 'Leader', + }), + ], + }), + expect.objectContaining({ + organization: 'Intrinsic', + positions: [ + expect.objectContaining({ + duration: 'May 2022 - November 2023', + title: 'Research Scientist', + }), + ], + }), + expect.objectContaining({ + organization: 'MIT Media Lab', + positions: [ + expect.objectContaining({ + duration: 'September 2012 - May 2018', + title: 'Research Assistant', + }), + ], + }), + ]); + }); + + test('preserves company total duration and duplicate dated positions', () => { + const [experience] = ExperienceStructuralParser.parseExperience([ + textItem({ text: 'Berufserfahrung', y: 700, fontSize: 16 }), + textItem({ text: 'Wolske Wealth Management', y: 670 }), + textItem({ text: '6 Jahre', y: 650 }), + textItem({ text: 'CEO', y: 630, fontSize: 11.5 }), + textItem({ text: '2020 - Present (6 Jahre)', y: 610 }), + textItem({ text: 'Founder', y: 580, fontSize: 11.5 }), + textItem({ text: '2020 - Present (6 Jahre)', y: 560 }), + ]); + + expect(experience).toEqual( + expect.objectContaining({ + organization: 'Wolske Wealth Management', + totalDuration: '6 Jahre', + positions: [ + expect.objectContaining({ + duration: '2020 - Present', + title: 'CEO', + dates: expect.objectContaining({ + durationText: '6 Jahre', + originalText: '2020 - Present (6 Jahre)', + }), + }), + expect.objectContaining({ + duration: '2020 - Present', + title: 'Founder', + }), + ], + }) + ); + }); + + test('recognizes organizations before short domain-specific titles', () => { + const experiences = ExperienceStructuralParser.parseExperience([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Horatius Group', y: 670 }), + textItem({ text: 'Managing Director', y: 650, fontSize: 11.5 }), + textItem({ text: 'January 2016 - Present (10 years)', y: 630 }), + textItem({ text: 'United States Marine Corps', y: 590 }), + textItem({ text: 'Marine', y: 570, fontSize: 11.5 }), + textItem({ + text: 'May 2001 - September 2009 (8 years 5 months)', + y: 550, + }), + textItem({ text: 'Fund Fellow Founders (FFF.VC)', y: 510 }), + textItem({ text: 'Angel Investor', y: 490, fontSize: 11.5 }), + textItem({ text: 'October 2022 - Present (3 years 8 months)', y: 470 }), + ]); + + expect(experiences).toEqual([ + expect.objectContaining({ + organization: 'Horatius Group', + }), + expect.objectContaining({ + organization: 'United States Marine Corps', + positions: [ + expect.objectContaining({ + title: 'Marine', + }), + ], + }), + expect.objectContaining({ + organization: 'Fund Fellow Founders (FFF.VC)', + positions: [ + expect.objectContaining({ + title: 'Angel Investor', + }), + ], + }), + ]); + }); + + test('recognizes lower-camel organizations with lowercase suffixes', () => { + const [experience] = ExperienceStructuralParser.parseExperience([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'xLabs llc', y: 670 }), + textItem({ text: 'Principal Engineer', y: 650, fontSize: 11.5 }), + textItem({ text: 'January 2020 - Present (6 years)', y: 630 }), + ]); + + expect(experience.organization).toBe('xLabs llc'); + expect(experience.positions[0]?.title).toBe('Principal Engineer'); + }); + + test('keeps prose with role verbs in descriptions when no date follows', () => { + const result = ExperienceStructuralParser.parseExperienceWithWarnings([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Mimosa Ventures', y: 670 }), + textItem({ text: 'Founder', y: 650, fontSize: 11.5 }), + textItem({ text: 'September 2024 - Present (1 year 9 months)', y: 630 }), + textItem({ text: 'Dallas, TX', y: 610 }), + textItem({ + text: "We don't lead rounds or demand board seats. When we invest, it's because", + y: 590, + }), + textItem({ + text: 'we have conviction in the founder.', + y: 570, + }), + textItem({ text: 'DallasMeetup', y: 530 }), + textItem({ text: 'Executive Advisor', y: 510, fontSize: 11.5 }), + textItem({ text: 'July 2025 - Present (11 months)', y: 490 }), + textItem({ + text: "Executive advisor for Dallas's largest industry-agnostic networking event.", + y: 470, + }), + ]); + + expect(result.warnings).toEqual([]); + expect(result.value).toEqual([ + expect.objectContaining({ + organization: 'Mimosa Ventures', + positions: [ + expect.objectContaining({ + description: + "We don't lead rounds or demand board seats. When we invest, it's because we have conviction in the founder.", + title: 'Founder', + }), + ], + }), + expect.objectContaining({ + organization: 'DallasMeetup', + positions: [ + expect.objectContaining({ + description: + "Executive advisor for Dallas's largest industry-agnostic networking event.", + duration: 'July 2025 - Present', + title: 'Executive Advisor', + }), + ], + }), + ]); + }); + + test('keeps short media descriptors inside experience descriptions', () => { + const result = ExperienceStructuralParser.parseExperienceWithWarnings([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Fulldome Film Society', y: 670 }), + textItem({ + text: 'Producer, “MYSTERY OF THE KUMBH MELA"', + y: 650, + fontSize: 11.5, + }), + textItem({ text: 'February 2013 - April 2013 (3 months)', y: 630 }), + textItem({ text: 'Directed by Apollo Phoebus', y: 610 }), + textItem({ text: 'Feature Film', y: 590 }), + textItem({ + text: 'Scouted to find story subjects and conducted pre-interviews for back story', + y: 570, + }), + textItem({ + text: 'Discovery Communications / Fischer Productions', + y: 530, + }), + textItem({ text: '4 months', y: 510 }), + textItem({ + text: "Post Production Supervisor, KING'S OF CRASH", + y: 490, + fontSize: 11.5, + }), + textItem({ text: 'November 2012 - January 2013 (3 months)', y: 470 }), + textItem({ text: 'Park City, UT', y: 450 }), + textItem({ + text: 'Executive Produced by Achilles Pelides & Circe Aeaea', + y: 430, + }), + textItem({ text: 'Television Series', y: 410 }), + textItem({ text: 'Areas of responsibility included:', y: 390 }), + textItem({ + text: '• Maintenance of daily operation of the Facilis server and editor workstations', + y: 370, + }), + textItem({ + text: "Producer, KING'S OF CRASH", + y: 330, + fontSize: 11.5, + }), + textItem({ text: 'October 2012 - November 2012 (2 months)', y: 310 }), + textItem({ text: 'Park City, UT', y: 290 }), + textItem({ + text: 'Executive Produced by Achilles Pelides & Circe Aeaea', + y: 270, + }), + textItem({ text: 'Television Series', y: 250 }), + textItem({ + text: 'I was a primary shooter/field producer on a fast-paced reality television series', + y: 230, + }), + ]); + + expect(result.warnings).toEqual([]); + expect(result.value).toEqual([ + expect.objectContaining({ + organization: 'Fulldome Film Society', + positions: [ + expect.objectContaining({ + description: + 'Directed by Apollo Phoebus Feature Film Scouted to find story subjects and conducted pre-interviews for back story', + title: 'Producer, “MYSTERY OF THE KUMBH MELA"', + }), + ], + }), + expect.objectContaining({ + organization: 'Discovery Communications / Fischer Productions', + positions: [ + expect.objectContaining({ + description: + 'Executive Produced by Achilles Pelides & Circe Aeaea Television Series Areas of responsibility included: • Maintenance of daily operation of the Facilis server and editor workstations', + title: "Post Production Supervisor, KING'S OF CRASH", + }), + expect.objectContaining({ + description: + 'Executive Produced by Achilles Pelides & Circe Aeaea Television Series I was a primary shooter/field producer on a fast-paced reality television series', + title: "Producer, KING'S OF CRASH", + }), + ], + totalDuration: '4 months', + }), + ]); + }); + + test('keeps short descriptors and client labels in descriptions', () => { + const result = ExperienceStructuralParser.parseExperienceWithWarnings([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Visual Machines Group', y: 670 }), + textItem({ text: 'Leader', y: 650, fontSize: 11.5 }), + textItem({ text: 'July 2018 - Present (7 years 11 months)', y: 630 }), + textItem({ text: 'Los Angeles CA', y: 610 }), + textItem({ text: 'Spatial AI', y: 590 }), + textItem({ text: 'OnePager', y: 550 }), + textItem({ text: 'Venture', y: 530, fontSize: 11.5 }), + textItem({ + text: 'September 2020 - February 2022 (1 year 6 months)', + y: 510, + }), + textItem({ + text: 'data room for startups in one link— email gate, analytics, and deck viewer.', + y: 490, + }), + textItem({ text: 'RQ', y: 450 }), + textItem({ text: 'Account Supervisor', y: 430, fontSize: 11.5 }), + textItem({ + text: 'May 2015 - September 2017 (2 years 5 months)', + y: 410, + }), + textItem({ text: 'Client: Paypal + Airbnb', y: 390 }), + textItem({ + text: 'Meet Halfway led the co-marketing initiative.', + y: 370, + }), + textItem({ text: 'Client: 1800 Tequila', y: 350 }), + textItem({ + text: 'Led the strategic repositioning of 1800 Tequila.', + y: 330, + }), + textItem({ text: 'KPMG', y: 290 }), + textItem({ text: 'Intern', y: 270, fontSize: 11.5 }), + textItem({ text: 'September 2003 - February 2004 (6 months)', y: 250 }), + textItem({ text: 'Paris Area, France', y: 230 }), + textItem({ text: 'Audit', y: 210 }), + textItem({ text: 'HEC Junior Conseil', y: 170 }), + textItem({ text: 'Consultant', y: 150, fontSize: 11.5 }), + textItem({ + text: 'December 2001 - March 2003 (1 year 4 months)', + y: 130, + }), + textItem({ text: 'Consulting', y: 110 }), + ]); + + expect(result.warnings).toEqual([]); + expect(result.value).toEqual([ + expect.objectContaining({ + organization: 'Visual Machines Group', + positions: [ + expect.objectContaining({ + description: 'Spatial AI', + title: 'Leader', + }), + ], + }), + expect.objectContaining({ + organization: 'OnePager', + positions: [ + expect.objectContaining({ + description: + 'data room for startups in one link— email gate, analytics, and deck viewer.', + title: 'Venture', + }), + ], + }), + expect.objectContaining({ + organization: 'RQ', + positions: [ + expect.objectContaining({ + description: + 'Client: Paypal + Airbnb Meet Halfway led the co-marketing initiative. Client: 1800 Tequila Led the strategic repositioning of 1800 Tequila.', + title: 'Account Supervisor', + }), + ], + }), + expect.objectContaining({ + organization: 'KPMG', + positions: [ + expect.objectContaining({ + description: 'Audit', + title: 'Intern', + }), + ], + }), + expect.objectContaining({ + organization: 'HEC Junior Conseil', + positions: [ + expect.objectContaining({ + description: 'Consulting', + title: 'Consultant', + }), + ], + }), + ]); + }); + + test('does not let sentence-like description lines hide the next organization', () => { + const result = ExperienceStructuralParser.parseExperienceWithWarnings([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Hermès', y: 670 }), + textItem({ + text: 'VP, corporate VC investments', + y: 650, + fontSize: 11.5, + }), + textItem({ text: 'February 2019 - Present (7 years 4 months)', y: 630 }), + textItem({ text: 'Greater Los Angeles Area', y: 610 }), + textItem({ + text: 'Exploring modern craftsmanship and looking for singularity through our', + y: 590, + }), + textItem({ + text: 'Corporate VC. Actively but discreetly investing in tech …', + y: 570, + }), + textItem({ text: 'Ampli & Co', y: 530 }), + textItem({ text: 'Consultant', y: 510, fontSize: 11.5 }), + textItem({ + text: 'February 2018 - February 2019 (1 year 1 month)', + y: 490, + }), + textItem({ text: 'Greater Los Angeles Area', y: 470 }), + ]); + + expect(result.warnings).toEqual([]); + expect(result.value).toEqual([ + expect.objectContaining({ + organization: 'Hermès', + positions: [ + expect.objectContaining({ + description: + 'Exploring modern craftsmanship and looking for singularity through our Corporate VC. Actively but discreetly investing in tech …', + title: 'VP, corporate VC investments', + }), + ], + }), + expect.objectContaining({ + organization: 'Ampli & Co', + positions: [ + expect.objectContaining({ + duration: 'February 2018 - February 2019', + location: 'Greater Los Angeles Area', + title: 'Consultant', + }), + ], + }), + ]); + }); + + test('merges wrapped bilingual organization headings', () => { + const result = ExperienceStructuralParser.parseExperienceWithWarnings([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ + text: 'Consulate General of Canada in New York | Consulat général du', + y: 670, + }), + textItem({ text: 'Canada à New York', y: 652 }), + textItem({ text: 'Venture Partner', y: 630, fontSize: 11.5 }), + textItem({ text: 'March 2023 - March 2024 (1 year 1 month)', y: 610 }), + textItem({ text: 'New York City Metropolitan Area', y: 590 }), + textItem({ + text: "Seed Stage Venture Capital Program hosted by Canada's Trade Commissioner Service.", + y: 570, + }), + ]); + + expect(result.warnings).toEqual([]); + expect(result.value).toEqual([ + expect.objectContaining({ + organization: + 'Consulate General of Canada in New York | Consulat général du Canada à New York', + positions: [ + expect.objectContaining({ + description: + "Seed Stage Venture Capital Program hosted by Canada's Trade Commissioner Service.", + duration: 'March 2023 - March 2024', + location: 'New York City Metropolitan Area', + title: 'Venture Partner', + }), + ], + }), + ]); + }); + + test('preserves hyphenated organization taglines', () => { + const [experience] = ExperienceStructuralParser.parseExperience([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'WhereTo - Business Travel Reimagined', y: 670 }), + textItem({ text: 'Board Member', y: 650, fontSize: 11.5 }), + textItem({ text: 'April 2017 - February 2018 (11 months)', y: 630 }), + ]); + + expect(experience.organization).toBe( + 'WhereTo - Business Travel Reimagined' + ); + }); + + test('parses board-advisor organization names with lowercase connectors', () => { + const result = ExperienceStructuralParser.parseExperienceWithWarnings([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'More than Equal', y: 670 }), + textItem({ text: 'Senior Advisor to the CEO', y: 650, fontSize: 11.5 }), + textItem({ text: 'April 2026 - Present (2 months)', y: 630 }), + textItem({ text: 'London Area, United Kingdom', y: 610 }), + textItem({ + text: 'More than Equal is a global high-performance motorsport programme.', + y: 590, + }), + textItem({ text: 'UNRSF – UN Road Safety Fund', y: 550 }), + textItem({ + text: 'Member of the Board of Advisors', + y: 530, + fontSize: 11.5, + }), + textItem({ text: 'December 2025 - Present (6 months)', y: 510 }), + textItem({ text: 'Geneva, Switzerland', y: 490 }), + ]); + + expect(result.warnings).toEqual([]); + expect(result.value).toEqual([ + expect.objectContaining({ + organization: 'More than Equal', + positions: [ + expect.objectContaining({ + duration: 'April 2026 - Present', + location: 'London Area, United Kingdom', + title: 'Senior Advisor to the CEO', + }), + ], + }), + expect.objectContaining({ + organization: 'UNRSF – UN Road Safety Fund', + positions: [ + expect.objectContaining({ + duration: 'December 2025 - Present', + location: 'Geneva, Switzerland', + title: 'Member of the Board of Advisors', + }), + ], + }), + ]); + }); + + test('keeps valid single-column experience lines left of the raw x fallback', () => { + const result = ExperienceStructuralParser.parseExperienceWithWarnings([ + textItem({ text: 'Decorative right rail', x: 220, y: 720 }), + textItem({ text: 'Experience', x: 90, y: 700, fontSize: 16 }), + textItem({ text: 'Women in AI', x: 90, y: 670 }), + textItem({ text: 'Advisor', x: 90, y: 650, fontSize: 11.5 }), + textItem({ text: 'January 2020 - Present (6 years)', x: 90, y: 630 }), + ]); + + expect(result.warnings).toEqual([]); + expect(result.value).toEqual([ + expect.objectContaining({ + organization: 'Women in AI', + positions: [ + expect.objectContaining({ + duration: 'January 2020 - Present', + title: 'Advisor', + }), + ], + }), + ]); + }); + + test('keeps short wrapped title continuations when a duration follows nearby', () => { + const result = ExperienceStructuralParser.parseExperienceWithWarnings([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Northstar Solutions', y: 670 }), + textItem({ text: 'Principal Architect', y: 650, fontSize: 11.5 }), + textItem({ text: 'AI', y: 630, fontSize: 11.5 }), + textItem({ text: 'Remote', y: 610 }), + textItem({ text: 'January 2020 - Present (6 years)', y: 590 }), + ]); + + expect(result.warnings).toEqual([]); + expect(result.value[0]?.positions[0]).toEqual( + expect.objectContaining({ + duration: 'January 2020 - Present', + location: 'Remote', + title: 'Principal Architect AI', + }) + ); + }); + + test('parses person-shaped organization names with canonical visual hierarchy', () => { + const items = [ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Hermes Argus', y: 670 }), + textItem({ text: 'Software Engineer', y: 650, fontSize: 11.5 }), + textItem({ text: '2020 - 2022', y: 630 }), + ]; + + const experiences = ExperienceStructuralParser.parseExperience(items); + + expect(experiences).toEqual([ + expect.objectContaining({ + organization: 'Hermes Argus', + positions: [ + expect.objectContaining({ + duration: '2020 - 2022', + title: 'Software Engineer', + }), + ], + }), + ]); + }); + + test('rejects person-shaped organization names without aligned header hierarchy', () => { + const items = [ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Hermes Argus', y: 670, fontSize: 10 }), + textItem({ + text: 'Software Engineer', + y: 650, + fontSize: 12, + x: 270, + }), + textItem({ text: '2020 - 2022', y: 630, fontSize: 12, x: 270 }), + ]; + + const experiences = ExperienceStructuralParser.parseExperience(items); + + expect(experiences).toEqual([]); + }); + + test('recognizes angel network names before title and duration without a location', () => { + const experiences = ExperienceStructuralParser.parseExperience([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Nordic Angels', y: 670 }), + textItem({ text: 'Investor', y: 650, fontSize: 11.5 }), + textItem({ + text: 'August 2024 - Present (1 year 10 months)', + y: 630, + }), + textItem({ + text: 'Recommended and accepted as a member and investor of the private social', + y: 610, + }), + textItem({ + text: 'investor network Nordic Angels. Nordic Angels is the largest angel investor', + y: 590, + }), + textItem({ + text: 'network in the Nordics and mobilizes business angels through a combination', + y: 570, + }), + textItem({ + text: 'of digital platforms and curated in-person events.', + y: 550, + }), + ]); + + expect(experiences).toEqual([ + expect.objectContaining({ + organization: 'Nordic Angels', + positions: [ + expect.objectContaining({ + description: + 'Recommended and accepted as a member and investor of the private social investor network Nordic Angels. Nordic Angels is the largest angel investor network in the Nordics and mobilizes business angels through a combination of digital platforms and curated in-person events.', + duration: 'August 2024 - Present', + title: 'Investor', + }), + ], + }), + ]); + }); + + test('keeps page-footer noise and location inside canonical person-shaped blocks', () => { + const experiences = ExperienceStructuralParser.parseExperience([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Aster Vale', y: 670 }), + textItem({ text: 'Software Engineer', y: 650, fontSize: 11.5 }), + textItem({ text: 'Page 1 of 2', y: 635, fontSize: 9 }), + textItem({ text: 'January 2020 - Present', y: 620, fontSize: 10.5 }), + textItem({ text: 'Austin, TX', y: 600, fontSize: 10.5 }), + textItem({ text: 'Built durable workflow tools.', y: 580 }), + ]); + + expect(experiences).toEqual([ + expect.objectContaining({ + organization: 'Aster Vale', + positions: [ + expect.objectContaining({ + description: 'Built durable workflow tools.', + duration: 'January 2020 - Present', + location: 'Austin, TX', + title: 'Software Engineer', + }), + ], + }), + ]); + }); + + test('scores organization mentions after page-footer noise in canonical headers', () => { + const experiences = ExperienceStructuralParser.parseExperience([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Aster Vale', y: 670 }), + textItem({ text: 'Software Engineer', y: 650, fontSize: 11.5 }), + textItem({ text: 'January 2020 - Present', y: 630 }), + textItem({ text: 'Page 1 of 2', y: 615, fontSize: 9 }), + textItem({ + text: 'Aster Vale builds planning software.', + y: 600, + }), + ]); + + expect(experiences).toEqual([ + expect.objectContaining({ + organization: 'Aster Vale', + positions: [ + expect.objectContaining({ + description: 'Aster Vale builds planning software.', + duration: 'January 2020 - Present', + title: 'Software Engineer', + }), + ], + }), + ]); + }); + + test('recognizes person-shaped multi-position organizations with total duration', () => { + const experiences = ExperienceStructuralParser.parseExperience([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Blue River', y: 670 }), + textItem({ text: '3 years', y: 650, fontSize: 10.5 }), + textItem({ text: 'Principal Engineer', y: 630, fontSize: 11.5 }), + textItem({ text: 'January 2022 - Present', y: 610, fontSize: 10.5 }), + textItem({ text: 'Senior Engineer', y: 580, fontSize: 11.5 }), + textItem({ text: 'January 2021 - January 2022', y: 560, fontSize: 10.5 }), + ]); + + expect(experiences).toEqual([ + expect.objectContaining({ + organization: 'Blue River', + positions: [ + expect.objectContaining({ + duration: 'January 2022 - Present', + title: 'Principal Engineer', + }), + expect.objectContaining({ + duration: 'January 2021 - January 2022', + title: 'Senior Engineer', + }), + ], + totalDuration: '3 years', + }), + ]); + }); + + test('starts a new visual organization for person-shaped brand names after descriptions', () => { + const items = [ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Northstar Solutions', y: 670 }), + textItem({ text: 'Staff Engineer', y: 650, fontSize: 11.5 }), + textItem({ text: '2021 - 2024', y: 630 }), + textItem({ text: 'Built internal systems.', y: 610 }), + textItem({ text: 'Boba Joy', y: 580 }), + textItem({ text: 'Investor & Advisor', y: 560, fontSize: 11.5 }), + textItem({ text: 'November 2024 - Present', y: 540 }), + ]; + + const experiences = ExperienceStructuralParser.parseExperience(items); + + expect(experiences).toEqual([ + expect.objectContaining({ + organization: 'Northstar Solutions', + }), + expect.objectContaining({ + organization: 'Boba Joy', + positions: [ + expect.objectContaining({ + duration: 'November 2024 - Present', + title: 'Investor & Advisor', + }), + ], + }), + ]); + }); + + test('recognizes short LinkedIn title vocabulary without turning titles into organizations', () => { + const items = [ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'VDOSH', y: 670 }), + textItem({ text: 'Managing Partner', y: 650, fontSize: 11.5 }), + textItem({ text: 'April 2016 - Present', y: 630 }), + textItem({ text: 'Greater Los Angeles Area', y: 610 }), + textItem({ text: 'Alchemist Accelerator', y: 570 }), + textItem({ text: 'Mentor', y: 550, fontSize: 11.5 }), + textItem({ text: 'January 2018 - Present', y: 530 }), + textItem({ text: 'Accenture', y: 490 }), + textItem({ + text: 'Business & Technology Executive', + y: 470, + fontSize: 11.5, + }), + textItem({ text: 'March 2015 - January 2022', y: 450 }), + textItem({ text: 'Zones', y: 410 }), + textItem({ text: 'Sr. Web Programmer', y: 390, fontSize: 11.5 }), + textItem({ text: 'August 2000 - September 2001', y: 370 }), + textItem({ text: 'MOSUM Technology Pvt Ltd', y: 330 }), + textItem({ text: 'Programmer', y: 310, fontSize: 11.5 }), + textItem({ text: 'September 1998 - January 1999', y: 290 }), + ]; + + const experiences = ExperienceStructuralParser.parseExperience(items); + + expect(experiences).toEqual([ + expect.objectContaining({ + organization: 'VDOSH', + positions: [ + expect.objectContaining({ + duration: 'April 2016 - Present', + location: 'Greater Los Angeles Area', + title: 'Managing Partner', + }), + ], + }), + expect.objectContaining({ + organization: 'Alchemist Accelerator', + positions: [ + expect.objectContaining({ + duration: 'January 2018 - Present', + title: 'Mentor', + }), + ], + }), + expect.objectContaining({ + organization: 'Accenture', + positions: [ + expect.objectContaining({ + duration: 'March 2015 - January 2022', + title: 'Business & Technology Executive', + }), + ], + }), + expect.objectContaining({ + organization: 'Zones', + positions: [ + expect.objectContaining({ + duration: 'August 2000 - September 2001', + title: 'Sr. Web Programmer', + }), + ], + }), + expect.objectContaining({ + organization: 'MOSUM Technology Pvt Ltd', + positions: [ + expect.objectContaining({ + duration: 'September 1998 - January 1999', + title: 'Programmer', + }), + ], + }), + ]); + }); + + test('keeps page-break role details with the organization that started before the footer', () => { + const items = [ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Microsoft', y: 670 }), + textItem({ + text: 'Software Design Engineer in Test', + y: 650, + fontSize: 11.5, + }), + textItem({ text: 'October 2002 - November 2008', y: 630 }), + textItem({ text: 'Redmond, WA', y: 610 }), + textItem({ text: '- Office Communication Server', y: 590 }), + textItem({ text: '- Microsoft VISIO', y: 570 }), + textItem({ text: 'Innosoft, Inc', y: 530 }), + textItem({ + text: 'Sr. Programmer / Project Lead', + y: 510, + fontSize: 11.5, + }), + textItem({ text: 'Page 2 of 3', y: 490, fontSize: 9 }), + textItem({ text: 'November 2001 - October 2002', y: -9300 }), + ]; + + const experiences = ExperienceStructuralParser.parseExperience(items); + + expect(experiences).toEqual([ + expect.objectContaining({ + organization: 'Microsoft', + positions: [ + expect.objectContaining({ + description: '- Office Communication Server - Microsoft VISIO', + location: 'Redmond, WA', + title: 'Software Design Engineer in Test', + }), + ], + }), + expect.objectContaining({ + organization: 'Innosoft, Inc', + positions: [ + expect.objectContaining({ + duration: 'November 2001 - October 2002', + title: 'Sr. Programmer / Project Lead', + }), + ], + }), + ]); + }); + + test('starts a new same-organization role when a page footer splits title and dates', () => { + const [experience] = ExperienceStructuralParser.parseExperience([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Carta', y: 670 }), + textItem({ text: 'Tech Lead Manager', y: 650, fontSize: 11.5 }), + textItem({ text: 'July 2019 - October 2021', y: 630 }), + textItem({ text: 'Palo Alto, CA', y: 610 }), + textItem({ + text: 'Provided technical leadership and mentored engineers.', + y: 590, + }), + textItem({ + text: 'Senior Software Engineer', + y: 560, + fontSize: 11.5, + }), + textItem({ text: 'Page 2 of 7', y: 540, fontSize: 9 }), + textItem({ text: 'October 2017 - June 2019', y: -9300 }), + textItem({ text: 'Rio de Janeiro', y: -9320 }), + ]); + + expect(experience).toEqual( + expect.objectContaining({ + organization: 'Carta', + positions: [ + expect.objectContaining({ + description: + 'Provided technical leadership and mentored engineers.', + title: 'Tech Lead Manager', + }), + expect.objectContaining({ + duration: 'October 2017 - June 2019', + location: 'Rio de Janeiro', + title: 'Senior Software Engineer', + }), + ], + }) + ); + }); + + test('detects generic organizations without a source allowlist', () => { + const items = [ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Northstar Solutions', y: 670 }), + textItem({ text: 'Staff Platform Engineer', y: 650, fontSize: 11.5 }), + textItem({ text: '2021 - 2024', y: 630 }), + ]; + + const [experience] = ExperienceStructuralParser.parseExperience(items); + + expect(experience.organization).toBe('Northstar Solutions'); + expect(experience.positions[0]).toEqual( + expect.objectContaining({ + title: 'Staff Platform Engineer', + duration: '2021 - 2024', + }) + ); + }); + + test('ignores page footers between role details', () => { + const items = [ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Northstar Solutions', y: 670 }), + textItem({ text: 'Staff Platform Engineer', y: 650, fontSize: 11.5 }), + textItem({ text: '2021 - 2024', y: 630 }), + textItem({ text: 'Page 1 of 2', y: 620 }), + textItem({ text: 'Austin, TX', y: 610 }), + ]; + + const [experience] = ExperienceStructuralParser.parseExperience(items); + + expect(experience.organization).toBe('Northstar Solutions'); + expect(experience.positions[0]).toEqual( + expect.objectContaining({ + duration: '2021 - 2024', + location: 'Austin, TX', + title: 'Staff Platform Engineer', + }) + ); + }); + + test('keeps organization suffix terms when cleaning names', () => { + const items = [ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Research Systems Group', y: 670 }), + textItem({ text: 'Principal Engineer', y: 650, fontSize: 11.5 }), + textItem({ text: 'January 2020 - March 2024', y: 630 }), + ]; + + const [experience] = ExperienceStructuralParser.parseExperience(items); + + expect(experience.organization).toBe('Research Systems Group'); + }); + + test('keeps noisy embedded date lines in descriptions', () => { + const items = [ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Research Systems Group', y: 670 }), + textItem({ text: 'Researcher', y: 650, fontSize: 11.5 }), + textItem({ text: 'Provided support from 2019 - 2021', y: 630 }), + ]; + + const [experience] = ExperienceStructuralParser.parseExperience(items); + + expect(experience.positions[0]).toEqual( + expect.objectContaining({ + description: 'Provided support from 2019 - 2021', + duration: '', + }) + ); + }); + + test('uses localized section headers and accented organization names', () => { + const items = [ + textItem({ text: 'Experiência', y: 700, fontSize: 16 }), + textItem({ text: 'Ação Labs', y: 670 }), + textItem({ text: 'Engenheiro de Software', y: 650, fontSize: 11.5 }), + textItem({ text: 'janeiro de 2020 - março de 2024', y: 630 }), + textItem({ text: 'Formação', y: 500, fontSize: 16 }), + textItem({ text: 'Universidade Exemplo', y: 480 }), + ]; + + const [experience] = ExperienceStructuralParser.parseExperience(items); + + expect(experience.organization).toBe('Ação Labs'); + expect(experience.positions[0]).toEqual( + expect.objectContaining({ + title: 'Engenheiro de Software', + duration: 'janeiro de 2020 - março de 2024', + }) + ); + }); + + test('preserves current experience when an organization section cannot be cleaned', () => { + const sections: StructuralSection[] = [ + structuralSection({ + text: 'Research Systems Group', + type: 'organization', + }), + structuralSection({ + text: 'Principal Engineer', + type: 'position', + }), + structuralSection({ + text: '2020 - 2024', + type: 'duration', + }), + structuralSection({ + text: 'Austin, TX', + type: 'organization', + }), + structuralSection({ + text: 'Kept platform work moving.', + type: 'description', + }), + ]; + + const [experience] = + ExperienceStructuralParser['buildWorkExperiences'](sections); + + expect(experience).toEqual({ + organization: 'Research Systems Group', + positions: [ + { + description: 'Austin, TX Kept platform work moving.', + duration: '2020 - 2024', + dates: { + originalText: '2020 - 2024', + start: { + iso: '2020', + precision: 'year', + text: '2020', + }, + end: { + iso: '2024', + precision: 'year', + text: '2024', + }, + kind: 'completed', + }, + title: 'Principal Engineer', + }, + ], + totalDuration: undefined, + }); + }); + + test('compacts spaced state abbreviations in locations', () => { + const items = [ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Research Systems Group', y: 670 }), + textItem({ text: 'Principal Engineer', y: 650, fontSize: 11.5 }), + textItem({ text: '2020 - 2024', y: 630 }), + textItem({ text: 'New Y ork, N Y', y: 610 }), + ]; + + const [experience] = ExperienceStructuralParser.parseExperience(items); + + expect(experience.positions[0]).toEqual( + expect.objectContaining({ + location: 'New York, NY', + }) + ); + }); + + test('keeps split address locations before contractor descriptions', () => { + const items = [ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'CEPEL', y: 670 }), + textItem({ + text: 'Technical Researcher – Automation and Robotics (Contractor)', + y: 650, + fontSize: 11.5, + }), + textItem({ text: 'December 2006 - April 2010', y: 630 }), + textItem({ + text: 'Av. Horácio Macedo, 354 - Cidade Universitária - Rio de Janeiro - RJ,', + y: 610, + }), + textItem({ text: '21941-911', y: 595 }), + textItem({ + text: 'Worked as a Researcher in renewable energy projects.', + y: 570, + }), + ]; + + const [experience] = ExperienceStructuralParser.parseExperience(items); + + expect(experience).toEqual( + expect.objectContaining({ + organization: 'CEPEL', + positions: [ + expect.objectContaining({ + description: 'Worked as a Researcher in renewable energy projects.', + duration: 'December 2006 - April 2010', + location: + 'Av. Horácio Macedo, 354 - Cidade Universitária - Rio de Janeiro - RJ, 21941-911', + title: + 'Technical Researcher – Automation and Robotics (Contractor)', + }), + ], + }) + ); + }); + + test('recognizes dotted address prefixes before spaces', () => { + const items = [ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Research Systems Group', y: 670 }), + textItem({ text: 'Principal Engineer', y: 650, fontSize: 11.5 }), + textItem({ text: '2020 - 2024', y: 630 }), + textItem({ text: 'Rd. 10', y: 610 }), + ]; + + const [experience] = ExperienceStructuralParser.parseExperience(items); + + expect(experience.positions[0]).toEqual( + expect.objectContaining({ + location: 'Rd. 10', + }) + ); + }); + + test('normalizes the full joined split location', () => { + const sections: StructuralSection[] = [ + structuralSection({ + text: 'Research Systems Group', + type: 'organization', + }), + structuralSection({ + text: 'Principal Engineer', + type: 'position', + }), + structuralSection({ + text: '2020 - 2024', + type: 'duration', + }), + structuralSection({ + text: 'New Y', + type: 'location', + }), + structuralSection({ + text: 'ork, N Y', + type: 'location', + }), + ]; + + const [experience] = + ExperienceStructuralParser['buildWorkExperiences'](sections); + + expect(experience.positions[0]).toEqual( + expect.objectContaining({ + location: 'New York, NY', + }) + ); + }); + + test('keeps RQ client campaign lines in the dated role description', () => { + const result = ExperienceStructuralParser.parseExperienceWithWarnings([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'RQ', y: 670 }), + textItem({ text: 'Associate Director', y: 650, fontSize: 11.5 }), + textItem({ + text: 'September 2017 - June 2018 (10 months)', + y: 630, + }), + textItem({ + text: 'Los Angeles, California, United States', + y: 610, + }), + textItem({ text: 'Client: YouTube', y: 590 }), + textItem({ + text: 'Creative strategy, planning, and event activation for influencer campaigns for', + y: 570, + }), + textItem({ text: 'YouTube Originals + YouTube TV:', y: 550 }), + textItem({ + text: '+ YouTube x Getty Studio Sundance Photo Studio', + y: 530, + }), + textItem({ + text: '+ World Series Partner Programming 2018', + y: 510, + }), + textItem({ text: 'Account Supervisor', y: 480, fontSize: 11.5 }), + textItem({ + text: 'May 2015 - September 2017 (2 years 5 months)', + y: 460, + }), + ]); + + expect(result.warnings).toEqual([]); + expect(result.value).toEqual([ + expect.objectContaining({ + organization: 'RQ', + positions: [ + expect.objectContaining({ + description: + 'Client: YouTube Creative strategy, planning, and event activation for influencer campaigns for YouTube Originals + YouTube TV: + YouTube x Getty Studio Sundance Photo Studio + World Series Partner Programming 2018', + duration: 'September 2017 - June 2018', + title: 'Associate Director', + }), + expect.objectContaining({ + duration: 'May 2015 - September 2017', + title: 'Account Supervisor', + }), + ], + }), + ]); + }); + + test('keeps no-date Future US prose as description and starts the next organization', () => { + const result = ExperienceStructuralParser.parseExperienceWithWarnings([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'The Future US', y: 670 }), + textItem({ + text: 'Member of the Board of Advisors', + y: 650, + fontSize: 11.5, + }), + textItem({ + text: 'January 2023 - February 2025 (2 years 2 months)', + y: 630, + }), + textItem({ text: 'Washington DC-Baltimore Area', y: 610 }), + textItem({ + text: 'Non-profit organization and catalytic, post-partisan policy accelerator.', + y: 590, + }), + textItem({ text: 'Venture Partner', y: 560, fontSize: 11.5 }), + textItem({ + text: 'March 2023 - March 2024 (1 year 1 month)', + y: 540, + }), + textItem({ text: 'New York City Metropolitan Area', y: 520 }), + textItem({ + text: "Seed Stage Venture Capital Program hosted by Canada's Trade", + y: 500, + }), + textItem({ text: 'Commissioner Service.', y: 480 }), + textItem({ text: 'SurveyMonkey', y: 450 }), + textItem({ + text: 'Strategic Finance & Business Operation Lead', + y: 430, + fontSize: 11.5, + }), + textItem({ text: '2020 - 2021 (1 year)', y: 410 }), + ]); + + expect(result.warnings).toEqual([]); + expect(result.value).toEqual([ + expect.objectContaining({ + organization: 'The Future US', + positions: [ + expect.objectContaining({ + title: 'Member of the Board of Advisors', + }), + expect.objectContaining({ + description: + "Seed Stage Venture Capital Program hosted by Canada's Trade Commissioner Service.", + duration: 'March 2023 - March 2024', + title: 'Venture Partner', + }), + ], + }), + expect.objectContaining({ + organization: 'SurveyMonkey', + positions: [ + expect.objectContaining({ + duration: '2020 - 2021', + title: 'Strategic Finance & Business Operation Lead', + }), + ], + }), + ]); + }); + + test('does not let J.P. Morgan description text swallow Goldman Sachs', () => { + const result = ExperienceStructuralParser.parseExperienceWithWarnings([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'J.P. Morgan', y: 670 }), + textItem({ text: 'Investment Banking', y: 650, fontSize: 11.5 }), + textItem({ text: '2015 - 2016 (1 year)', y: 630 }), + textItem({ text: 'New York, New York, United States', y: 610 }), + textItem({ text: 'Mergers & Acquisitions Group (M&A)', y: 590 }), + textItem({ text: 'Goldman Sachs', y: 560 }), + textItem({ text: 'Investment Banking', y: 540, fontSize: 11.5 }), + textItem({ text: '2014 - 2015 (1 year)', y: 520 }), + textItem({ text: 'New York, New York, United States', y: 500 }), + textItem({ text: 'Financial Institutions Group (FIG)', y: 480 }), + ]); + + expect(result.warnings).toEqual([]); + expect(result.value).toEqual([ + expect.objectContaining({ + organization: 'J.P. Morgan', + positions: [ + expect.objectContaining({ + description: 'Mergers & Acquisitions Group (M&A)', + duration: '2015 - 2016', + title: 'Investment Banking', + }), + ], + }), + expect.objectContaining({ + organization: 'Goldman Sachs', + positions: [ + expect.objectContaining({ + description: 'Financial Institutions Group (FIG)', + duration: '2014 - 2015', + title: 'Investment Banking', + }), + ], + }), + ]); + }); + + test('keeps CAA founder detail as Creative Artists Agency description', () => { + const result = ExperienceStructuralParser.parseExperienceWithWarnings([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Creative Artists Agency', y: 670 }), + textItem({ + text: 'Member of Management Committee / Senior Talent Agent', + y: 650, + fontSize: 11.5, + }), + textItem({ text: '1986 - 1995 (9 years)', y: 630 }), + textItem({ + text: 'Founder CAA Corporate Advisory Group in 1987.', + y: 610, + }), + textItem({ text: 'MGM', y: 580 }), + textItem({ + text: 'Executive Positions in Productions and Distribution', + y: 560, + fontSize: 11.5, + }), + textItem({ text: '1979 - 1985 (6 years)', y: 540 }), + ]); + + expect(result.warnings).toEqual([]); + expect(result.value).toEqual([ + expect.objectContaining({ + organization: 'Creative Artists Agency', + positions: [ + expect.objectContaining({ + description: 'Founder CAA Corporate Advisory Group in 1987.', + duration: '1986 - 1995', + title: 'Member of Management Committee / Senior Talent Agent', + }), + ], + }), + expect.objectContaining({ + organization: 'MGM', + }), + ]); + }); + + test('keeps Helios Phaethon description continuations under their dated roles', () => { + const result = ExperienceStructuralParser.parseExperienceWithWarnings([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Rotary International', y: 670 }), + textItem({ text: 'Angel Investor', y: 650, fontSize: 11.5 }), + textItem({ + text: 'January 2014 - January 2024 (10 years 1 month)', + y: 630, + }), + textItem({ + text: 'Invest in early stage companies in B2B SaaS, Digital Health, Cybersecurity,', + y: 610, + }), + textItem({ + text: 'Diagnostics, Medical Device, IoT, Future of Work, HRtech, Adtech, AR/VR,', + y: 590, + }), + textItem({ + text: 'Mental Health. Also invest as Limited Partner in Emerging Funds in the US.', + y: 570, + }), + textItem({ text: '500 Global', y: 540 }), + textItem({ text: 'Mentor', y: 520, fontSize: 11.5 }), + textItem({ + text: 'December 2021 - November 2023 (2 years)', + y: 500, + }), + textItem({ text: 'GBSS Group', y: 460 }), + textItem({ + text: 'Co-Founder & CEO (Business Units Acquired Separately: 2006, 2010, 2013)', + y: 440, + fontSize: 11.5, + }), + textItem({ + text: 'November 1999 - June 2013 (13 years 8 months)', + y: 420, + }), + textItem({ text: 'San Diego, California, United States', y: 400 }), + textItem({ + text: 'A group of ecommerce and technology companies in the office supplies,', + y: 380, + }), + textItem({ + text: 'Successfully spin-off three business units that lead to three different exits.', + y: 360, + }), + textItem({ text: 'Interbank Turkiye', y: 330 }), + textItem({ + text: 'Product Manager / Strategic Planning Manager', + y: 310, + fontSize: 11.5, + }), + textItem({ + text: 'June 1996 - August 1998 (2 years 3 months)', + y: 290, + }), + ]); + + expect(result.warnings).toEqual([]); + expect(result.value).toEqual([ + expect.objectContaining({ + organization: 'Rotary International', + positions: [ + expect.objectContaining({ + description: + 'Invest in early stage companies in B2B SaaS, Digital Health, Cybersecurity, Diagnostics, Medical Device, IoT, Future of Work, HRtech, Adtech, AR/VR, Mental Health. Also invest as Limited Partner in Emerging Funds in the US.', + title: 'Angel Investor', + }), + ], + }), + expect.objectContaining({ + organization: '500 Global', + }), + expect.objectContaining({ + organization: 'GBSS Group', + positions: [ + expect.objectContaining({ + description: + 'A group of ecommerce and technology companies in the office supplies, Successfully spin-off three business units that lead to three different exits.', + title: + 'Co-Founder & CEO (Business Units Acquired Separately: 2006, 2010, 2013)', + }), + ], + }), + expect.objectContaining({ + organization: 'Interbank Turkiye', + }), + ]); + }); + + test('separates Andromeda Cassiopeia company boundaries and keeps prose out of locations', () => { + const result = ExperienceStructuralParser.parseExperienceWithWarnings([ + textItem({ text: 'Experience', y: 900, fontSize: 16 }), + textItem({ text: 'MUDE', y: 870 }), + textItem({ text: 'Founder', y: 850, fontSize: 11.5 }), + textItem({ text: 'April 2025 - Present (1 year 2 months)', y: 830 }), + textItem({ text: 'In stealth. More information coming soon.', y: 810 }), + textItem({ text: 'Velocity AI', y: 780 }), + textItem({ text: 'Strategic Advisor', y: 760, fontSize: 11.5 }), + textItem({ text: 'March 2026 - Present (3 months)', y: 740 }), + textItem({ + text: 'Velocity AI is building The Operating System of Human Performance™,', + y: 720, + }), + textItem({ text: 'HeadVantage Corporation', y: 690 }), + textItem({ text: 'Strategic Advisor', y: 670, fontSize: 11.5 }), + textItem({ text: 'March 2025 - Present (1 year 3 months)', y: 650 }), + textItem({ + text: 'HeadVantage puts fans inside the helmet, delivering live, first-person athlete', + y: 630, + }), + textItem({ + text: 'Comcast NBCUniversal SportsTech Accelerator, HeadVantage is redefining', + y: 610, + }), + textItem({ text: 'how the world experiences sport.', y: 590 }), + textItem({ text: 'Prescient', y: 560 }), + textItem({ text: 'Founding Partner + Advisor', y: 540, fontSize: 11.5 }), + textItem({ text: 'October 2018 - Present (7 years 8 months)', y: 520 }), + textItem({ + text: 'Prescient, formerly Vybn, is a decision science platform that unifies and', + y: 500, + }), + textItem({ + text: 'After securing an exclusive partnership with Warner Music, Prescient is now', + y: 480, + }), + textItem({ + text: 'focused on a variety of brands, accelerating LTV in a cookieless world.', + y: 460, + }), + textItem({ text: 'Rasgo', y: 430 }), + textItem({ + text: 'Chief of Staff + Head of Operations', + y: 410, + fontSize: 11.5, + }), + textItem({ + text: 'January 2020 - January 2023 (3 years 1 month)', + y: 390, + }), + textItem({ text: 'New York, United States', y: 370 }), + textItem({ + text: 'As the first partner to the two founders, I helped bring the vision to life', + y: 350, + }), + ]); + const byOrganization = new Map( + result.value.map(experience => [experience.organization, experience]) + ); + + expect(result.warnings).toEqual([]); + expect(byOrganization.get('MUDE')?.positions[0]?.location).toBeUndefined(); + expect(byOrganization.get('Velocity AI')?.positions[0]?.title).toBe( + 'Strategic Advisor' + ); + expect( + byOrganization.get('HeadVantage Corporation')?.positions[0]?.location + ).toBeUndefined(); + expect( + byOrganization.get('HeadVantage Corporation')?.positions[0]?.description + ).toContain('Comcast NBCUniversal SportsTech Accelerator'); + expect( + byOrganization.get('Prescient')?.positions[0]?.location + ).toBeUndefined(); + expect(byOrganization.get('Rasgo')?.positions[0]?.location).toBe( + 'New York, United States' + ); + expect(byOrganization.get('Rasgo')?.positions[0]?.description).toContain( + 'As the first partner' + ); + }); + + test('keeps Helios Phaethon wrapped titles and Cross Ocean boundaries intact', () => { + const result = ExperienceStructuralParser.parseExperienceWithWarnings([ + textItem({ text: 'Experience', y: 900, fontSize: 16 }), + textItem({ text: 'Cross Ocean Ventures', y: 870 }), + textItem({ text: 'Co-Founder General Partner', y: 850, fontSize: 11.5 }), + textItem({ text: 'April 2021 - Present (5 years 2 months)', y: 830 }), + textItem({ text: 'San Diego Metropolitan Area', y: 810 }), + textItem({ + text: 'The leading go to early-stage investor of choice for ambitious high growth', + y: 790, + }), + textItem({ text: 'Breakaway Partners OU', y: 760 }), + textItem({ text: 'Co-Founder & Partner', y: 740, fontSize: 11.5 }), + textItem({ text: '2021 - Present (5 years)', y: 720 }), + textItem({ text: 'Tallinn, Harjumaa, Estonia', y: 700 }), + textItem({ text: 'GBSS Group', y: 670 }), + textItem({ + text: 'Co-Founder & CEO (Business Units Acquired Separately: 2006, 2010,', + y: 650, + fontSize: 11.5, + }), + textItem({ text: '2013)', y: 635, fontSize: 11.5 }), + textItem({ + text: 'November 1999 - June 2013 (13 years 8 months)', + y: 615, + }), + ]); + const byOrganization = new Map( + result.value.map(experience => [experience.organization, experience]) + ); + + expect(result.warnings).toEqual([]); + expect(byOrganization.get('Cross Ocean Ventures')?.positions).toEqual([ + expect.objectContaining({ + location: 'San Diego Metropolitan Area', + title: 'Co-Founder General Partner', + }), + ]); + expect(byOrganization.get('Breakaway Partners OU')?.positions).toEqual([ + expect.objectContaining({ + location: 'Tallinn, Harjumaa, Estonia', + title: 'Co-Founder & Partner', + }), + ]); + expect(byOrganization.get('GBSS Group')?.positions[0]?.title).toBe( + 'Co-Founder & CEO (Business Units Acquired Separately: 2006, 2010, 2013)' + ); + }); + + test('keeps Orion Lycaon prose dates and wrapped Brown organization names', () => { + const result = ExperienceStructuralParser.parseExperienceWithWarnings([ + textItem({ text: 'Experience', y: 900, fontSize: 16 }), + textItem({ text: 'Resilient Connections', y: 870 }), + textItem({ + text: 'Founding Executive Director', + y: 850, + fontSize: 11.5, + }), + textItem({ text: '2020 - 2020 (less than a year)', y: 830 }), + textItem({ + text: 'Resilient Connections was a pop-up non-profit that launched in March 2020 to', + y: 810, + }), + textItem({ + text: 'coordinate high potential impact grass roots COVID-19 response projects that', + y: 790, + }), + textItem({ + text: 'Clinical and Affective Neuroscience Laboratory (CLANlab) at Brown', + y: 760, + }), + textItem({ text: 'University', y: 742 }), + textItem({ text: 'Research Assistant', y: 720, fontSize: 11.5 }), + textItem({ text: '2008 - 2010 (2 years)', y: 700 }), + textItem({ + text: 'Harvard John A. Paulson School of Engineering and Applied', + y: 670, + }), + textItem({ text: 'Sciences', y: 652 }), + textItem({ + text: 'Applied Physics Teaching Fellow', + y: 630, + fontSize: 11.5, + }), + textItem({ + text: 'January 2019 - December 2019 (1 year)', + y: 610, + }), + textItem({ + text: 'University de', + y: 580, + }), + textItem({ text: 'Paris', y: 562 }), + textItem({ + text: 'Research Fellow', + y: 540, + fontSize: 11.5, + }), + textItem({ + text: 'September 2011 - June 2012 (10 months)', + y: 520, + }), + ]); + const byOrganization = new Map( + result.value.map(experience => [experience.organization, experience]) + ); + + expect(result.warnings).toEqual([]); + expect(byOrganization.get('Resilient Connections')?.positions[0]).toEqual( + expect.objectContaining({ + description: expect.stringContaining('launched in March 2020 to'), + duration: '2020 - 2020', + }) + ); + expect( + byOrganization.get( + 'Clinical and Affective Neuroscience Laboratory (CLANlab) at Brown University' + )?.positions[0]?.title + ).toBe('Research Assistant'); + expect( + byOrganization.get( + 'Harvard John A. Paulson School of Engineering and Applied Sciences' + )?.positions[0]?.title + ).toBe('Applied Physics Teaching Fellow'); + expect(byOrganization.get('University de Paris')?.positions[0]?.title).toBe( + 'Research Fellow' + ); + expect(byOrganization.has('University')).toBe(false); + expect(byOrganization.has('Sciences')).toBe(false); + expect(byOrganization.has('Paris')).toBe(false); + }); + + test('keeps secondary reference companies and page-break prose out of locations', () => { + const result = ExperienceStructuralParser.parseExperienceWithWarnings([ + textItem({ text: 'Experience', y: 1100, fontSize: 16 }), + textItem({ text: 'WeGive', y: 1070 }), + textItem({ text: 'Board Member', y: 1050, fontSize: 11.5 }), + textItem({ text: 'January 2024 - Present (2 years 5 months)', y: 1030 }), + textItem({ text: 'Mission Control AI', y: 1000 }), + textItem({ text: 'Board Member', y: 980, fontSize: 11.5 }), + textItem({ text: 'March 2022 - Present (4 years 3 months)', y: 960 }), + textItem({ text: 'Visual Machines Group', y: 930 }), + textItem({ text: 'Leader', y: 910, fontSize: 11.5 }), + textItem({ text: 'July 2018 - Present (7 years 11 months)', y: 890 }), + textItem({ text: 'Los Angeles CA', y: 870 }), + textItem({ text: 'Spatial AI', y: 850 }), + textItem({ text: 'Vayu Robotics', y: 820 }), + textItem({ text: 'Co-Founder (Acquired)', y: 800, fontSize: 11.5 }), + textItem({ + text: 'October 2021 - August 2025 (3 years 11 months)', + y: 780, + }), + textItem({ text: 'Alerian', y: 750 }), + textItem({ text: '9 years 5 months', y: 730 }), + textItem({ text: 'Director of Data Science', y: 710, fontSize: 11.5 }), + textItem({ text: 'January 2013 - December 2020 (8 years)', y: 690 }), + textItem({ text: 'Dallas, Texas', y: 670 }), + textItem({ + text: 'Over nearly a decade, I designed benchmarks and indices from concept', + y: 650, + }), + ]); + const byOrganization = new Map( + result.value.map(experience => [experience.organization, experience]) + ); + + expect(result.warnings).toEqual([]); + expect(byOrganization.get('WeGive')?.positions).toHaveLength(1); + expect(byOrganization.get('Mission Control AI')?.positions[0]?.title).toBe( + 'Board Member' + ); + expect(byOrganization.get('Visual Machines Group')?.positions[0]).toEqual( + expect.objectContaining({ + location: 'Los Angeles CA', + }) + ); + expect( + byOrganization.get('Visual Machines Group')?.positions[0]?.location + ).not.toContain('Spatial AI'); + expect(byOrganization.has('Spatial AI')).toBe(false); + expect(byOrganization.get('Vayu Robotics')?.positions[0]?.title).toBe( + 'Co-Founder (Acquired)' + ); + expect(byOrganization.get('Alerian')?.positions[0]).toEqual( + expect.objectContaining({ + description: expect.stringContaining('Over nearly a decade'), + location: 'Dallas, Texas', + }) + ); + }); + + test('preserves description labels used by investor and corporate-development roles', () => { + const result = ExperienceStructuralParser.parseExperienceWithWarnings([ + textItem({ text: 'Experience', y: 900, fontSize: 16 }), + textItem({ text: 'Global Ventures', y: 870 }), + textItem({ text: 'VC Investor', y: 850, fontSize: 11.5 }), + textItem({ text: 'June 2023 - November 2023 (6 months)', y: 830 }), + textItem({ text: 'IC Deal: Fuse', y: 810 }), + textItem({ text: 'Collide Capital', y: 780 }), + textItem({ + text: 'VC Investor | Venture Fellow', + y: 760, + fontSize: 11.5, + }), + textItem({ text: 'January 2023 - May 2023 (5 months)', y: 740 }), + textItem({ text: 'Sourced Investment: Coldcart', y: 720 }), + textItem({ text: 'Cinedigm', y: 690 }), + textItem({ + text: 'VP, Corporate Development and Strategy', + y: 670, + fontSize: 11.5, + }), + textItem({ + text: 'March 2012 - September 2016 (4 years 7 months)', + y: 650, + }), + textItem({ text: 'Achievements:', y: 630 }), + textItem({ + text: 'Oversaw strategic and business planning of video app new business.', + y: 610, + }), + ]); + const byOrganization = new Map( + result.value.map(experience => [experience.organization, experience]) + ); + + expect(result.warnings).toEqual([]); + expect( + byOrganization.get('Global Ventures')?.positions[0]?.description + ).toBe('IC Deal: Fuse'); + expect( + byOrganization.get('Collide Capital')?.positions[0]?.description + ).toBe('Sourced Investment: Coldcart'); + expect(byOrganization.get('Cinedigm')?.positions[0]?.description).toBe( + 'Achievements: Oversaw strategic and business planning of video app new business.' + ); + }); + + test('splits Medea Colchis combined organization-title rows and keeps Bosch prose', () => { + const result = ExperienceStructuralParser.parseExperienceWithWarnings([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Stealth Company', y: 670 }), + textItem({ text: 'Managing Partner', y: 650, fontSize: 11.5 }), + textItem({ text: 'December 2024 - Present (1 year 6 months)', y: 630 }), + textItem({ text: 'United States', y: 610 }), + textItem({ text: 'Bookbinders Design', y: 580 }), + textItem({ + text: 'Sales & Merchandising Manager (Fixed-term consulting)', + y: 560, + fontSize: 11.5, + }), + textItem({ text: 'May 2018 - June 2018 (2 months)', y: 540 }), + textItem({ + text: 'Requested to provide training and process improvement.', + y: 520, + }), + textItem({ text: 'Robert Bosch GmbH Business Controller', y: 490 }), + textItem({ + text: 'December 2012 - August 2017 (4 years 9 months)', + y: 470, + }), + textItem({ text: 'Yongin, Gyeonggi-do, Korea', y: 450 }), + textItem({ + text: 'Stimulated organizational change and summarized best practice articles for', + y: 430, + }), + textItem({ text: 'Bosch intranet homepage', y: 410 }), + textItem({ text: 'Hyundai Kefico Corporation', y: 380 }), + textItem({ text: 'Business Controller', y: 360, fontSize: 11.5 }), + textItem({ + text: 'January 2012 - November 2012 (11 months)', + y: 340, + }), + ]); + + expect(result.warnings).toEqual([]); + expect(result.value).toEqual([ + expect.objectContaining({ + organization: 'Stealth Company', + positions: [ + expect.objectContaining({ + duration: 'December 2024 - Present', + title: 'Managing Partner', + }), + ], + }), + expect.objectContaining({ + organization: 'Bookbinders Design', + positions: [ + expect.objectContaining({ + duration: 'May 2018 - June 2018', + title: 'Sales & Merchandising Manager (Fixed-term consulting)', + }), + ], + }), + expect.objectContaining({ + organization: 'Robert Bosch GmbH', + positions: [ + expect.objectContaining({ + description: + 'Stimulated organizational change and summarized best practice articles for Bosch intranet homepage', + duration: 'December 2012 - August 2017', + title: 'Business Controller', + }), + ], + }), + expect.objectContaining({ + organization: 'Hyundai Kefico Corporation', + }), + ]); + }); + + test('splits combined organization-title rows with lowercase and mixed-case suffixes', () => { + const result = ExperienceStructuralParser.parseExperienceWithWarnings([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Robert Bosch GmbH. Business Controller', y: 670 }), + textItem({ + text: 'December 2012 - August 2017 (4 years 9 months)', + y: 650, + }), + textItem({ text: 'Acme llC Principal Consultant', y: 620 }), + textItem({ text: 'January 2021 - Present (3 years 5 months)', y: 600 }), + textItem({ text: 'Northstar ltd Staff Engineer', y: 570 }), + textItem({ text: '2020 - 2022 (2 years)', y: 550 }), + ]); + + expect(result.warnings).toEqual([]); + expect(result.value).toEqual([ + expect.objectContaining({ + organization: 'Robert Bosch GmbH.', + positions: [ + expect.objectContaining({ + duration: 'December 2012 - August 2017', + title: 'Business Controller', + }), + ], + }), + expect.objectContaining({ + organization: 'Acme llC', + positions: [ + expect.objectContaining({ + duration: 'January 2021 - Present', + title: 'Principal Consultant', + }), + ], + }), + expect.objectContaining({ + organization: 'Northstar ltd', + positions: [ + expect.objectContaining({ + duration: '2020 - 2022', + title: 'Staff Engineer', + }), + ], + }), + ]); + }); + + test('does not split organization suffix-only rows into fake roles', () => { + const result = ExperienceStructuralParser.parseExperienceWithWarnings([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'My Company LLC', y: 670 }), + textItem({ text: 'January 2021 - Present (3 years 5 months)', y: 650 }), + textItem({ text: 'Robert Bosch GmbH Inc', y: 620 }), + textItem({ + text: 'February 2019 - December 2020 (1 year 11 months)', + y: 600, + }), + textItem({ text: 'Bosch Company GmbH.', y: 590 }), + textItem({ + text: 'February 2018 - December 2018 (11 months)', + y: 580, + }), + textItem({ text: 'Acme Agency Principal Consultant', y: 570 }), + textItem({ text: 'January 2015 - January 2018 (3 years)', y: 550 }), + ]); + + const parsedOrganizationTitles = result.value.flatMap(experience => + experience.positions.map(position => ({ + organization: experience.organization, + title: position.title, + })) + ); + + expect(result.warnings).toEqual([]); + expect(parsedOrganizationTitles).toEqual([ + { + organization: 'Acme Agency', + title: 'Principal Consultant', + }, + ]); + expect(parsedOrganizationTitles).not.toContainEqual({ + organization: 'My Company', + title: 'LLC', + }); + expect(parsedOrganizationTitles).not.toContainEqual({ + organization: 'Robert Bosch GmbH', + title: 'Inc', + }); + expect(parsedOrganizationTitles).not.toContainEqual({ + organization: 'Bosch Company', + title: 'GmbH.', + }); + }); + + test('recognizes dotted GmbH organization boundary rows', () => { + const result = ExperienceStructuralParser.parseExperienceWithWarnings([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Bosch Company GmbH.', y: 670 }), + textItem({ text: 'Business Controller', y: 650, fontSize: 11.5 }), + textItem({ + text: 'February 2018 - December 2018 (11 months)', + y: 630, + }), + textItem({ text: 'Acme Holding, GmbH.', y: 590 }), + textItem({ text: 'Principal Consultant', y: 570, fontSize: 11.5 }), + textItem({ text: 'January 2015 - January 2018 (3 years)', y: 550 }), + ]); + + expect(result.warnings).toEqual([]); + expect(result.value).toEqual([ + expect.objectContaining({ + organization: 'Bosch Company GmbH.', + positions: [ + expect.objectContaining({ + duration: 'February 2018 - December 2018', + title: 'Business Controller', + }), + ], + }), + expect.objectContaining({ + organization: 'Acme Holding, GmbH.', + positions: [ + expect.objectContaining({ + duration: 'January 2015 - January 2018', + title: 'Principal Consultant', + }), + ], + }), + ]); + }); + + test('keeps Palo Alto as a location instead of a no-date First Republic role', () => { + const result = ExperienceStructuralParser.parseExperienceWithWarnings([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'First Republic Bank', y: 670 }), + textItem({ text: '12 years 1 month', y: 650 }), + textItem({ + text: 'Deputy Regional Managing Director', + y: 630, + fontSize: 11.5, + }), + textItem({ + text: 'April 2017 - June 2023 (6 years 3 months)', + y: 610, + }), + textItem({ + text: 'Senior Managing Director', + y: 580, + fontSize: 11.5, + }), + textItem({ + text: 'February 2017 - June 2023 (6 years 5 months)', + y: 560, + }), + textItem({ text: 'Palo Alto, California', y: 540 }), + textItem({ text: 'Managing Director', y: 510, fontSize: 11.5 }), + textItem({ + text: 'June 2011 - February 2017 (5 years 9 months)', + y: 490, + }), + textItem({ text: 'Palo Alto', y: 470 }), + textItem({ text: 'HSBC', y: 440 }), + textItem({ text: '6 years', y: 420 }), + textItem({ + text: 'Vice President- Bay Area', + y: 400, + fontSize: 11.5, + }), + textItem({ + text: 'August 2008 - June 2011 (2 years 11 months)', + y: 380, + }), + ]); + + expect(result.warnings).toEqual([]); + expect(result.value).toEqual([ + expect.objectContaining({ + organization: 'First Republic Bank', + totalDuration: '12 years 1 month', + positions: [ + expect.objectContaining({ + title: 'Deputy Regional Managing Director', + }), + expect.objectContaining({ + location: 'Palo Alto, California', + title: 'Senior Managing Director', + }), + expect.objectContaining({ + location: 'Palo Alto', + title: 'Managing Director', + }), + ], + }), + expect.objectContaining({ + organization: 'HSBC', + }), + ]); + }); + + test('exposes warnings through the structural parser result API', () => { + const result = ExperienceStructuralParser.parseExperienceWithWarnings([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Research Systems Group', y: 670 }), + textItem({ text: 'Principal Engineer', y: 650, fontSize: 11.5 }), + ]); + + expect(result.value[0]).toEqual( + expect.objectContaining({ + organization: 'Research Systems Group', + }) + ); + expect(result.warnings).toEqual([ + expect.objectContaining({ + field: 'dates', + section: 'experience', + }), + ]); + }); + + test('starts a new organization while seeking missing dates', () => { + const items = [ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Northstar Solutions', y: 670 }), + textItem({ text: 'Principal Engineer', y: 650, fontSize: 11.5 }), + textItem({ text: 'Blue Oak Labs', y: 620 }), + textItem({ text: 'Staff Engineer', y: 600, fontSize: 11.5 }), + textItem({ text: '2021 - 2024', y: 580 }), + ]; + + const experiences = ExperienceStructuralParser.parseExperience(items); + + expect(experiences).toEqual([ + expect.objectContaining({ + organization: 'Northstar Solutions', + positions: [ + expect.objectContaining({ + title: 'Principal Engineer', + }), + ], + }), + expect.objectContaining({ + organization: 'Blue Oak Labs', + positions: [ + expect.objectContaining({ + duration: '2021 - 2024', + title: 'Staff Engineer', + }), + ], + }), + ]); + }); + + test('keeps producer roles under the current organization and preserves short description continuations', () => { + const items = [ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ + text: 'Discovery Communications / Fischer Productions', + y: 670, + }), + textItem({ + text: "Post Production Supervisor, KING'S OF CRASH", + y: 650, + fontSize: 11.5, + }), + textItem({ text: 'November 2012 - January 2013', y: 630 }), + textItem({ text: 'Park City, UT', y: 610 }), + textItem({ + text: 'Executive Produced by Achilles Pelides & Circe Aeaea', + y: 590, + }), + textItem({ + text: "Producer, KING'S OF CRASH", + y: 560, + fontSize: 11.5, + }), + textItem({ text: 'October 2012 - November 2012', y: 540 }), + textItem({ text: 'Park City, UT', y: 520 }), + textItem({ + text: 'subject matter I helped actively develop story through field interviewing of', + y: 500, + }), + textItem({ text: 'characters.', y: 480 }), + ]; + + const [experience] = ExperienceStructuralParser.parseExperience(items); + + expect(experience).toEqual( + expect.objectContaining({ + organization: 'Discovery Communications / Fischer Productions', + positions: [ + expect.objectContaining({ + description: 'Executive Produced by Achilles Pelides & Circe Aeaea', + duration: 'November 2012 - January 2013', + location: 'Park City, UT', + title: "Post Production Supervisor, KING'S OF CRASH", + }), + expect.objectContaining({ + description: + 'subject matter I helped actively develop story through field interviewing of characters.', + duration: 'October 2012 - November 2012', + location: 'Park City, UT', + title: "Producer, KING'S OF CRASH", + }), + ], + }) + ); + }); + + test('keeps short description fragments after short location lines', () => { + const items = [ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Acme Labs', y: 670 }), + textItem({ text: 'Engineering Manager', y: 650, fontSize: 11.5 }), + textItem({ text: '2020 - 2021', y: 630 }), + textItem({ text: 'Remote', y: 610 }), + textItem({ text: 'led rollout.', y: 590 }), + ]; + + const [experience] = ExperienceStructuralParser.parseExperience(items); + + expect(experience.positions).toEqual([ + expect.objectContaining({ + description: 'led rollout.', + duration: '2020 - 2021', + location: 'Remote', + title: 'Engineering Manager', + }), + ]); + }); + + test('accepts dotted position titles and lowercase location markers', () => { + const items = [ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Acme Labs', y: 670 }), + textItem({ text: 'Manager.', y: 650, fontSize: 11.5 }), + textItem({ text: '2020 - 2021', y: 630 }), + textItem({ text: 'remote', y: 610 }), + ]; + + const [experience] = ExperienceStructuralParser.parseExperience(items); + + expect(experience.positions).toEqual([ + expect.objectContaining({ + duration: '2020 - 2021', + location: 'remote', + title: 'Manager.', + }), + ]); + }); + + test('classifies Colorado state abbreviation as a location', () => { + const items = [ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Acme Labs', y: 670 }), + textItem({ text: 'Staff Engineer', y: 650, fontSize: 11.5 }), + textItem({ text: '2020 - 2022', y: 630 }), + textItem({ text: 'Denver, CO', y: 610 }), + textItem({ text: 'Built internal systems for support teams.', y: 590 }), + ]; + + const [experience] = ExperienceStructuralParser.parseExperience(items); + + expect(experience.positions).toEqual([ + expect.objectContaining({ + description: 'Built internal systems for support teams.', + location: 'Denver, CO', + title: 'Staff Engineer', + }), + ]); + }); + + test('classifies 3-letter country codes as location suffixes', () => { + const items = [ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Acme Labs', y: 670 }), + textItem({ text: 'Staff Engineer', y: 650, fontSize: 11.5 }), + textItem({ text: '2020 - 2022', y: 630 }), + textItem({ text: 'San Francisco, CA, USA', y: 610 }), + textItem({ text: 'Maple Systems', y: 580 }), + textItem({ text: 'Engineering Manager', y: 560, fontSize: 11.5 }), + textItem({ text: '2018 - 2020', y: 540 }), + textItem({ text: 'Toronto, ON, CAN', y: 520 }), + ]; + + const experiences = ExperienceStructuralParser.parseExperience(items); + + expect(experiences).toEqual([ + expect.objectContaining({ + organization: 'Acme Labs', + positions: [ + expect.objectContaining({ + location: 'San Francisco, CA, USA', + title: 'Staff Engineer', + }), + ], + }), + expect.objectContaining({ + organization: 'Maple Systems', + positions: [ + expect.objectContaining({ + location: 'Toronto, ON, CAN', + title: 'Engineering Manager', + }), + ], + }), + ]); + }); + + test('starts a new organization after a description ending with a preposition', () => { + const items = [ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Northstar Solutions', y: 670 }), + textItem({ text: 'Principal Engineer', y: 650, fontSize: 11.5 }), + textItem({ text: '2020 - 2021', y: 630 }), + textItem({ text: 'Owned platform migrations and rollout of', y: 610 }), + textItem({ text: 'Blue Oak Labs', y: 580 }), + textItem({ text: 'Staff Engineer', y: 560, fontSize: 11.5 }), + textItem({ text: '2022 - 2023', y: 540 }), + ]; + + const experiences = ExperienceStructuralParser.parseExperience(items); + + expect(experiences).toEqual([ + expect.objectContaining({ + organization: 'Northstar Solutions', + positions: [ + expect.objectContaining({ + description: 'Owned platform migrations and rollout of', + title: 'Principal Engineer', + }), + ], + }), + expect.objectContaining({ + organization: 'Blue Oak Labs', + positions: [ + expect.objectContaining({ + duration: '2022 - 2023', + title: 'Staff Engineer', + }), + ], + }), + ]); + }); + + test.each([ + ['worked at', 'Client Sites', 'worked at Client Sites'], + ['guided by', 'Senior Advisors', 'guided by Senior Advisors'], + ['served on', 'Advisory Boards', 'served on Advisory Boards'], + ])( + 'continues uppercase fragments after short preposition line "%s"', + (firstFragment, secondFragment, expectedDescription) => { + const items = [ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Acme Labs', y: 670 }), + textItem({ text: 'Engineering Manager', y: 650, fontSize: 11.5 }), + textItem({ text: '2020 - 2021', y: 630 }), + textItem({ text: 'Remote', y: 610 }), + textItem({ text: firstFragment, y: 590 }), + textItem({ text: secondFragment, y: 570 }), + ]; + + const [experience] = ExperienceStructuralParser.parseExperience(items); + + expect(experience.positions).toEqual([ + expect.objectContaining({ + description: expectedDescription, + title: 'Engineering Manager', + }), + ]); + } + ); + + test('keeps sentence-ending title words inside existing descriptions', () => { + const items = [ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Community Relief Services', y: 670 }), + textItem({ text: 'Director of Online Media', y: 650, fontSize: 11.5 }), + textItem({ text: 'June 2008 - January 2012', y: 630 }), + textItem({ + text: 'Full-time staff member, under the direction of the Secretary and Publishing', + y: 610, + }), + textItem({ text: 'Manager.', y: 590 }), + textItem({ + text: 'I oversaw paid and volunteer staff in the multimedia division.', + y: 570, + }), + ]; + + const [experience] = ExperienceStructuralParser.parseExperience(items); + + expect(experience.positions).toEqual([ + expect.objectContaining({ + description: + 'Full-time staff member, under the direction of the Secretary and Publishing Manager. I oversaw paid and volunteer staff in the multimedia division.', + title: 'Director of Online Media', + }), + ]); + }); + + test('starts dotted position titles after complete description sentences', () => { + const items = [ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Acme Labs', y: 670 }), + textItem({ text: 'Staff Engineer', y: 650, fontSize: 11.5 }), + textItem({ text: '2020 - 2021', y: 630 }), + textItem({ text: 'Remote', y: 610 }), + textItem({ + text: 'Led distributed platform migrations across regions.', + y: 590, + }), + textItem({ text: 'Manager.', y: 570, fontSize: 11.5 }), + textItem({ text: '2022 - Present', y: 550 }), + textItem({ text: 'Managed support operations.', y: 530 }), + ]; + + const [experience] = ExperienceStructuralParser.parseExperience(items); + + expect(experience.positions).toEqual([ + expect.objectContaining({ + description: 'Led distributed platform migrations across regions.', + duration: '2020 - 2021', + location: 'Remote', + title: 'Staff Engineer', + }), + expect.objectContaining({ + description: 'Managed support operations.', + duration: '2022 - Present', + title: 'Manager.', + }), + ]); + }); + + test('starts long position titles before description fallback', () => { + const items = [ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Acme Labs', y: 670 }), + textItem({ text: 'Staff Engineer', y: 650, fontSize: 11.5 }), + textItem({ text: '2020 - 2021', y: 630 }), + textItem({ + text: 'Led distributed platform migrations across regions.', + y: 610, + }), + textItem({ + text: 'Senior Director of Product Strategy and Platform Operations', + y: 590, + fontSize: 11.5, + }), + textItem({ text: '2022 - Present', y: 570 }), + ]; + + const [experience] = ExperienceStructuralParser.parseExperience(items); + + expect(experience.positions).toEqual([ + expect.objectContaining({ + description: 'Led distributed platform migrations across regions.', + duration: '2020 - 2021', + title: 'Staff Engineer', + }), + expect.objectContaining({ + duration: '2022 - Present', + title: 'Senior Director of Product Strategy and Platform Operations', + }), + ]); + }); + + test('starts dotted organization names after existing descriptions', () => { + const items = [ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Omnispace360', y: 670 }), + textItem({ text: 'President', y: 650, fontSize: 11.5 }), + textItem({ text: 'January 2015 - December 2023', y: 630 }), + textItem({ + text: 'We believe this is the final digital medium for conveying stories.', + y: 610, + }), + textItem({ text: 'Golden Angle Productions, LLC.', y: 590 }), + textItem({ text: 'Chief Executive Officer', y: 570, fontSize: 11.5 }), + textItem({ text: 'April 2011 - March 2014', y: 550 }), + textItem({ + text: 'Led talent acquisition and improved hiring signal over time.', + y: 520, + }), + textItem({ text: 'Partiu Vantagens!', y: 500 }), + textItem({ text: 'Head of Engineering', y: 480, fontSize: 11.5 }), + textItem({ text: 'October 2015 - October 2017', y: 460 }), + ]; + + const experiences = ExperienceStructuralParser.parseExperience(items); + + expect(experiences).toEqual([ + expect.objectContaining({ + organization: 'Omnispace360', + positions: [ + expect.objectContaining({ + description: + 'We believe this is the final digital medium for conveying stories.', + title: 'President', + }), + ], + }), + expect.objectContaining({ + organization: 'Golden Angle Productions, LLC.', + positions: [ + expect.objectContaining({ + duration: 'April 2011 - March 2014', + title: 'Chief Executive Officer', + }), + ], + }), + expect.objectContaining({ + organization: 'Partiu Vantagens!', + positions: [ + expect.objectContaining({ + duration: 'October 2015 - October 2017', + title: 'Head of Engineering', + }), + ], + }), + ]); + }); + + test('keeps sentence-ending locations out of existing descriptions', () => { + const items = [ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Northstar Solutions', y: 670 }), + textItem({ text: 'Principal Engineer', y: 650, fontSize: 11.5 }), + textItem({ text: '2020 - 2021', y: 630 }), + textItem({ + text: 'Led distributed platform migrations across regions.', + y: 610, + }), + textItem({ text: 'Washington, D.C.', y: 590 }), + ]; + + const [experience] = ExperienceStructuralParser.parseExperience(items); + + expect(experience.positions[0]).toEqual( + expect.objectContaining({ + description: 'Led distributed platform migrations across regions.', + location: 'Washington, D.C.', + title: 'Principal Engineer', + }) + ); + }); + + test('classifies dotted initial standalone locations after durations', () => { + const [experience] = ExperienceStructuralParser.parseExperience([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Policy Lab', y: 670 }), + textItem({ text: 'Research Fellow', y: 650, fontSize: 11.5 }), + textItem({ text: 'January 2020 - Present', y: 630 }), + textItem({ text: 'Washington D.C.', y: 610 }), + textItem({ text: 'Built durable public-sector tools.', y: 590 }), + ]); + + expect(experience.positions[0]).toEqual( + expect.objectContaining({ + description: 'Built durable public-sector tools.', + location: 'Washington D.C.', + title: 'Research Fellow', + }) + ); + }); + + test('does not classify title-bearing area phrases as locations', () => { + const [experience] = ExperienceStructuralParser.parseExperience([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Creative Artists Agency', y: 670 }), + textItem({ + text: 'Chief of Staff to the CEO - Evolution Media', + y: 650, + fontSize: 11.5, + }), + textItem({ text: 'April 2013 - April 2014', y: 630 }), + textItem({ + text: 'Corporate Finance Los Angeles Metropolitan Area', + y: 610, + }), + ]); + + expect(experience.positions[0]).toEqual( + expect.objectContaining({ + description: 'Corporate Finance Los Angeles Metropolitan Area', + }) + ); + expect(experience.positions[0].location).toBeUndefined(); + }); + + test('prefers organization boundaries over place-word names before title and duration', () => { + const experiences = ExperienceStructuralParser.parseExperience([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'self-employed', y: 670 }), + textItem({ text: 'Investor', y: 650, fontSize: 11.5 }), + textItem({ text: 'January 2005 - August 2011', y: 630 }), + textItem({ + text: 'Public investing a special situation portfolio.', + y: 610, + }), + textItem({ text: 'Los Angeles Animal Services', y: 590 }), + textItem({ text: 'Commissioner', y: 570, fontSize: 11.5 }), + textItem({ text: 'September 2003 - August 2005', y: 550 }), + textItem({ text: 'Appointed by the mayor.', y: 530 }), + ]); + + expect(experiences).toEqual([ + expect.objectContaining({ + organization: 'self-employed', + positions: [ + expect.objectContaining({ + description: 'Public investing a special situation portfolio.', + title: 'Investor', + }), + ], + }), + expect.objectContaining({ + organization: 'Los Angeles Animal Services', + positions: [ + expect.objectContaining({ + description: 'Appointed by the mayor.', + duration: 'September 2003 - August 2005', + title: 'Commissioner', + }), + ], + }), + ]); + }); + + test('keeps adjacent location and place-word organization separate', () => { + const experiences = ExperienceStructuralParser.parseExperience([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Cantor Fitzgerald LLC', y: 670 }), + textItem({ text: 'SVP Foreign Exchange Trader', y: 650, fontSize: 11.5 }), + textItem({ text: 'August 1994 - August 1999', y: 630 }), + textItem({ text: 'London, England', y: 610 }), + textItem({ text: 'Tokyo Forex', y: 590 }), + textItem({ text: 'SVP', y: 570, fontSize: 11.5 }), + textItem({ text: 'August 1992 - August 1994', y: 550 }), + textItem({ text: 'Tokyo, Japan', y: 530 }), + ]); + + expect(experiences).toEqual([ + expect.objectContaining({ + organization: 'Cantor Fitzgerald LLC', + positions: [ + expect.objectContaining({ + duration: 'August 1994 - August 1999', + location: 'London, England', + title: 'SVP Foreign Exchange Trader', + }), + ], + }), + expect.objectContaining({ + organization: 'Tokyo Forex', + positions: [ + expect.objectContaining({ + duration: 'August 1992 - August 1994', + location: 'Tokyo, Japan', + title: 'SVP', + }), + ], + }), + ]); + }); + + test('classifies city and full-state standalone locations after durations', () => { + const [experience] = ExperienceStructuralParser.parseExperience([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Parametric', y: 670 }), + textItem({ text: 'Senior Investment Analyst', y: 650, fontSize: 11.5 }), + textItem({ text: 'September 2016 - May 2019', y: 630 }), + textItem({ text: 'Minneapolis, Minnesota', y: 610 }), + textItem({ text: 'Built overlay solutions.', y: 590 }), + ]); + + expect(experience.positions[0]).toEqual( + expect.objectContaining({ + description: 'Built overlay solutions.', + location: 'Minneapolis, Minnesota', + title: 'Senior Investment Analyst', + }) + ); + }); + + test('keeps standalone city locations out of following descriptions', () => { + const [experience] = ExperienceStructuralParser.parseExperience([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Foundation Law Group LLP', y: 670 }), + textItem({ text: 'Partner', y: 650, fontSize: 11.5 }), + textItem({ + text: 'August 2017 - Present (8 years 10 months)', + y: 630, + }), + textItem({ text: 'Los Angeles', y: 610 }), + textItem({ + text: 'Foundation Law Group is a group of Big Firm attorneys.', + y: 590, + }), + ]); + + expect(experience.positions[0]).toEqual( + expect.objectContaining({ + description: 'Foundation Law Group is a group of Big Firm attorneys.', + location: 'Los Angeles', + title: 'Partner', + }) + ); + }); + + test('normalizes trailing commas on greater-area locations', () => { + const [experience] = ExperienceStructuralParser.parseExperience([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Cantor Fitzgerald LLC', y: 670 }), + textItem({ + text: 'Managing Director Institutional Equity Sales', + y: 650, + fontSize: 11.5, + }), + textItem({ + text: 'September 2001 - October 2016 (15 years 2 months)', + y: 630, + }), + textItem({ text: 'Greater New York City Area,', y: 610 }), + textItem({ text: '--Increased order flow and revenue.', y: 590 }), + ]); + + expect(experience.positions[0]).toEqual( + expect.objectContaining({ + description: '--Increased order flow and revenue.', + location: 'Greater New York City Area', + title: 'Managing Director Institutional Equity Sales', + }) + ); + }); + + test('normalizes trailing country codes on greater-area locations', () => { + for (const countrySuffix of [ + ' US', + ', US', + ' U.S.', + ', U.S.A.', + ' U S', + ' U S A', + ', U. S. A.', + ' US.', + ' US,', + ' U.S.,', + ' USA ', + ' U.S.A. ', + ' U.S., ', + ]) { + const [experience] = ExperienceStructuralParser.parseExperience([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Example Co', y: 670 }), + textItem({ text: 'Director', y: 650, fontSize: 11.5 }), + textItem({ text: 'January 2020 - Present', y: 630 }), + textItem({ + text: `Greater Los Angeles Area${countrySuffix}`, + y: 610, + }), + ]); + + expect(experience.positions[0]).toEqual( + expect.objectContaining({ + location: 'Greater Los Angeles Area', + title: 'Director', + }) + ); + } + }); + + test('parses page-break descriptions, fellow roles, and greater area locations', () => { + const items = [ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Hexagon Wireless', y: 670 }), + textItem({ text: 'Co-Founder', y: 650, fontSize: 11.5 }), + textItem({ text: 'November 2021 - January 2023', y: 630 }), + textItem({ + text: 'Hexagon Wireless was a leader in building decentralized physical', + y: 610, + }), + textItem({ + text: 'infrastructure networks and accelerating DePIN technologies in', + y: 590, + }), + textItem({ text: 'the United States and Colombia.', y: 570 }), + textItem({ text: 'International Monetary Fund', y: 540 }), + textItem({ text: '2022 Youth Fellow', y: 520, fontSize: 11.5 }), + textItem({ text: '2022 - 2022', y: 500 }), + textItem({ text: 'Foreign Brief', y: 460 }), + textItem({ text: 'Contributing Writer', y: 440, fontSize: 11.5 }), + textItem({ text: 'February 2020 - August 2021', y: 420 }), + textItem({ text: 'Page 1 of 2', y: 390, fontSize: 9 }), + textItem({ + text: 'Weekly columns and interviews analyzing global geopolitical events and their', + y: -9260, + }), + textItem({ text: 'implications.', y: -9280 }), + textItem({ text: 'Bank of America Merrill Lynch', y: -9320 }), + textItem({ text: 'Investment Advisor', y: -9340, fontSize: 11.5 }), + textItem({ text: 'November 2017 - August 2018', y: -9360 }), + textItem({ + text: 'Minneapolis, Minnesota, United States', + y: -9380, + }), + textItem({ text: 'Inspire Medical $INSP IPO', y: -9400 }), + textItem({ + text: 'Organisation for the Prohibition of Chemical Weapons (OPCW)', + y: -9440, + }), + textItem({ text: 'Business Analyst', y: -9460, fontSize: 11.5 }), + textItem({ text: 'June 2016 - December 2016', y: -9480 }), + textItem({ text: 'The Hague Area, Netherlands', y: -9500 }), + textItem({ text: 'Fermilab', y: -9540 }), + textItem({ text: 'Student Manager', y: -9560, fontSize: 11.5 }), + textItem({ text: 'October 2011 - October 2013', y: -9580 }), + textItem({ text: 'Greater Minneapolis-St. Paul Area', y: -9600 }), + textItem({ text: 'Education', y: -9700, fontSize: 16 }), + ]; + + const experiences = ExperienceStructuralParser.parseExperience(items); + const byOrganization = new Map( + experiences.map(experience => [experience.organization, experience]) + ); + + expect( + byOrganization.get('Hexagon Wireless')?.positions[0]?.description + ).toBe( + 'Hexagon Wireless was a leader in building decentralized physical infrastructure networks and accelerating DePIN technologies in the United States and Colombia.' + ); + expect( + byOrganization.get('International Monetary Fund')?.positions[0] + ).toEqual( + expect.objectContaining({ + duration: '2022 - 2022', + title: '2022 Youth Fellow', + }) + ); + expect(byOrganization.get('Foreign Brief')?.positions[0]).toEqual( + expect.objectContaining({ + description: + 'Weekly columns and interviews analyzing global geopolitical events and their implications.', + title: 'Contributing Writer', + }) + ); + expect( + byOrganization.get('Bank of America Merrill Lynch')?.positions[0] + ).toEqual( + expect.objectContaining({ + description: 'Inspire Medical $INSP IPO', + title: 'Investment Advisor', + }) + ); + expect( + byOrganization.get( + 'Organisation for the Prohibition of Chemical Weapons (OPCW)' + )?.positions[0] + ).toEqual( + expect.objectContaining({ + location: 'The Hague Area, Netherlands', + title: 'Business Analyst', + }) + ); + expect(byOrganization.get('Fermilab')?.positions[0]).toEqual( + expect.objectContaining({ + location: 'Greater Minneapolis-St. Paul Area', + title: 'Student Manager', + }) + ); + }); + + test('uses unique warning entries for nested positions', () => { + const result = ExperienceStructuralParser.parseExperienceWithWarnings([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Northstar Solutions', y: 670 }), + textItem({ text: 'Principal Engineer', y: 650, fontSize: 11.5 }), + textItem({ text: 'Staff Engineer', y: 620, fontSize: 11.5 }), + textItem({ text: 'Blue Oak Labs', y: 590 }), + textItem({ text: 'Advisor', y: 570, fontSize: 11.5 }), + ]); + + expect(result.warnings).toEqual([ + expect.objectContaining({ entry: 0, rawText: 'Principal Engineer' }), + expect.objectContaining({ entry: 1, rawText: 'Staff Engineer' }), + expect.objectContaining({ entry: 2, rawText: 'Advisor' }), + ]); + }); + + test('parses bounded right-column lines without an explicit section header', () => { + const items = [ + textItem({ text: 'Ignored Sidebar', x: 80, y: 670 }), + textItem({ text: 'Outside Range Labs', y: 700 }), + textItem({ text: 'Northstar Solutions', y: 670 }), + textItem({ text: 'Principal Engineer', y: 650, fontSize: 11.5 }), + textItem({ text: '2020 - 2024', y: 630 }), + textItem({ text: 'Ignored Later Labs', y: 550 }), + ]; + + const [experience] = ExperienceStructuralParser.parseExperience( + items, + 680, + 580 + ); + + expect(experience).toEqual( + expect.objectContaining({ + organization: 'Northstar Solutions', + positions: [ + expect.objectContaining({ + duration: '2020 - 2024', + title: 'Principal Engineer', + }), + ], + }) + ); + }); + + test('drops organization total duration rows when no position title follows', () => { + const result = ExperienceStructuralParser.parseExperienceWithWarnings([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Northstar Solutions', y: 670 }), + textItem({ text: '2020 - 2024', y: 650 }), + ]); + + expect(result.value).toEqual([]); + expect(result.warnings).toEqual([]); + }); + + test('starts a replacement organization before any title appears', () => { + const experiences = ExperienceStructuralParser.parseExperience([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Northstar Solutions', y: 670 }), + textItem({ text: 'Blue Oak Labs', y: 640 }), + textItem({ text: 'Staff Engineer', y: 620, fontSize: 11.5 }), + textItem({ text: '2021 - 2024', y: 600 }), + ]); + + expect(experiences).toEqual([ + expect.objectContaining({ + organization: 'Blue Oak Labs', + positions: [ + expect.objectContaining({ + duration: '2021 - 2024', + title: 'Staff Engineer', + }), + ], + }), + ]); + }); + + test('keeps locations that appear before dates on the current position', () => { + const [experience] = ExperienceStructuralParser.parseExperience([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Northstar Solutions', y: 670 }), + textItem({ text: 'Principal Engineer', y: 650, fontSize: 11.5 }), + textItem({ text: 'Austin, TX', y: 630 }), + textItem({ text: '2020 - 2024', y: 610 }), + ]); + + expect(experience.positions).toEqual([ + expect.objectContaining({ + duration: '2020 - 2024', + location: 'Austin, TX', + title: 'Principal Engineer', + }), + ]); + }); + + test('splits consecutive position titles under the same organization', () => { + const [experience] = ExperienceStructuralParser.parseExperience([ + textItem({ text: 'Experience', y: 700, fontSize: 16 }), + textItem({ text: 'Northstar Solutions', y: 670 }), + textItem({ text: 'Principal Engineer', y: 650, fontSize: 11.5 }), + textItem({ text: 'Engineering Manager', y: 630, fontSize: 11.5 }), + textItem({ text: '2021 - 2024', y: 610 }), + ]); + + expect(experience.positions).toEqual([ + expect.objectContaining({ + duration: '', + title: 'Principal Engineer', + }), + expect.objectContaining({ + duration: '2021 - 2024', + title: 'Engineering Manager', + }), + ]); + }); + + test('normalizes fallback duration fragments when date parsing cannot', () => { + expect( + ExperienceStructuralParser['extractCleanDuration']( + 'Museum archive appointment 1888 - 1889' + ) + ).toBe('1888 - 1889'); + expect( + ExperienceStructuralParser['extractCleanDuration']( + '• shipped in fiscal 1888 after launch' + ) + ).toBe('fiscal 1888'); + expect( + ExperienceStructuralParser['extractCleanDuration']('contract-to-hire') + ).toBe('contract-to-hire'); + + const longNonDateText = + 'served without explicit dates in a description that is too long to be treated like a compact duration value'; + + expect( + ExperienceStructuralParser['extractCleanDuration'](longNonDateText) + ).toBe(longNonDateText); + }); + + test('reports unparseable non-profile date ranges separately from missing dates', () => { + const warnings = ExperienceStructuralParser['createExperienceWarnings']([ + { + organization: 'Archive Museum', + positions: [ + { + description: '', + duration: '1888 - 1889', + title: 'Cataloger', + }, + ], + }, + ]); + + expect(warnings).toEqual([ + expect.objectContaining({ + field: 'dates', + message: 'Could not parse date range', + rawText: '1888 - 1889', + }), + ]); + }); + + test('classifies sparse parser lines through fallback states', () => { + const sections = ExperienceStructuralParser['classifyLines']([ + parserLine({ text: 'Experience' }), + parserLine({ index: 1, text: '2020 - 2021' }), + parserLine({ index: 2, text: 'Remote' }), + parserLine({ + fontSize: 13, + index: 3, + text: 'Northstar Solutions', + }), + parserLine({ index: 4, text: 'Principal Engineer' }), + parserLine({ + index: 5, + text: 'A description line long enough to classify as prose.', + }), + parserLine({ index: 6, text: 'x' }), + ]); + + expect(sections.map(section => section.type)).toEqual([ + 'other', + 'duration', + 'location', + 'organization', + 'position', + 'description', + ]); + }); + + test('covers fallback line classification outcomes directly', () => { + expect( + ExperienceStructuralParser['fallbackLineType']('Blue Oak Labs', 9, 0, [ + 'Blue Oak Labs', + 'Staff Engineer', + '2020 - 2022', + ]) + ).toBe('organization'); + expect( + ExperienceStructuralParser['fallbackLineType']('ok', 12, 0, ['ok']) + ).toBe('other'); + expect( + ExperienceStructuralParser['fallbackLineType']( + 'This description line is long enough to be treated as prose.', + 12, + 0, + [] + ) + ).toBe('description'); + }); + + test('classifies explicit states with missing optional structural metadata', () => { + const organizationLine = parserLine({ text: 'Blue Oak Labs' }); + const otherLine = parserLine({ text: 'tiny' }); + const sentenceLine = parserLine({ text: 'Wrapped sentence.' }); + + expect( + ExperienceStructuralParser['classifyLineType']({ + allLines: [ + organizationLine, + parserLine({ index: 1, text: 'Staff Engineer' }), + ], + index: 0, + line: organizationLine, + state: 'seeking_title', + }) + ).toBe('organization'); + expect( + ExperienceStructuralParser['classifyLineType']({ + allLines: [otherLine], + index: 0, + line: otherLine, + state: 'seeking_title', + }) + ).toBe('other'); + expect( + ExperienceStructuralParser['classifyLineType']({ + allLines: [ + parserLine({ + text: 'Previous role description with enough context.', + }), + organizationLine, + parserLine({ index: 2, text: 'Staff Engineer' }), + parserLine({ index: 3, text: 'January 2020 - Present' }), + ], + index: 1, + line: organizationLine, + state: 'in_description', + }) + ).toBe('organization'); + expect( + ExperienceStructuralParser['classifyLineType']({ + allLines: [sentenceLine], + index: 0, + line: sentenceLine, + state: 'in_description', + }) + ).toBe('other'); + }); + + test('classifies standalone Venture as a position title', () => { + const ventureLine = parserLine({ text: 'Venture' }); + + expect( + ExperienceStructuralParser['classifyLineType']({ + allLines: [ventureLine], + index: 0, + line: ventureLine, + state: 'seeking_title', + }) + ).toBe('position'); + }); + + test('covers description continuation helpers directly', () => { + expect( + ExperienceStructuralParser['looksLikeDescriptionLine']({ + allLines: ['Tiny'], + index: 0, + line: 'Tiny', + }) + ).toBe(false); + expect( + ExperienceStructuralParser['looksLikeDescriptionLine']({ + allLines: ['Owned migration planning for', 'Migration rollout'], + index: 1, + line: 'Migration rollout', + previousLine: 'Owned migration planning for', + }) + ).toBe(true); + expect( + ExperienceStructuralParser['looksLikeDescriptionLine']({ + allLines: [ + 'Owned migration planning for', + 'Blue Oak Labs', + 'Staff Engineer', + 'January 2020 - Present', + ], + index: 1, + line: 'Blue Oak Labs', + previousLine: 'Owned migration planning for', + }) + ).toBe(false); + expect( + ExperienceStructuralParser['looksLikeDescriptionLine']({ + allLines: ['Inspire Medical $INSP IPO'], + index: 0, + line: 'Inspire Medical $INSP IPO', + }) + ).toBe(true); + expect( + ExperienceStructuralParser['looksLikeDescriptionLine']({ + allLines: ['Responsibilities: Led platform rollout.'], + index: 0, + line: 'Responsibilities: Led platform rollout.', + }) + ).toBe(true); + expect( + ExperienceStructuralParser['looksLikeDescriptionContinuationLine']( + 'continued rollout' + ) + ).toBe(false); + expect( + ExperienceStructuralParser['looksLikeDescriptionContinuationLine']( + 'Shipped safely.', + 'Owned migration planning with enough context' + ) + ).toBe(true); + expect( + ExperienceStructuralParser[ + 'looksLikeSentenceEndingDescriptionContinuationLine' + ]('Manager.', 'This previous sentence is complete.') + ).toBe(false); + }); + + test('handles orphan structural sections and optional position fields', () => { + const experiences = ExperienceStructuralParser['buildWorkExperiences']([ + structuralSection({ + text: 'Austin, TX', + type: 'organization', + }), + structuralSection({ + text: 'Principal Engineer', + type: 'position', + }), + structuralSection({ + text: '2020 - 2021', + type: 'duration', + }), + structuralSection({ + text: 'Remote', + type: 'location', + }), + structuralSection({ + text: 'Northstar Solutions', + type: 'organization', + }), + structuralSection({ + text: '2022 - 2024', + type: 'duration', + }), + structuralSection({ + text: 'Austin, TX', + type: 'location', + }), + ]); + + expect(experiences).toEqual([]); + expect( + ExperienceStructuralParser['buildWorkExperiences']([ + structuralSection({ + text: 'Principal Engineer', + type: 'position', + }), + structuralSection({ + text: '2020 - 2021', + type: 'duration', + }), + structuralSection({ + text: 'Staff Engineer', + type: 'position', + }), + ]) + ).toEqual([]); + expect( + ExperienceStructuralParser['buildWorkExperiences']([ + structuralSection({ + text: 'Northstar Solutions', + type: 'organization', + }), + structuralSection({ + text: '2020 - 2021', + type: 'duration', + }), + structuralSection({ + text: '2022 - 2024', + type: 'duration', + }), + ]) + ).toEqual([]); + expect( + ExperienceStructuralParser['completePosition']({ + descriptionLines: [], + position: { + title: 'Advisor', + }, + }) + ).toEqual({ + description: '', + duration: '', + title: 'Advisor', + }); + expect( + ExperienceStructuralParser['completeWorkExperience']({ + descriptionLines: [], + position: null, + workExperience: { + organization: 'Existing Roles', + positions: [ + { + description: '', + duration: '', + title: 'Advisor', + }, + ], + }, + }) + ).toEqual({ + organization: 'Existing Roles', + positions: [ + { + description: '', + duration: '', + title: 'Advisor', + }, + ], + totalDuration: undefined, + }); + expect( + ExperienceStructuralParser['completeWorkExperience']({ + descriptionLines: [], + position: { + duration: '', + title: 'Advisor', + }, + workExperience: { + organization: 'No Existing Roles', + }, + }) + ).toEqual({ + organization: 'No Existing Roles', + positions: [ + { + description: '', + duration: '', + title: 'Advisor', + }, + ], + totalDuration: undefined, + }); + }); + + test('covers organization, confidence, and duration fallback edges', () => { + expect( + ExperienceStructuralParser['looksLikeOrganization']( + 'International Research Systems Group Partners', + 9, + 0, + [ + 'International Research Systems Group Partners', + 'Staff Engineer', + '2020 - 2022', + ] + ) + ).toBe(false); + expect( + ExperienceStructuralParser['calculateConfidence']( + 'Northstar Solutions', + 'organization', + 13 + ) + ).toBeCloseTo(0.9); + expect( + ExperienceStructuralParser['calculateConfidence']( + 'Present', + 'duration', + 12 + ) + ).toBe(0.5); + expect( + ExperienceStructuralParser['extractCleanDuration']('Launched in 2025') + ).toBe('2025'); + }); + + test('classifies only real location and duration lines for experience metadata', () => { + for (const falseLocation of [ + 'Velocity AI', + 'Mission Control AI', + 'Breakaway Partners OU', + 'Spatial AI', + 'Comcast NBCUniversal SportsTech Accelerator, HeadVantage is redefining', + 'After securing an exclusive partnership with Warner Music, Prescient is now', + ]) { + expect( + ExperienceStructuralParser['looksLikeLocation'](falseLocation) + ).toBe(false); + } + + for (const trueLocation of [ + 'Los Angeles CA', + 'San Diego', + 'San Diego Metropolitan Area', + 'Tallinn, Harjumaa, Estonia', + 'Dallas, Texas', + 'London Area, United Kingdom', + 'Denver, CO', + 'Greater New York City Area,', + ]) { + expect( + ExperienceStructuralParser['looksLikeLocation'](trueLocation) + ).toBe(true); + } + + expect( + ExperienceStructuralParser['looksLikeDuration']( + 'Resilient Connections was a pop-up non-profit that launched in March 2020 to' + ) + ).toBe(false); + expect( + ExperienceStructuralParser['looksLikeDuration']( + '2020 - 2020 (less than a year)' + ) + ).toBe(true); + expect( + ExperienceStructuralParser['classifyLineType']({ + allLines: [ + parserLine({ index: 0, text: 'Partner' }), + parserLine({ index: 1, text: 'August 2017 - Present' }), + parserLine({ index: 2, text: 'San Diego' }), + parserLine({ + index: 3, + text: 'Confirm BioSciences is a diagnostic commercialization company.', + }), + ], + index: 2, + line: parserLine({ index: 2, text: 'San Diego' }), + state: 'in_description', + }) + ).toBe('location'); + }); + + test('covers remaining structural classification and helper branches', () => { + expect( + ExperienceStructuralParser['classifyLineType']({ + allLines: [ + parserLine({ index: 0, text: 'Chief Architect' }), + parserLine({ index: 1, text: 'AI' }), + parserLine({ index: 2, text: 'Remote' }), + ], + index: 1, + line: parserLine({ index: 1, text: 'AI' }), + state: 'seeking_dates', + }) + ).toBe('other'); + expect( + ExperienceStructuralParser['classifyLineType']({ + allLines: [ + parserLine({ index: 0, text: 'Principal Engineer' }), + parserLine({ index: 1, text: '2020 - 2021' }), + parserLine({ index: 2, text: 'Page 1 of 2' }), + ], + index: 2, + line: parserLine({ index: 2, text: 'Page 1 of 2' }), + state: 'in_description', + }) + ).toBe('other'); + expect( + ExperienceStructuralParser['classifyLineType']({ + allLines: [ + parserLine({ + index: 0, + text: 'Existing detailed work context with enough words', + }), + parserLine({ + index: 1, + text: 'Detailed delivery narrative that should remain prose.', + }), + parserLine({ index: 2, text: '2020 - 2021' }), + ], + index: 1, + line: parserLine({ + index: 1, + text: 'Detailed delivery narrative that should remain prose.', + }), + state: 'in_description', + }) + ).toBe('description'); + expect( + ExperienceStructuralParser['classifyLineType']({ + allLines: [ + parserLine({ + index: 0, + text: 'Owned migration planning with enough context', + }), + parserLine({ index: 1, text: 'continued rollout' }), + parserLine({ index: 2, text: '2020 - 2021' }), + ], + index: 1, + line: parserLine({ index: 1, text: 'continued rollout' }), + state: 'in_description', + }) + ).toBe('description'); + + expect( + ExperienceStructuralParser['hasTotalDurationThenPosition'](0, [ + 'Example Labs', + '3 years', + 'Principal Engineer', + ]) + ).toBe(false); + expect( + ExperienceStructuralParser['looksLikeDescriptionContinuationLine']( + 'Client Sites', + 'worked at' + ) + ).toBe(true); + expect( + ExperienceStructuralParser['looksLikeDescriptionContinuationLine']( + 'Client Sites', + 'short' + ) + ).toBe(false); + expect( + ExperienceStructuralParser['looksLikeShortDescriptorEntryHeader']( + 'Principal Engineer', + 0, + ['Principal Engineer', '2020 - 2021'] + ) + ).toBe(true); + }); + + test('covers work-experience completion and warning edge branches directly', () => { + expect( + ExperienceStructuralParser['buildWorkExperiences']([ + structuralSection({ + text: 'Example Labs', + type: 'organization', + }), + structuralSection({ + text: 'Principal Engineer', + type: 'position', + }), + structuralSection({ + text: 'principal engineer', + type: 'position', + }), + structuralSection({ + text: '2020 - 2021', + type: 'duration', + }), + ]) + ).toEqual([ + expect.objectContaining({ + positions: [ + expect.objectContaining({ + title: 'principal engineer', + }), + ], + }), + ]); + + expect( + ExperienceStructuralParser['completeWorkExperience']({ + descriptionLines: [], + position: null, + workExperience: { + organization: 'Example Labs', + positions: [ + { + description: '', + duration: '2020 - 2021', + title: 'Principal Engineer', + }, + ], + }, + }) + ).toEqual( + expect.objectContaining({ + organization: 'Example Labs', + }) + ); + expect( + ExperienceStructuralParser['completePosition']({ + descriptionLines: [], + position: { + title: 'Advisor', + }, + }) + ).toEqual({ + description: '', + duration: '', + title: 'Advisor', + }); + expect( + ExperienceStructuralParser['createExperienceWarnings']([ + { + organization: 'Empty Company', + positions: [], + }, + ]) + ).toEqual([ + expect.objectContaining({ + field: 'positions', + rawText: 'Empty Company', + }), + ]); + }); + + test('covers duration extraction fallbacks with embedded and compact years', () => { + expect( + ExperienceStructuralParser['extractCleanDuration']( + 'Managed launch work in fiscal 2020 planning cycle with no range text here' + ) + ).toBe('fiscal 2020'); + expect( + ExperienceStructuralParser['extractCleanDuration']( + 'FY2020 planning cycle with long text long text long text long text long text' + ) + ).toBe('FY2020 planning cycle with long text long text lon'); + }); +}); + +function structuralSection({ + text, + type, +}: { + text: string; + type: StructuralSection['type']; +}): StructuralSection { + return { + confidence: 1, + fontSize: 12, + text, + type, + y: 0, + }; +} + +function parserLine({ + fontSize, + index = 0, + text, + y, +}: { + fontSize?: number; + index?: number; + text: string; + y?: number; +}): NormalizedParserLine { + return { + ...(fontSize !== undefined ? { fontSize } : {}), + ...(y !== undefined ? { y } : {}), + index, + section: 'experience', + source: 'structural', + text, + }; +} diff --git a/tests/unit/experience.test.ts b/tests/unit/experience.test.ts new file mode 100644 index 0000000..96edee3 --- /dev/null +++ b/tests/unit/experience.test.ts @@ -0,0 +1,323 @@ +import { ExperienceParser } from '../../src/parsers/experience.js'; + +describe('ExperienceParser', () => { + test('parses separate generic company, title, and duration lines', () => { + const [experience] = ExperienceParser.parse(` + Experience + Northstar AI + Principal Software Engineer + 2021 - 2024 + Austin, TX + Built platform services for customer-facing products. + + Education + Example University + `); + + expect(experience).toEqual({ + title: 'Principal Software Engineer', + company: 'Northstar AI', + duration: '2021 - 2024', + dates: { + originalText: '2021 - 2024', + start: { + iso: '2021', + precision: 'year', + text: '2021', + }, + end: { + iso: '2024', + precision: 'year', + text: '2024', + }, + kind: 'completed', + }, + location: 'Austin, TX', + description: 'Built platform services for customer-facing products.', + }); + }); + + test('stops parsing when the next section starts', () => { + const experiences = ExperienceParser.parse(` + Experience + Example Systems + Staff Engineer + 2020 - 2022 + + Education + Principal Engineer + 2023 - 2024 + `); + + expect(experiences).toHaveLength(1); + expect(experiences[0]).toEqual( + expect.objectContaining({ + title: 'Staff Engineer', + company: 'Example Systems', + }) + ); + }); + + test('parses inline title and company entries', () => { + const [experience] = ExperienceParser.parse(` + Experience + Product Manager at Blue Oak Labs + 2020 - 2022 + Led delivery for customer-facing products. + `); + + expect(experience).toEqual({ + title: 'Product Manager', + company: 'Blue Oak Labs', + duration: '2020 - 2022', + dates: { + originalText: '2020 - 2022', + start: { + iso: '2020', + precision: 'year', + text: '2020', + }, + end: { + iso: '2022', + precision: 'year', + text: '2022', + }, + kind: 'completed', + }, + location: '', + description: 'Led delivery for customer-facing products.', + }); + }); + + test('returns section warnings when an experience section has no complete entries', () => { + const result = ExperienceParser.parseWithWarnings(` + Experience + Principal Engineer + 2020 - 2024 + `); + + expect(result.value).toEqual([]); + expect(result.warnings).toEqual([ + expect.objectContaining({ + code: 'section_parse_warning', + field: 'entry', + section: 'experience', + }), + ]); + }); + + test('does not treat prose lines with years as durations', () => { + const [experience] = ExperienceParser.parse(` + Experience + Northstar AI + Principal Software Engineer + 2021 - 2024 + Built customer-facing systems in 2020 before leading platform work. + `); + + expect(experience.duration).toBe('2021 - 2024'); + expect(experience.dates).toEqual({ + originalText: '2021 - 2024', + start: { + iso: '2021', + precision: 'year', + text: '2021', + }, + end: { + iso: '2024', + precision: 'year', + text: '2024', + }, + kind: 'completed', + }); + expect(experience.description).toBe( + 'Built customer-facing systems in 2020 before leading platform work.' + ); + }); + + test('normalizes irregular spaces around duration separators', () => { + const [experience] = ExperienceParser.parse(` + Experience + Northstar AI + Principal Software Engineer + 2021 - 2024 + Built customer-facing systems in 2020 before leading platform work. + `); + + expect(experience.duration).toBe('2021 - 2024'); + expect(experience.description).toBe( + 'Built customer-facing systems in 2020 before leading platform work.' + ); + }); + + test('keeps title-only entries and warns about missing dates', () => { + const result = ExperienceParser.parseWithWarnings(` + Experience + Northstar AI + Principal Software Engineer + `); + + expect(result.value).toEqual([ + { + title: 'Principal Software Engineer', + company: 'Northstar AI', + duration: '', + location: '', + description: '', + }, + ]); + expect(result.warnings).toEqual([ + expect.objectContaining({ + field: 'dates', + rawText: 'Principal Software Engineer', + }), + ]); + }); + + test('covers inline and duration rejection branches', () => { + expect( + ExperienceParser['parseInlineTitleAndCompany']( + 'Principal Engineer at 2020 - 2021' + ) + ).toBeUndefined(); + expect( + ExperienceParser['looksLikeDuration']( + 'This long delivery note mentions 2020 - 2021 but is prose, not a compact duration value.' + ) + ).toBe(false); + }); + + test('reports unparseable duration text separately from missing dates', () => { + const warnings = ExperienceParser['createExperienceWarnings']( + [ + { + title: 'Cataloger', + company: 'Archive Museum', + duration: '1888 - 1889', + location: '', + description: '', + }, + ], + ['Archive Museum', 'Cataloger', '1888 - 1889'] + ); + + expect(warnings).toEqual([ + expect.objectContaining({ + field: 'dates', + message: 'Could not parse date range', + rawText: '1888 - 1889', + }), + ]); + }); + + test('covers company, inline, location, and incomplete-position helper branches', () => { + expect( + ExperienceParser['parseInlineTitleAndCompany']( + 'Principal Engineer @ Blue Oak Labs' + ) + ).toEqual( + expect.objectContaining({ + company: 'Blue Oak Labs', + title: 'Principal Engineer', + }) + ); + expect( + ExperienceParser['parseInlineTitleAndCompany']( + 'Built platform systems at Blue Oak Labs' + ) + ).toBeUndefined(); + + expect( + ExperienceParser['looksLikeCompanyName']( + '2020 - 2024', + ['2020 - 2024', 'Principal Engineer'], + 0 + ) + ).toBe(false); + expect( + ExperienceParser['looksLikeCompanyName']( + 'Blue Oak Labs', + ['Blue Oak Labs', 'Principal Engineer', '2020 - 2024'], + 0 + ) + ).toBe(true); + + expect(ExperienceParser['looksLikeDuration']('2020 - current')).toBe(true); + expect(ExperienceParser['looksLikeLocation']('Austin, Texas')).toBe(true); + expect(ExperienceParser['looksLikeLocation']('Austin, TX @ Remote')).toBe( + false + ); + expect( + ExperienceParser['completeExperience']({ + descriptionLines: ['ignored'], + position: { + company: 'Blue Oak Labs', + duration: '2020 - 2024', + }, + }) + ).toBeUndefined(); + expect( + ExperienceParser['completeExperience']({ + descriptionLines: [], + position: { + company: 'Blue Oak Labs', + title: 'Advisor', + }, + }) + ).toEqual({ + company: 'Blue Oak Labs', + description: '', + duration: '', + location: undefined, + title: 'Advisor', + }); + }); + + test('completes previous entries when text parsing sees new companies and titles', () => { + const result = ExperienceParser.parseWithWarnings(` + Experience + Blue Oak Labs + Principal Engineer + January 2020 - March 2021 + Staff Engineer + April 2021 - Present + Northstar AI + 3 years + Unmatched detail + `); + + expect(result.value).toEqual([ + expect.objectContaining({ + company: 'Blue Oak Labs', + duration: 'January 2020 - March 2021', + title: 'Principal Engineer', + }), + expect.objectContaining({ + company: 'Blue Oak Labs', + duration: 'April 2021 - Present', + title: 'Staff Engineer', + }), + ]); + expect(result.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'entry', + rawText: '3 years', + }), + ]) + ); + }); + + test('recognizes company names followed only by total duration text', () => { + expect( + ExperienceParser['looksLikeCompanyName']( + 'Northstar AI', + ['Northstar AI', '3 years'], + 0 + ) + ).toBe(true); + expect( + ExperienceParser['looksLikeDuration']('January 2020 - March 2021') + ).toBe(true); + }); +}); diff --git a/tests/unit/extra-sections.test.ts b/tests/unit/extra-sections.test.ts new file mode 100644 index 0000000..84a60f9 --- /dev/null +++ b/tests/unit/extra-sections.test.ts @@ -0,0 +1,233 @@ +import { + ExtraSectionParser, + filterMergedSectionWarnings, +} from '../../src/parsers/extra-sections.js'; +import type { SectionParseWarning } from '../../src/types/profile.js'; +import type { StructuralLine } from '../../src/utils/structural-lines.js'; + +function line({ + column = 'right', + text, + y, +}: { + column?: StructuralLine['column']; + text: string; + y: number; +}): StructuralLine { + return { + column, + fontSize: 10, + height: 10, + text, + width: text.length * 5, + x: column === 'left' ? 30 : 220, + y, + }; +} + +describe('ExtraSectionParser', () => { + test('extracts text fallback certifications, projects, publications, and volunteer work', () => { + const sections = ExtraSectionParser.parseText(` + Apollo Helios + apollo@example.com + + Certifications + Cloud Architect Professional + + Projects + Internal Search Migration + + Publications + Scaling Engineering Teams + + Volunteer Experience + Community Mentor + + Experience + Example Labs + `); + + expect(sections).toEqual({ + certifications: ['Cloud Architect Professional'], + honors_awards: [], + projects: ['Internal Search Migration'], + publications: ['Scaling Engineering Teams'], + volunteer_work: ['Community Mentor'], + }); + }); + + test('extracts structural sections per visual column', () => { + const sections = ExtraSectionParser.parseStructural([ + line({ column: 'left', text: 'Licenses & Certifications', y: 760 }), + line({ column: 'left', text: 'AWS Solutions Architect', y: 740 }), + line({ column: 'left', text: 'Languages', y: 700 }), + line({ text: 'Projects', y: 760 }), + line({ text: 'Revenue Forecasting Tool', y: 740 }), + line({ text: 'Volunteer Work', y: 700 }), + line({ text: 'Open Source Mentor', y: 680 }), + line({ text: 'Publications', y: 640 }), + line({ text: 'Distributed Systems Notes', y: 620 }), + line({ text: 'Education', y: 640 }), + ]); + + expect(sections.certifications).toEqual(['AWS Solutions Architect']); + expect(sections.projects).toEqual(['Revenue Forecasting Tool']); + expect(sections.publications).toEqual(['Distributed Systems Notes']); + expect(sections.volunteer_work).toEqual(['Open Source Mentor']); + }); + + test('extracts honors-awards as a supported extra section', () => { + const sections = ExtraSectionParser.parseStructural([ + line({ column: 'left', text: 'Honors-Awards', y: 760 }), + line({ + column: 'left', + text: 'Defender of the Declaration Award', + y: 740, + }), + line({ column: 'left', text: 'Winner', y: 728 }), + line({ column: 'left', text: 'Experience', y: 700 }), + ]); + + expect(sections.honors_awards).toEqual([ + 'Defender of the Declaration Award Winner', + ]); + }); + + test('merges wrapped structural extra section entries', () => { + const sections = ExtraSectionParser.parseStructural([ + line({ column: 'left', text: 'Certifications', y: 760 }), + line({ + column: 'left', + text: 'MITx 14.310Fx: Data Analysis in', + y: 740, + }), + line({ column: 'left', text: 'Social Science', y: 728 }), + line({ + column: 'left', + text: 'Certificate of Completion - 23 hours', + y: 708, + }), + line({ + column: 'left', + text: 'of Android development training', + y: 696, + }), + line({ column: 'left', text: 'Experience', y: 660 }), + ]); + + expect(sections.certifications).toEqual([ + 'MITx 14.310Fx: Data Analysis in Social Science', + 'Certificate of Completion - 23 hours of Android development training', + ]); + }); + + test('stops extra section capture at normalized registry boundaries', () => { + const sections = ExtraSectionParser.parseText(` + Certificações e Licenças + Cloud Architect Professional + + Competências + TypeScript + `); + + expect(sections.certifications).toEqual(['Cloud Architect Professional']); + }); + + test('returns warnings for detected empty extra sections', () => { + const result = ExtraSectionParser.parseTextWithWarnings(` + Certifications + + Experience + Example Labs + `); + + expect(result.value.certifications).toEqual([]); + expect(result.warnings).toEqual([ + expect.objectContaining({ + field: 'section', + section: 'certifications', + }), + ]); + }); + + test('suppresses structural empty-column warnings after merged entries exist', () => { + const result = ExtraSectionParser.parseStructuralWithWarnings([ + line({ column: 'left', text: 'Certifications', y: 760 }), + line({ column: 'left', text: 'Cloud Architect Professional', y: 740 }), + line({ column: 'right', text: 'Certifications', y: 760 }), + line({ column: 'right', text: 'Experience', y: 740 }), + ]); + + expect(result.value.certifications).toEqual([ + 'Cloud Architect Professional', + ]); + expect(result.warnings).toEqual([]); + }); + + test('preserves non-section warnings while filtering merged section warnings', () => { + const nonSectionWarning: SectionParseWarning = { + code: 'section_parse_warning', + field: 'entry', + message: 'Discarded malformed project entry', + rawText: 'Project without details', + section: 'projects', + }; + const warnings: SectionParseWarning[] = [ + { + code: 'section_parse_warning', + field: 'section', + message: + 'Detected a certifications section but could not extract entries', + section: 'certifications', + }, + nonSectionWarning, + ]; + + const filteredWarnings = filterMergedSectionWarnings({ + sections: { + certifications: ['Cloud Architect Professional'], + honors_awards: [], + projects: [], + publications: [], + volunteer_work: [], + }, + warnings, + }); + + expect(filteredWarnings).toEqual([nonSectionWarning]); + }); + + test('keeps unrelated section warnings and deduplicates merged empty warnings', () => { + const summaryWarning: SectionParseWarning = { + code: 'section_parse_warning', + field: 'section', + message: 'Detected a summary section but could not extract entries', + section: 'summary', + }; + const firstProjectWarning: SectionParseWarning = { + code: 'section_parse_warning', + field: 'section', + message: 'Detected a projects section but could not extract entries', + section: 'projects', + }; + const duplicateProjectWarning: SectionParseWarning = { + code: 'section_parse_warning', + field: 'section', + message: 'Detected a projects section but could not extract entries', + section: 'projects', + }; + + const filteredWarnings = filterMergedSectionWarnings({ + sections: { + certifications: [], + honors_awards: [], + projects: [], + publications: [], + volunteer_work: [], + }, + warnings: [summaryWarning, firstProjectWarning, duplicateProjectWarning], + }); + + expect(filteredWarnings).toEqual([summaryWarning, firstProjectWarning]); + }); +}); diff --git a/tests/unit/formatter.test.ts b/tests/unit/formatter.test.ts new file mode 100644 index 0000000..24d5189 --- /dev/null +++ b/tests/unit/formatter.test.ts @@ -0,0 +1,506 @@ +import { + formatLinkedInProfile, + type LinkedInProfile, +} from '../../src/index.js'; + +describe('formatLinkedInProfile', () => { + test('formats a stable plain-text profile without contact by default', () => { + expect(formatLinkedInProfile(createProfile())).toBe( + [ + 'Orion Helios', + 'Principal Engineer', + 'San Francisco, CA', + '', + 'Summary', + 'Builds reliable parsing systems.', + '', + 'Experience', + 'Principal Engineer at Fixture Co', + 'January 2020 - Present', + 'San Francisco, CA', + 'Leads platform work.', + '', + 'Education', + 'BS Computer Science, Example University', + '2012', + '', + 'Top Skills', + '- TypeScript', + '- Parsing', + '', + 'Languages', + '- English (Native)', + '- French', + '', + 'Projects', + '- Parser Toolkit', + ].join('\n') + ); + }); + + test('formats a stable plain-text profile when explicitly requested', () => { + expect( + formatLinkedInProfile(createProfile(), { + outputFormat: 'plainText', + }) + ).toBe(formatLinkedInProfile(createProfile())); + }); + + test('formats a stable markdown profile without contact', () => { + expect( + formatLinkedInProfile(createProfile(), { + outputFormat: 'markdown', + }) + ).toBe( + [ + '# Orion Helios', + 'Principal Engineer', + 'San Francisco, CA', + '', + '## Summary', + 'Builds reliable parsing systems.', + '', + '## Experience', + 'Principal Engineer at Fixture Co', + 'January 2020 - Present', + 'San Francisco, CA', + 'Leads platform work.', + '', + '## Education', + 'BS Computer Science, Example University', + '2012', + '', + '## Top Skills', + '- TypeScript', + '- Parsing', + '', + '## Languages', + '- English (Native)', + '- French', + '', + '## Projects', + '- Parser Toolkit', + ].join('\n') + ); + }); + + test('includes contact details only when requested', () => { + const profile = createProfile(); + + expect(formatLinkedInProfile(profile)).not.toContain('Email:'); + expect( + formatLinkedInProfile(profile, { + includeContact: true, + }) + ).toContain( + [ + 'Contact', + 'Email: orion@example.com', + 'Phone: +1 555 123 4567', + 'LinkedIn: https://linkedin.com/in/orion', + 'Portfolio: https://example.com/orion', + ].join('\n') + ); + }); + + test('includes contact details in markdown only when requested', () => { + const profile = createProfile(); + + expect( + formatLinkedInProfile(profile, { + outputFormat: 'markdown', + }) + ).not.toContain('## Contact'); + expect( + formatLinkedInProfile(profile, { + includeContact: true, + outputFormat: 'markdown', + }) + ).toContain( + [ + '## Contact', + 'Email: orion@example.com', + 'Phone: +1 555 123 4567', + 'LinkedIn: https://linkedin.com/in/orion', + 'Portfolio: https://example.com/orion', + ].join('\n') + ); + }); + + test('omits contact links without URLs', () => { + expect( + formatLinkedInProfile( + { + ...createEmptyProfile(), + contact: { + links: [ + { + label: 'Portfolio', + rawText: 'Portfolio', + url: '', + }, + { + rawText: 'https://example.com', + url: 'https://example.com', + }, + ], + }, + }, + { + includeContact: true, + } + ) + ).toBe(['Contact', 'https://example.com'].join('\n')); + }); + + test('skips malformed contact link entries without crashing', () => { + const profileWithMalformedLinks = JSON.parse( + JSON.stringify({ + ...createEmptyProfile(), + contact: { + links: [ + null, + { + label: ' Portfolio ', + rawText: 'Portfolio', + url: ' https://example.com ', + }, + { + label: 'No URL', + rawText: 'No URL', + }, + ], + }, + }) + ); + + expect( + formatLinkedInProfile(profileWithMalformedLinks, { + includeContact: true, + }) + ).toBe(['Contact', 'Portfolio: https://example.com'].join('\n')); + }); + + test('omits contact links that duplicate the canonical LinkedIn URL', () => { + expect( + formatLinkedInProfile( + { + ...createEmptyProfile(), + contact: { + links: [ + { + label: 'LinkedIn', + rawText: 'LinkedIn', + url: 'https://linkedin.com/in/orion', + }, + { + label: 'Portfolio', + rawText: 'Portfolio', + url: 'https://example.com/orion', + }, + ], + linkedin_url: ' https://linkedin.com/in/orion ', + }, + }, + { + includeContact: true, + } + ) + ).toBe( + [ + 'Contact', + 'LinkedIn: https://linkedin.com/in/orion', + 'Portfolio: https://example.com/orion', + ].join('\n') + ); + }); + + test('omits LinkedIn contact links with common URL variations', () => { + expect( + formatLinkedInProfile( + { + ...createEmptyProfile(), + contact: { + links: [ + { + label: 'LinkedIn', + rawText: 'LinkedIn', + url: 'HTTP://WWW.LinkedIn.com/in/ORION/', + }, + { + label: 'LinkedIn', + rawText: 'LinkedIn', + url: 'www.LinkedIn.com/in/ORION/', + }, + { + label: 'Portfolio', + rawText: 'Portfolio', + url: 'https://example.com/orion', + }, + ], + linkedin_url: 'https://linkedin.com/in/orion', + }, + }, + { + includeContact: true, + } + ) + ).toBe( + [ + 'Contact', + 'LinkedIn: https://linkedin.com/in/orion', + 'Portfolio: https://example.com/orion', + ].join('\n') + ); + }); + + test('normalizes whitespace and skips malformed contact links in markdown', () => { + const profileWithMalformedLinks = JSON.parse( + JSON.stringify({ + ...createEmptyProfile(), + contact: { + links: [ + null, + { + label: ' Portfolio ', + rawText: 'Portfolio', + url: ' https://example.com ', + }, + { + label: 'No URL', + rawText: 'No URL', + }, + ], + }, + name: ' Cassandra Troy ', + summary: 'Builds\n\ncareful\tinterfaces.', + }) + ); + + expect( + formatLinkedInProfile(profileWithMalformedLinks, { + includeContact: true, + outputFormat: 'markdown', + }) + ).toBe( + [ + '# Cassandra Troy', + '', + '## Contact', + 'Portfolio: https://example.com', + '', + '## Summary', + 'Builds careful interfaces.', + ].join('\n') + ); + }); + + test('separates multiple experience and education entries', () => { + expect( + formatLinkedInProfile({ + ...createEmptyProfile(), + education: [ + { + degree: 'BS Computer Science', + institution: 'Example University', + year: '2012', + }, + { + degree: 'MS Systems', + institution: 'Northern College', + year: '2014', + }, + ], + experience: [ + { + company: 'Fixture Co', + description: 'Built parsing tools.', + duration: '2020 - 2022', + title: 'Engineer', + }, + { + company: 'Example Labs', + description: 'Led platform work.', + duration: '2022 - Present', + title: 'Senior Engineer', + }, + ], + }) + ).toBe( + [ + 'Experience', + 'Engineer at Fixture Co', + '2020 - 2022', + 'Built parsing tools.', + '', + 'Senior Engineer at Example Labs', + '2022 - Present', + 'Led platform work.', + '', + 'Education', + 'BS Computer Science, Example University', + '2012', + '', + 'MS Systems, Northern College', + '2014', + ].join('\n') + ); + }); + + test('normalizes whitespace and skips empty sections', () => { + expect( + formatLinkedInProfile({ + ...createEmptyProfile(), + name: ' Cassandra Troy ', + summary: 'Builds\n\ncareful\tinterfaces.', + }) + ).toBe( + ['Cassandra Troy', '', 'Summary', 'Builds careful interfaces.'].join('\n') + ); + }); + + test('returns an empty string when every section is empty', () => { + expect( + formatLinkedInProfile(createEmptyProfile(), { + includeContact: true, + }) + ).toBe(''); + }); + + test('returns an empty string for markdown when every section is empty', () => { + expect( + formatLinkedInProfile(createEmptyProfile(), { + includeContact: true, + outputFormat: 'markdown', + }) + ).toBe(''); + }); + + test('formats sparse entries and skips blank language names', () => { + expect( + formatLinkedInProfile( + { + ...createEmptyProfile(), + contact: { + links: [ + { + rawText: 'https://example.com', + url: 'https://example.com', + }, + ], + }, + education: [ + { + degree: '', + institution: 'Example University', + }, + ], + experience: [ + { + company: '', + duration: '', + title: 'Advisor', + }, + ], + languages: [ + { + language: '', + proficiency: 'Native', + }, + { + language: 'Spanish', + proficiency: '', + }, + ], + }, + { + includeContact: true, + } + ) + ).toBe( + [ + 'Contact', + 'https://example.com', + '', + 'Experience', + 'Advisor', + '', + 'Education', + 'Example University', + '', + 'Languages', + '- Spanish', + ].join('\n') + ); + }); +}); + +function createProfile(): LinkedInProfile { + return { + certifications: [], + contact: { + email: 'orion@example.com', + links: [ + { + label: 'Portfolio', + rawText: 'Portfolio', + url: 'https://example.com/orion', + }, + ], + linkedin_url: 'https://linkedin.com/in/orion', + phone: '+1 555 123 4567', + }, + education: [ + { + degree: 'BS Computer Science', + institution: 'Example University', + year: '2012', + }, + ], + experience: [ + { + company: 'Fixture Co', + description: 'Leads platform work.', + duration: 'January 2020 - Present', + location: 'San Francisco, CA', + title: 'Principal Engineer', + }, + ], + experience_groups: [], + headline: 'Principal Engineer', + honors_awards: [], + languages: [ + { + language: 'English', + proficiency: 'Native', + }, + { + language: 'French', + proficiency: 'Unknown', + }, + ], + location: 'San Francisco, CA', + name: 'Orion Helios', + projects: ['Parser Toolkit'], + publications: [], + summary: 'Builds reliable parsing systems.', + top_skills: ['TypeScript', 'Parsing'], + volunteer_work: [], + }; +} + +function createEmptyProfile(): LinkedInProfile { + return { + certifications: [], + contact: {}, + education: [], + experience: [], + experience_groups: [], + honors_awards: [], + languages: [], + projects: [], + publications: [], + top_skills: [], + volunteer_work: [], + }; +} diff --git a/tests/unit/identity-structural.test.ts b/tests/unit/identity-structural.test.ts new file mode 100644 index 0000000..8e8c0d9 --- /dev/null +++ b/tests/unit/identity-structural.test.ts @@ -0,0 +1,145 @@ +import { IdentityStructuralParser } from '../../src/parsers/identity-structural.js'; +import type { StructuralLine } from '../../src/utils/structural-lines.js'; + +function line({ + column = 'right', + fontSize = 10, + text, + y, +}: { + column?: StructuralLine['column']; + fontSize?: number; + text: string; + y: number; +}): StructuralLine { + return { + column, + fontSize, + height: fontSize, + text, + width: text.length * 5, + x: column === 'left' ? 30 : 220, + y, + }; +} + +describe('IdentityStructuralParser', () => { + test('uses the largest main-column identity line as the name', () => { + const identity = IdentityStructuralParser.parse([ + line({ column: 'left', text: 'Contact', y: 760 }), + line({ + column: 'left', + text: 'www.linkedin.com/in/ariadne-minos', + y: 740, + }), + line({ fontSize: 26, text: 'ARIADNE MINOS', y: 760 }), + line({ fontSize: 11, text: 'Strategic Planning Advisor', y: 730 }), + line({ + fontSize: 11, + text: 'São Paulo, São Paulo, Brasil', + y: 710, + }), + line({ fontSize: 16, text: 'Experience', y: 680 }), + ]); + + expect(identity).toEqual( + expect.objectContaining({ + headline: 'Strategic Planning Advisor', + linkedinUrl: 'https://linkedin.com/in/ariadne-minos', + location: 'São Paulo, São Paulo, Brasil', + name: 'ARIADNE MINOS', + }) + ); + }); + + test('normalizes LinkedIn URLs split after the profile path', () => { + const identity = IdentityStructuralParser.parse([ + line({ column: 'left', text: 'Contact', y: 760 }), + line({ column: 'left', text: 'www.linkedin.com/in/', y: 740 }), + line({ column: 'left', text: 'theseusaegeus (LinkedIn)', y: 720 }), + line({ fontSize: 26, text: 'Theseus Aegeus', y: 760 }), + line({ fontSize: 16, text: 'Experience', y: 700 }), + ]); + + expect(identity.linkedinUrl).toBe('https://linkedin.com/in/theseusaegeus'); + }); + + test('keeps company-at headlines and non-US locations', () => { + const identity = IdentityStructuralParser.parse([ + line({ fontSize: 26, text: "Lugh O'Nuada", y: 760 }), + line({ fontSize: 11, text: 'CTO @ Example Labs', y: 730 }), + line({ + fontSize: 11, + text: 'München, Bayern, Deutschland', + y: 710, + }), + line({ fontSize: 16, text: 'Education', y: 680 }), + ]); + + expect(identity.name).toBe("Lugh O'Nuada"); + expect(identity.headline).toBe('CTO @ Example Labs'); + expect(identity.location).toBe('München, Bayern, Deutschland'); + }); + + test('keeps country-only locations out of the headline', () => { + const identity = IdentityStructuralParser.parse([ + line({ fontSize: 26, text: 'Freya Vanir', y: 760 }), + line({ + fontSize: 12, + text: 'Web2.5 Finance & Payments Innovation', + y: 730, + }), + line({ fontSize: 12, text: 'United States', y: 710 }), + line({ fontSize: 16, text: 'Summary', y: 680 }), + ]); + + expect(identity.headline).toBe('Web2.5 Finance & Payments Innovation'); + expect(identity.location).toBe('United States'); + }); + + test('returns warnings for malformed sidebar sections without identity candidates', () => { + const result = IdentityStructuralParser.parseWithWarnings([ + line({ column: 'left', text: 'Contact', y: 760 }), + line({ column: 'left', text: 'linkedin.com/in/', y: 740 }), + line({ column: 'left', text: 'Top Skills', y: 720 }), + line({ column: 'left', text: 'Languages', y: 700 }), + line({ fontSize: 16, text: 'Experience', y: 760 }), + ]); + + expect(result.value).toEqual({ + headline: undefined, + linkedinUrl: undefined, + location: undefined, + name: undefined, + topSkills: [], + }); + expect(result.warnings).toEqual([ + expect.objectContaining({ + field: 'section', + section: 'top_skills', + }), + expect.objectContaining({ + field: 'linkedin_url', + section: 'contact', + }), + ]); + }); + + test('uses a later larger identity line and unbounded top skills', () => { + const identity = IdentityStructuralParser.parse([ + line({ column: 'left', text: 'Top Skills', y: 760 }), + line({ column: 'left', text: 'TypeScript', y: 740 }), + line({ column: 'left', text: 'Product Strategy', y: 720 }), + line({ fontSize: 12, text: 'Technical Advisor', y: 760 }), + line({ fontSize: 26, text: 'Artemis Selene', y: 730 }), + ]); + + expect(identity).toEqual({ + headline: undefined, + linkedinUrl: undefined, + location: undefined, + name: 'Artemis Selene', + topSkills: ['TypeScript', 'Product Strategy'], + }); + }); +}); diff --git a/tests/unit/index-warning-filter.test.ts b/tests/unit/index-warning-filter.test.ts new file mode 100644 index 0000000..c90e7d7 --- /dev/null +++ b/tests/unit/index-warning-filter.test.ts @@ -0,0 +1,227 @@ +import { jest } from '@jest/globals'; +import { parseLinkedInPDF } from '../../src/index.js'; +import { BasicInfoParser } from '../../src/parsers/basic-info.js'; +import { EducationParser } from '../../src/parsers/education.js'; +import { ExperienceStructuralParser } from '../../src/parsers/experience-structural.js'; +import { ExtraSectionParser } from '../../src/parsers/extra-sections.js'; +import { IdentityStructuralParser } from '../../src/parsers/identity-structural.js'; +import { ListParser } from '../../src/parsers/lists.js'; +import { StructuralParser } from '../../src/parsers/structural-parser.js'; +import type { + Education, + SectionParseWarning, +} from '../../src/types/profile.js'; +import type { TextItem, WorkExperience } from '../../src/types/structural.js'; + +const contactWarning: SectionParseWarning = { + code: 'section_parse_warning', + field: 'contact', + message: 'Detected a contact section but could not extract contact fields', + section: 'contact', +}; + +const summaryWarning: SectionParseWarning = { + code: 'section_parse_warning', + field: 'summary', + message: 'Detected a summary section but could not extract summary text', + section: 'summary', +}; + +describe('parseLinkedInPDF warning filtering', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('suppresses contact section warnings when structural identity resolves contact data', async () => { + mockBinaryParse({ + basicInfoWarnings: [contactWarning, summaryWarning], + linkedinUrl: 'https://linkedin.com/in/daphne-laurel', + }); + + const result = await parseLinkedInPDF(new Uint8Array([1, 2, 3])); + + expect(result.profile.contact.linkedin_url).toBe( + 'https://linkedin.com/in/daphne-laurel' + ); + expect(result.warnings).toEqual( + expect.arrayContaining([expect.objectContaining(summaryWarning)]) + ); + expect(result.warnings).not.toEqual( + expect.arrayContaining([expect.objectContaining(contactWarning)]) + ); + }); + + test('keeps contact section warnings when no contact data is resolved', async () => { + mockBinaryParse({ + basicInfoWarnings: [contactWarning], + linkedinUrl: undefined, + }); + + const result = await parseLinkedInPDF(new Uint8Array([1, 2, 3])); + + expect(result.profile.contact.linkedin_url).toBeUndefined(); + expect(result.warnings).toEqual( + expect.arrayContaining([expect.objectContaining(contactWarning)]) + ); + }); + + test('prefers structural skills and removes phone numbers echoed in profile URLs', async () => { + mockBinaryParse({ + basicInfoContact: { + phone: '+1 415 555 1212', + }, + basicInfoWarnings: [], + education: [ + { + degree: 'Bachelor of Science', + institution: 'Example University', + location: '', + year: '2020', + }, + ], + linkedinUrl: 'https://linkedin.com/in/14155551212', + topSkills: ['TypeScript'], + workExperiences: [ + { + organization: 'Example Labs', + positions: [ + { + description: '', + duration: '', + title: 'Advisor', + }, + ], + totalDuration: '1 year', + }, + ], + }); + + const result = await parseLinkedInPDF(new Uint8Array([1, 2, 3])); + + expect(result.profile.top_skills).toEqual(['TypeScript']); + expect(result.profile.contact).toEqual({ + linkedin_url: 'https://linkedin.com/in/14155551212', + }); + expect(result.profile.experience).toEqual([ + expect.objectContaining({ + company: 'Example Labs', + title: 'Advisor', + }), + ]); + expect(result.profile.experience_groups).toEqual([ + expect.objectContaining({ + company: 'Example Labs', + totalDuration: '1 year', + }), + ]); + expect(EducationParser.parseWithWarnings).not.toHaveBeenCalled(); + }); +}); + +function mockBinaryParse({ + basicInfoContact = {}, + basicInfoWarnings, + education = [], + linkedinUrl, + topSkills = [], + workExperiences = [], +}: { + basicInfoContact?: { + email?: string; + linkedin_url?: string; + phone?: string; + }; + basicInfoWarnings: SectionParseWarning[]; + education?: Education[]; + linkedinUrl: string | undefined; + topSkills?: string[]; + workExperiences?: WorkExperience[]; +}): void { + const textItem = createTextItem(); + + jest.spyOn(StructuralParser, 'extractStructuredText').mockResolvedValue({ + layout: { + type: 'single-column', + }, + textItems: [textItem], + }); + jest + .spyOn(StructuralParser, 'groupTextByProximity') + .mockReturnValue([[textItem]]); + jest + .spyOn(StructuralParser, 'combineGroupedText') + .mockReturnValue([ + 'Daphne Laurel', + 'Principal Parser', + 'Contact', + 'Available on request', + 'Experience', + 'Example Labs', + 'Engineer', + 'January 2020 - Present', + ]); + jest.spyOn(BasicInfoParser, 'parseStructuralWithWarnings').mockReturnValue({ + value: { + contact: basicInfoContact, + headline: 'Principal Parser', + location: 'Oakland, California, United States', + name: 'Daphne Laurel', + }, + warnings: basicInfoWarnings, + }); + jest.spyOn(IdentityStructuralParser, 'parseWithWarnings').mockReturnValue({ + value: { + linkedinUrl, + topSkills, + }, + warnings: [], + }); + jest.spyOn(ListParser, 'parseSkillsWithWarnings').mockReturnValue({ + value: [], + warnings: [], + }); + jest + .spyOn(ListParser, 'parseStructuralLanguagesWithWarnings') + .mockReturnValue({ + value: [], + warnings: [], + }); + jest + .spyOn(ExtraSectionParser, 'parseStructuralWithWarnings') + .mockReturnValue({ + value: { + certifications: [], + honors_awards: [], + projects: [], + publications: [], + volunteer_work: [], + }, + warnings: [], + }); + jest + .spyOn(ExperienceStructuralParser, 'parseExperienceWithWarnings') + .mockReturnValue({ + value: workExperiences, + warnings: [], + }); + jest.spyOn(EducationParser, 'parseStructuralWithWarnings').mockReturnValue({ + value: education, + warnings: [], + }); + jest.spyOn(EducationParser, 'parseWithWarnings').mockReturnValue({ + value: [], + warnings: [], + }); +} + +function createTextItem(): TextItem { + return { + fontFamily: 'Helvetica', + fontSize: 12, + height: 12, + text: 'Daphne Laurel', + width: 80, + x: 220, + y: 700, + }; +} diff --git a/tests/unit/inspect-pdf-source.test.ts b/tests/unit/inspect-pdf-source.test.ts new file mode 100644 index 0000000..28c45a6 --- /dev/null +++ b/tests/unit/inspect-pdf-source.test.ts @@ -0,0 +1,193 @@ +import * as path from 'node:path'; +import { + createFailureManifestEntry, + createItemOverlayHtml, + normalizeUnpdfTextItem, + resolvePdfPaths, + resolveBundleOutputDirs, +} from '../../scripts/inspect-pdf-source.mjs'; + +interface ReadSortedPdfFileNamesCall { + emptyMessage: string; + samplesDir: string; +} + +function fakePdfDirectory(fileNames: string[]): { + calls: ReadSortedPdfFileNamesCall[]; + dependencies: { + readSortedPdfFileNames: ( + samplesDir: string, + emptyMessage: string + ) => Promise; + }; +} { + const calls: ReadSortedPdfFileNamesCall[] = []; + + return { + calls, + dependencies: { + async readSortedPdfFileNames(samplesDir, emptyMessage) { + calls.push({ emptyMessage, samplesDir }); + + return fileNames; + }, + }, + }; +} + +describe('inspect PDF source overlay helpers', () => { + test('derives inspectable text item coordinates from PDF.js transform matrices', () => { + const rawTextItem = { + height: 12, + str: 'Cassandra Troy', + transform: [1, 0, 0, 1, 72.25, 650.5], + width: 42, + }; + + expect(normalizeUnpdfTextItem(rawTextItem)).toEqual({ + ...rawTextItem, + x: 72.25, + y: 650.5, + }); + }); + + test('normalizes nullish unpdf text items to an overlay-safe blank item', () => { + const blankTextItem = { + height: 0, + str: '', + width: 0, + x: 0, + y: 0, + }; + + expect(normalizeUnpdfTextItem(null)).toEqual(blankTextItem); + expect(normalizeUnpdfTextItem(undefined)).toEqual(blankTextItem); + }); + + test('renders normalized unpdf text items without NaN overlay coordinates', () => { + const normalizedTextItem = normalizeUnpdfTextItem({ + height: 12, + str: 'Cassandra Troy', + transform: [1, 0, 0, 1, 72.25, 650.5], + width: 42, + }); + + const html = createItemOverlayHtml({ + height: 792, + item: normalizedTextItem, + parserLayout: undefined, + }); + + expect(html).toContain('x="144.50"'); + expect(html).toContain('y="259.00"'); + expect(html).toContain('Cassandra Troy'); + expect(html).not.toContain('NaN'); + }); + + test('keeps multi-file bundle directories distinct for duplicate PDF stems', () => { + const outputDirs = resolveBundleOutputDirs({ + outputOption: '.debug/source-inspect', + pdfPaths: [ + path.join(process.cwd(), 'samples/team-a/Profile.pdf'), + path.join(process.cwd(), 'samples/team-b/Profile.pdf'), + ], + }); + + expect(new Set(outputDirs).size).toBe(2); + expect(outputDirs).toEqual([ + expect.stringMatching(/Profile-[a-f0-9]{8}$/), + expect.stringMatching(/Profile-[a-f0-9]{8}$/), + ]); + }); + + test('preserves explicit output directory for a single PDF bundle', () => { + expect( + resolveBundleOutputDirs({ + outputOption: '.debug/exact-output', + pdfPaths: [path.join(process.cwd(), 'samples/Profile.pdf')], + }) + ).toEqual([path.join(process.cwd(), '.debug/exact-output')]); + }); + + test('defaults to inspecting PDFs from the samples directory', async () => { + const { calls, dependencies } = fakePdfDirectory([ + 'Alpha Profile.pdf', + 'Beta Profile.pdf', + ]); + + await expect( + resolvePdfPaths({ + dependencies, + positionalPdfPaths: [], + }) + ).resolves.toEqual([ + path.join(process.cwd(), 'samples/Alpha Profile.pdf'), + path.join(process.cwd(), 'samples/Beta Profile.pdf'), + ]); + expect(calls).toEqual([ + { + emptyMessage: expect.stringContaining('No PDF files found'), + samplesDir: path.join(process.cwd(), 'samples'), + }, + ]); + }); + + test('uses explicit positional PDFs instead of the default samples directory', async () => { + const { calls, dependencies } = fakePdfDirectory(['Unused.pdf']); + + await expect( + resolvePdfPaths({ + dependencies, + positionalPdfPaths: ['custom/Profile.pdf'], + }) + ).resolves.toEqual([path.join(process.cwd(), 'custom/Profile.pdf')]); + expect(calls).toEqual([]); + }); + + test('keeps the explicit samples directory option when provided', async () => { + const { calls, dependencies } = fakePdfDirectory(['Profile.pdf']); + + await expect( + resolvePdfPaths({ + dependencies, + positionalPdfPaths: [], + samplesOption: 'fixtures/pdfs', + }) + ).resolves.toEqual([path.join(process.cwd(), 'fixtures/pdfs/Profile.pdf')]); + expect(calls).toEqual([ + { + emptyMessage: expect.stringContaining('No PDF files found'), + samplesDir: path.join(process.cwd(), 'fixtures/pdfs'), + }, + ]); + }); + + test('rejects simultaneous samples option and positional PDFs', async () => { + const { calls, dependencies } = fakePdfDirectory(['Unused.pdf']); + + await expect( + resolvePdfPaths({ + dependencies, + positionalPdfPaths: ['custom/Profile.pdf'], + samplesOption: 'fixtures/pdfs', + }) + ).rejects.toThrow( + 'Cannot specify both --samples and positional PDF paths.' + ); + expect(calls).toEqual([]); + }); + + test('includes failure detail artifact paths in manifest entries', () => { + expect( + createFailureManifestEntry({ + artifact: 'pdfinfo.txt', + message: 'command failed', + relativePath: 'pdfinfo.error.txt', + }) + ).toEqual({ + artifact: 'pdfinfo.txt', + message: 'command failed', + relativePath: 'pdfinfo.error.txt', + }); + }); +}); diff --git a/tests/unit/json-fixtures.test.ts b/tests/unit/json-fixtures.test.ts new file mode 100644 index 0000000..853041d --- /dev/null +++ b/tests/unit/json-fixtures.test.ts @@ -0,0 +1,853 @@ +import { + formatErrorMessage, + verifyJsonFixtures, + writeJsonFixtures, + type JsonFixtureDependencies, + type JsonFixtureDirectoryEntry, +} from '../../src/json-fixtures.js'; +import type { ParseOptions, ParseResult } from '../../src/index.js'; + +describe('JSON fixture batch operations', () => { + test('writes JSON files for top-level PDFs only', async () => { + const memoryFixtures = createMemoryJsonFixtureDependencies({ + binaryFiles: new Map([['/baselines/Profile.pdf', new Uint8Array([1])]]), + directories: new Set(['/baselines']), + directoryEntries: new Map([ + [ + '/baselines', + [ + { kind: 'file', name: 'Profile.pdf' }, + { kind: 'file', name: 'notes.txt' }, + { kind: 'directory', name: 'Nested.pdf' }, + ], + ], + ]), + }); + + const result = await writeJsonFixtures({ + dependencies: memoryFixtures.dependencies, + folderPath: '/baselines', + includeRawText: false, + outputFormat: 'pretty', + overwriteExisting: false, + }); + + expect(result).toEqual({ + exitCode: 0, + stderr: '', + stdout: expect.stringContaining('Wrote 1 JSON file(s)'), + }); + expect(memoryFixtures.readFilePaths).toEqual(['/baselines/Profile.pdf']); + expect(memoryFixtures.writtenTextFiles).toEqual([ + { + content: `${JSON.stringify(defaultParseResult, null, 2)}\n`, + filePath: '/baselines/Profile.json', + }, + ]); + }); + + test('does not replace existing JSON files without force', async () => { + const memoryFixtures = createMemoryJsonFixtureDependencies({ + binaryFiles: new Map([['/baselines/Profile.pdf', new Uint8Array([1])]]), + directories: new Set(['/baselines']), + directoryEntries: new Map([ + [ + '/baselines', + [ + { kind: 'file', name: 'Profile.pdf' }, + { kind: 'file', name: 'Profile.json' }, + ], + ], + ]), + textFiles: new Map([['/baselines/Profile.json', '{}']]), + }); + + const result = await writeJsonFixtures({ + dependencies: memoryFixtures.dependencies, + folderPath: '/baselines', + includeRawText: false, + outputFormat: 'pretty', + overwriteExisting: false, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + 'JSON already exists: /baselines/Profile.json' + ); + expect(memoryFixtures.readFilePaths).toEqual([]); + expect(memoryFixtures.writtenTextFiles).toEqual([]); + }); + + test('reports parse failures while writing JSON fixtures', async () => { + const memoryFixtures = createMemoryJsonFixtureDependencies({ + binaryFiles: new Map([['/baselines/Profile.pdf', new Uint8Array([1])]]), + directories: new Set(['/baselines']), + directoryEntries: new Map([ + ['/baselines', [{ kind: 'file', name: 'Profile.pdf' }]], + ]), + parsePdf: async () => { + throw new Error('write parse failed'); + }, + }); + + const result = await writeJsonFixtures({ + dependencies: memoryFixtures.dependencies, + folderPath: '/baselines', + includeRawText: false, + outputFormat: 'pretty', + overwriteExisting: false, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + '/baselines/Profile.pdf: write parse failed' + ); + expect(memoryFixtures.writtenTextFiles).toEqual([]); + }); + + test('overwrites existing JSON files with compact output and raw text parsing when forced', async () => { + const memoryFixtures = createMemoryJsonFixtureDependencies({ + binaryFiles: new Map([['/baselines/Profile.PDF', new Uint8Array([1])]]), + directories: new Set(['/baselines']), + directoryEntries: new Map([ + [ + '/baselines', + [ + { kind: 'file', name: 'Profile.PDF' }, + { kind: 'file', name: 'Profile.JSON' }, + ], + ], + ]), + textFiles: new Map([['/baselines/Profile.JSON', '{}']]), + }); + + const result = await writeJsonFixtures({ + dependencies: memoryFixtures.dependencies, + folderPath: '/baselines', + includeRawText: true, + outputFormat: 'compact', + overwriteExisting: true, + }); + + expect(result.exitCode).toBe(0); + expect(memoryFixtures.parseOptions).toEqual([{ includeRawText: true }]); + expect(memoryFixtures.writtenTextFiles).toEqual([ + { + content: `${JSON.stringify(defaultParseResult)}\n`, + filePath: '/baselines/Profile.JSON', + }, + ]); + }); + + test('verifies matching PDF and JSON pairs with JSON-normalized generated output', async () => { + const memoryFixtures = createMemoryJsonFixtureDependencies({ + binaryFiles: new Map([['/baselines/Profile.PDF', new Uint8Array([1])]]), + directories: new Set(['/baselines']), + directoryEntries: new Map([ + [ + '/baselines', + [ + { kind: 'file', name: 'Profile.PDF' }, + { kind: 'file', name: 'profile.json' }, + ], + ], + ]), + textFiles: new Map([ + ['/baselines/profile.json', JSON.stringify(defaultParseResult)], + ]), + }); + + const result = await verifyJsonFixtures({ + dependencies: memoryFixtures.dependencies, + folderPath: '/baselines', + includeRawText: true, + }); + + expect(result).toEqual({ + exitCode: 0, + stderr: '', + stdout: expect.stringContaining('Verified 1 PDF/JSON pair(s)'), + }); + expect(memoryFixtures.parseOptions).toEqual([{ includeRawText: true }]); + expect(memoryFixtures.readFilePaths).toEqual(['/baselines/Profile.PDF']); + }); + + test('verifies structurally equivalent JSON regardless of formatting or key order', async () => { + const memoryFixtures = createMemoryJsonFixtureDependencies({ + binaryFiles: new Map([['/baselines/Profile.pdf', new Uint8Array([1])]]), + directories: new Set(['/baselines']), + directoryEntries: new Map([ + [ + '/baselines', + [ + { kind: 'file', name: 'Profile.pdf' }, + { kind: 'file', name: 'Profile.json' }, + ], + ], + ]), + textFiles: new Map([ + [ + '/baselines/Profile.json', + `{ + "diagnostics": { + "confidence": 0.7, + "isEmpty": false, + "isLikelyLinkedInExport": true, + "sectionsFound": ["experience"] + }, + "warnings": [], + "profile": { + "volunteer_work": [], + "top_skills": [], + "projects": [], + "publications": [], + "honors_awards": [], + "name": "Orion Helios", + "location": "San Francisco, CA", + "languages": [], + "headline": "Fixture headline", + "experience": [ + { + "title": "Fixture Role", + "duration": "January 2020 - Present", + "company": "Fixture Co" + } + ], + "experience_groups": [ + { + "company": "Fixture Co", + "positions": [ + { + "title": "Fixture Role", + "duration": "January 2020 - Present" + } + ] + } + ], + "education": [], + "contact": { + "email": "fixture@example.com" + }, + "certifications": [] + } + }`, + ], + ]), + }); + + const result = await verifyJsonFixtures({ + dependencies: memoryFixtures.dependencies, + folderPath: '/baselines', + includeRawText: false, + }); + + expect(result).toEqual({ + exitCode: 0, + stderr: '', + stdout: expect.stringContaining('Verified 1 PDF/JSON pair(s)'), + }); + }); + + test('prints a compact context diff when generated JSON differs from the fixture', async () => { + const expectedResult: ParseResult = { + ...defaultParseResult, + profile: { + ...defaultParseResult.profile, + name: 'Hermes Trismegistus', + }, + }; + const memoryFixtures = createMemoryJsonFixtureDependencies({ + binaryFiles: new Map([['/baselines/Profile.pdf', new Uint8Array([1])]]), + directories: new Set(['/baselines']), + directoryEntries: new Map([ + [ + '/baselines', + [ + { kind: 'file', name: 'Profile.pdf' }, + { kind: 'file', name: 'Profile.json' }, + ], + ], + ]), + textFiles: new Map([ + ['/baselines/Profile.json', JSON.stringify(expectedResult)], + ]), + }); + + const result = await verifyJsonFixtures({ + dependencies: memoryFixtures.dependencies, + folderPath: '/baselines', + includeRawText: false, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('--- expected'); + expect(result.stderr).toContain('+++ generated'); + expect(result.stderr).toContain('@@ -'); + expect(result.stderr).toContain('- "name": "Hermes Trismegistus"'); + expect(result.stderr).toContain('+ "name": "Orion Helios"'); + expect(result.stderr).not.toContain('"diagnostics": {'); + expect(result.stderr).not.toContain('"company": "Fixture Co"'); + }); + + test('keeps context diffs focused around JSON insertions without positional cascade noise', async () => { + const generatedResult: ParseResult = { + ...defaultParseResult, + profile: { + ...defaultParseResult.profile, + top_skills: ['TypeScript'], + }, + }; + const memoryFixtures = createMemoryJsonFixtureDependencies({ + binaryFiles: new Map([['/baselines/Profile.pdf', new Uint8Array([1])]]), + directories: new Set(['/baselines']), + directoryEntries: new Map([ + [ + '/baselines', + [ + { kind: 'file', name: 'Profile.pdf' }, + { kind: 'file', name: 'Profile.json' }, + ], + ], + ]), + parsePdf: async () => generatedResult, + textFiles: new Map([ + ['/baselines/Profile.json', JSON.stringify(defaultParseResult)], + ]), + }); + + const result = await verifyJsonFixtures({ + dependencies: memoryFixtures.dependencies, + folderPath: '/baselines', + includeRawText: false, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('@@ -'); + expect(result.stderr).toContain('+ "TypeScript"'); + expect(result.stderr).not.toContain('- "volunteer_work": []'); + expect(result.stderr).not.toContain('+ "volunteer_work": []'); + }); + + test('falls back to a bounded linear context diff for large JSON mismatches', async () => { + const expectedSkills = Array.from( + { length: 600 }, + (_, index) => `skill-${index}` + ); + const expectedResult: ParseResult = { + ...defaultParseResult, + profile: { + ...defaultParseResult.profile, + top_skills: expectedSkills, + }, + }; + const generatedResult: ParseResult = { + ...defaultParseResult, + profile: { + ...defaultParseResult.profile, + top_skills: ['inserted', ...expectedSkills], + }, + }; + const memoryFixtures = createMemoryJsonFixtureDependencies({ + binaryFiles: new Map([['/baselines/Profile.pdf', new Uint8Array([1])]]), + directories: new Set(['/baselines']), + directoryEntries: new Map([ + [ + '/baselines', + [ + { kind: 'file', name: 'Profile.pdf' }, + { kind: 'file', name: 'Profile.json' }, + ], + ], + ]), + parsePdf: async () => generatedResult, + textFiles: new Map([ + ['/baselines/Profile.json', JSON.stringify(expectedResult)], + ]), + }); + + const result = await verifyJsonFixtures({ + dependencies: memoryFixtures.dependencies, + folderPath: '/baselines', + includeRawText: false, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('--- expected'); + expect(result.stderr).toContain('+++ generated'); + expect(result.stderr).toContain('- "skill-0",'); + expect(result.stderr).toContain('+ "inserted",'); + expect(result.stderr).toContain('+ "skill-599"'); + }); + + test('prints separate context hunks for distant generated JSON changes', async () => { + const expectedResult: ParseResult = { + ...defaultParseResult, + diagnostics: { + ...defaultParseResult.diagnostics, + confidence: 0.5, + }, + profile: { + ...defaultParseResult.profile, + name: 'Hermes Trismegistus', + }, + }; + const memoryFixtures = createMemoryJsonFixtureDependencies({ + binaryFiles: new Map([['/baselines/Profile.pdf', new Uint8Array([1])]]), + directories: new Set(['/baselines']), + directoryEntries: new Map([ + [ + '/baselines', + [ + { kind: 'file', name: 'Profile.pdf' }, + { kind: 'file', name: 'Profile.json' }, + ], + ], + ]), + textFiles: new Map([ + ['/baselines/Profile.json', JSON.stringify(expectedResult)], + ]), + }); + + const result = await verifyJsonFixtures({ + dependencies: memoryFixtures.dependencies, + folderPath: '/baselines', + includeRawText: false, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr.match(/^@@/gm)).toHaveLength(2); + expect(result.stderr).toContain('- "confidence": 0.5,'); + expect(result.stderr).toContain('+ "confidence": 0.7,'); + expect(result.stderr).toContain('- "name": "Hermes Trismegistus"'); + expect(result.stderr).toContain('+ "name": "Orion Helios"'); + }); + + test('prints JSON keypath changes when requested', async () => { + const expectedResult = { + ...defaultParseResult, + legacy: { + emptyArray: [], + tags: ['old'], + }, + profile: { + ...defaultParseResult.profile, + contact: { + ...defaultParseResult.profile.contact, + email: 'old@example.com', + 'weird.key': 'old value', + }, + name: 'Hermes Trismegistus', + top_skills: ['TypeScript', 'Node.js'], + }, + }; + const generatedResult = { + ...defaultParseResult, + modern: { + emptyObject: {}, + tags: ['new'], + }, + profile: { + ...defaultParseResult.profile, + contact: { + ...defaultParseResult.profile.contact, + email: 'new@example.com', + 'weird.key': 'new value', + }, + top_skills: ['TypeScript', 'Node.js', 'Zod'], + }, + }; + const memoryFixtures = createMemoryJsonFixtureDependencies({ + binaryFiles: new Map([['/baselines/Profile.pdf', new Uint8Array([1])]]), + directories: new Set(['/baselines']), + directoryEntries: new Map([ + [ + '/baselines', + [ + { kind: 'file', name: 'Profile.pdf' }, + { kind: 'file', name: 'Profile.json' }, + ], + ], + ]), + parsePdf: async () => generatedResult, + textFiles: new Map([ + ['/baselines/Profile.json', JSON.stringify(expectedResult)], + ]), + }); + + const result = await verifyJsonFixtures({ + dependencies: memoryFixtures.dependencies, + diffOutputFormat: 'json-paths', + folderPath: '/baselines', + includeRawText: false, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).not.toContain('--- expected'); + expect(result.stderr).toContain( + '~ profile.contact.email: "old@example.com" -> "new@example.com"' + ); + expect(result.stderr).toContain( + '~ profile.contact["weird.key"]: "old value" -> "new value"' + ); + expect(result.stderr).toContain( + '~ profile.name: "Hermes Trismegistus" -> "Orion Helios"' + ); + expect(result.stderr).toContain('+ profile.top_skills[2]: "Zod"'); + expect(result.stderr).toContain('- legacy.emptyArray: []'); + expect(result.stderr).toContain('- legacy.tags[0]: "old"'); + expect(result.stderr).toContain('+ modern.emptyObject: {}'); + expect(result.stderr).toContain('+ modern.tags[0]: "new"'); + }); + + test('prints root JSON keypath changes for unequal root value kinds', async () => { + const memoryFixtures = createMemoryJsonFixtureDependencies({ + binaryFiles: new Map([['/baselines/Profile.pdf', new Uint8Array([1])]]), + directories: new Set(['/baselines']), + directoryEntries: new Map([ + [ + '/baselines', + [ + { kind: 'file', name: 'Profile.pdf' }, + { kind: 'file', name: 'Profile.json' }, + ], + ], + ]), + textFiles: new Map([['/baselines/Profile.json', '"legacy"']]), + }); + + const result = await verifyJsonFixtures({ + dependencies: memoryFixtures.dependencies, + diffOutputFormat: 'json-paths', + folderPath: '/baselines', + includeRawText: false, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('~ $: "legacy" -> {'); + }); + + test('prints context diffs for unequal root value kinds', async () => { + const generatedPrimitive = JSON.parse('"generated"'); + const memoryFixtures = createMemoryJsonFixtureDependencies({ + binaryFiles: new Map([['/baselines/Profile.pdf', new Uint8Array([1])]]), + directories: new Set(['/baselines']), + directoryEntries: new Map([ + [ + '/baselines', + [ + { kind: 'file', name: 'Profile.pdf' }, + { kind: 'file', name: 'Profile.json' }, + ], + ], + ]), + parsePdf: async () => generatedPrimitive, + textFiles: new Map([ + ['/baselines/Profile.json', JSON.stringify(defaultParseResult)], + ]), + }); + + const result = await verifyJsonFixtures({ + dependencies: memoryFixtures.dependencies, + folderPath: '/baselines', + includeRawText: false, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('-{'); + expect(result.stderr).toContain('+"generated"'); + }); + + test('reports invalid JSON, parse failures, and missing fixture pairs', async () => { + const brokenPdfBytes = new Uint8Array([2]); + const memoryFixtures = createMemoryJsonFixtureDependencies({ + binaryFiles: new Map([ + ['/baselines/Broken.pdf', brokenPdfBytes], + ['/baselines/Empty.pdf', new Uint8Array([5])], + ['/baselines/Invalid.pdf', new Uint8Array([3])], + ['/baselines/MissingJson.pdf', new Uint8Array([4])], + ]), + directories: new Set(['/baselines']), + directoryEntries: new Map([ + [ + '/baselines', + [ + { kind: 'file', name: 'Broken.pdf' }, + { kind: 'file', name: 'Broken.json' }, + { kind: 'file', name: 'Empty.pdf' }, + { kind: 'file', name: 'Empty.json' }, + { kind: 'file', name: 'Invalid.pdf' }, + { kind: 'file', name: 'Invalid.json' }, + { kind: 'file', name: 'MissingJson.pdf' }, + { kind: 'file', name: 'Orphan.json' }, + ], + ], + ]), + parsePdf: async input => { + if (input === brokenPdfBytes) { + throw new Error('parse failed'); + } + + return defaultParseResult; + }, + textFiles: new Map([ + ['/baselines/Broken.json', JSON.stringify(defaultParseResult)], + ['/baselines/Empty.json', ''], + ['/baselines/Invalid.json', '{'], + ['/baselines/Orphan.json', JSON.stringify(defaultParseResult)], + ]), + }); + + const result = await verifyJsonFixtures({ + dependencies: memoryFixtures.dependencies, + folderPath: '/baselines', + includeRawText: false, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('/baselines/Broken.pdf: parse failed'); + expect(result.stderr).toContain( + '/baselines/Empty.json: Invalid JSON baseline: Unexpected end of JSON input' + ); + expect(result.stderr).toContain( + '/baselines/Invalid.json: Invalid JSON baseline' + ); + expect(result.stderr).toContain( + '/baselines/MissingJson.pdf: Missing JSON baseline' + ); + expect(result.stderr).toContain( + '/baselines/Orphan.json: Missing PDF source' + ); + }); + + test('handles empty fixture folders consistently', async () => { + const memoryFixtures = createMemoryJsonFixtureDependencies({ + directories: new Set(['/baselines']), + directoryEntries: new Map([['/baselines', []]]), + }); + + const writeResult = await writeJsonFixtures({ + dependencies: memoryFixtures.dependencies, + folderPath: '/baselines', + includeRawText: false, + outputFormat: 'pretty', + overwriteExisting: false, + }); + const verifyResult = await verifyJsonFixtures({ + dependencies: memoryFixtures.dependencies, + folderPath: '/baselines', + includeRawText: false, + }); + + expect(writeResult).toEqual({ + exitCode: 0, + stderr: '', + stdout: 'Wrote 0 JSON file(s) in /baselines.\n', + }); + expect(verifyResult).toEqual({ + exitCode: 1, + stderr: 'Error: No matching PDF/JSON pairs found in /baselines\n', + stdout: '', + }); + }); + + test('reports directory resolution errors', async () => { + const memoryFixtures = createMemoryJsonFixtureDependencies({ + binaryFiles: new Map([['/not-a-directory', new Uint8Array([1])]]), + }); + + const filePathResult = await writeJsonFixtures({ + dependencies: memoryFixtures.dependencies, + folderPath: '/not-a-directory', + includeRawText: false, + outputFormat: 'pretty', + overwriteExisting: false, + }); + const missingPathResult = await verifyJsonFixtures({ + dependencies: memoryFixtures.dependencies, + folderPath: '/missing', + includeRawText: false, + }); + + expect(filePathResult).toEqual({ + exitCode: 1, + stderr: 'Error: Path must be a directory: /not-a-directory\n', + stdout: '', + }); + expect(missingPathResult).toEqual({ + exitCode: 1, + stderr: 'Error: Directory not found: /missing\n', + stdout: '', + }); + }); + + test('formats non-error thrown values and diffs unequal JSON shapes', async () => { + const expectedResult: ParseResult = { + ...defaultParseResult, + warnings: [ + { + code: 'missing_profile_field', + field: 'profile.name', + message: 'Could not extract profile name', + }, + ], + }; + const memoryFixtures = createMemoryJsonFixtureDependencies({ + binaryFiles: new Map([['/baselines/Profile.pdf', new Uint8Array([1])]]), + directories: new Set(['/baselines']), + directoryEntries: new Map([ + [ + '/baselines', + [ + { kind: 'file', name: 'Profile.pdf' }, + { kind: 'file', name: 'Profile.json' }, + ], + ], + ]), + textFiles: new Map([ + ['/baselines/Profile.json', JSON.stringify(expectedResult)], + ]), + }); + + const result = await verifyJsonFixtures({ + dependencies: memoryFixtures.dependencies, + folderPath: '/baselines', + includeRawText: false, + }); + + expect(formatErrorMessage('plain failure')).toBe('plain failure'); + expect(result.stderr).toContain('- "code": "missing_profile_field",'); + expect(result.stderr).toContain('+ "warnings": []'); + }); +}); + +interface MemoryJsonFixtureDependenciesParams { + binaryFiles?: Map; + directories?: Set; + directoryEntries?: Map; + fileExists?: JsonFixtureDependencies['fileExists']; + parsePdf?: JsonFixtureDependencies['parsePdf']; + resolvePath?: (filePath: string) => string; + textFiles?: Map; +} + +interface TextFileWrite { + content: string; + filePath: string; +} + +interface MemoryJsonFixtureDependencies { + dependencies: JsonFixtureDependencies; + parseOptions: ParseOptions[]; + readFilePaths: string[]; + writtenTextFiles: TextFileWrite[]; +} + +const defaultParseResult: ParseResult = { + diagnostics: { + confidence: 0.7, + isEmpty: false, + isLikelyLinkedInExport: true, + sectionsFound: ['experience'], + }, + profile: { + certifications: [], + contact: { + email: 'fixture@example.com', + }, + education: [], + experience: [ + { + company: 'Fixture Co', + duration: 'January 2020 - Present', + location: undefined, + title: 'Fixture Role', + }, + ], + experience_groups: [ + { + company: 'Fixture Co', + positions: [ + { + duration: 'January 2020 - Present', + location: undefined, + title: 'Fixture Role', + }, + ], + }, + ], + headline: 'Fixture headline', + honors_awards: [], + languages: [], + location: 'San Francisco, CA', + name: 'Orion Helios', + projects: [], + publications: [], + summary: undefined, + top_skills: [], + volunteer_work: [], + }, + warnings: [], +}; + +function createMemoryJsonFixtureDependencies( + params: MemoryJsonFixtureDependenciesParams = {} +): MemoryJsonFixtureDependencies { + const binaryFiles = params.binaryFiles ?? new Map(); + const directories = params.directories ?? new Set(); + const directoryEntries = + params.directoryEntries ?? new Map(); + const parseOptions: ParseOptions[] = []; + const readFilePaths: string[] = []; + const textFiles = params.textFiles ?? new Map(); + const writtenTextFiles: TextFileWrite[] = []; + + return { + dependencies: { + directoryExists: directoryPath => directories.has(directoryPath), + fileExists: + params.fileExists ?? + (filePath => + directories.has(filePath) || + binaryFiles.has(filePath) || + textFiles.has(filePath)), + listDirectory: directoryPath => directoryEntries.get(directoryPath) ?? [], + parsePdf: async (input, options) => { + parseOptions.push(options); + + if (params.parsePdf) { + return params.parsePdf(input, options); + } + + return defaultParseResult; + }, + readFile: filePath => { + const file = binaryFiles.get(filePath); + + readFilePaths.push(filePath); + + if (file === undefined) { + throw new Error(`Missing binary file: ${filePath}`); + } + + return file; + }, + readTextFile: filePath => { + const file = textFiles.get(filePath); + + if (file === undefined) { + throw new Error(`Missing text file: ${filePath}`); + } + + return file; + }, + resolvePath: params.resolvePath ?? (filePath => filePath), + writeTextFile: (filePath, content) => { + textFiles.set(filePath, content); + writtenTextFiles.push({ content, filePath }); + }, + }, + parseOptions, + readFilePaths, + writtenTextFiles, + }; +} diff --git a/tests/unit/library.test.ts b/tests/unit/library.test.ts index 6c4435c..d96f00e 100644 --- a/tests/unit/library.test.ts +++ b/tests/unit/library.test.ts @@ -1,9 +1,19 @@ import * as fs from 'fs'; import * as path from 'path'; -import { parseLinkedInPDF } from '../../src/index.js'; +import { + LinkedInProfileParseError, + parseLinkedInPDF, +} from '../../src/index.js'; +import type { LinkedInProfile } from '../../src/index.js'; +import { expectedTestResumeProfile } from '../fixtures/expected-test-resume-profile.js'; describe('LinkedIn PDF Parser Library', () => { - const testPdfPath = path.join(process.cwd(), 'test_resume.pdf'); + const testPdfPath = path.join( + process.cwd(), + 'tests', + 'fixtures', + 'test_resume.pdf' + ); let pdfBuffer: Buffer; beforeAll(() => { @@ -14,14 +24,45 @@ describe('LinkedIn PDF Parser Library', () => { }); describe('Basic Parsing', () => { - test('should parse PDF buffer successfully', async () => { + test('should parse Node Buffer successfully', async () => { const result = await parseLinkedInPDF(pdfBuffer); - expect(result.profile).toBeDefined(); - expect(result.profile.name).toBeTruthy(); - expect(result.profile.contact).toBeDefined(); - expect(result.profile.contact.email).toBeTruthy(); - expect(result.profile.contact.email).toContain('@'); + expect(result.profile.name).toBe(expectedTestResumeProfile.name); + expect(result.profile.contact.email).toBeUndefined(); + expect(result.profile.contact.linkedin_url).toBe( + expectedTestResumeProfile.contact.linkedin_url + ); + }); + + test('should parse Uint8Array successfully', async () => { + const result = await parseLinkedInPDF(new Uint8Array(pdfBuffer)); + + expect(result.profile.name).toBe(expectedTestResumeProfile.name); + expect(result.profile.contact.email).toBeUndefined(); + }); + + test('should parse ArrayBuffer successfully', async () => { + const arrayBuffer = new ArrayBuffer(pdfBuffer.byteLength); + new Uint8Array(arrayBuffer).set(pdfBuffer); + const result = await parseLinkedInPDF(arrayBuffer); + + expect(result.profile.name).toBe(expectedTestResumeProfile.name); + expect(result.profile.contact.email).toBeUndefined(); + }); + + test('should parse extracted text directly', async () => { + const result = await parseLinkedInPDF(` + Atalanta Calydon + atalanta.calydon@example.com + Software Engineer + + Experience + Developer at TextCo + 2021-2024 + `); + + expect(result.profile.name).toBe('Atalanta Calydon'); + expect(result.profile.contact.email).toBe('atalanta.calydon@example.com'); }); test('should parse PDF with options', async () => { @@ -29,15 +70,13 @@ describe('LinkedIn PDF Parser Library', () => { includeRawText: true, }); - expect(result.profile).toBeDefined(); - expect(result.rawText).toBeDefined(); - expect(typeof result.rawText).toBe('string'); - expect(result.rawText!.length).toBeGreaterThan(100); + expect(result.profile.name).toBe(expectedTestResumeProfile.name); + expect(result.rawText).toHaveLength(13078); }); }); describe('Profile Structure Validation', () => { - let profile: any; + let profile: LinkedInProfile; beforeAll(async () => { const result = await parseLinkedInPDF(pdfBuffer); @@ -45,10 +84,10 @@ describe('LinkedIn PDF Parser Library', () => { }); test('should have required fields', () => { - expect(profile.name).toBeTruthy(); - expect(profile.contact).toBeDefined(); - expect(profile.experience).toBeDefined(); - expect(profile.education).toBeDefined(); + expect(profile.name).toBe(expectedTestResumeProfile.name); + expect(profile.headline).toBe(expectedTestResumeProfile.headline); + expect(profile.location).toBe(expectedTestResumeProfile.location); + expect(profile.contact).toEqual(expectedTestResumeProfile.contact); }); test('should have correct data types', () => { @@ -56,87 +95,102 @@ describe('LinkedIn PDF Parser Library', () => { expect(typeof profile.headline).toBe('string'); expect(typeof profile.location).toBe('string'); expect(typeof profile.contact).toBe('object'); - expect(Array.isArray(profile.top_skills)).toBe(true); - expect(Array.isArray(profile.languages)).toBe(true); - expect(Array.isArray(profile.experience)).toBe(true); - expect(Array.isArray(profile.education)).toBe(true); + expect(profile.top_skills).toEqual(expectedTestResumeProfile.top_skills); + expect(profile.languages).toEqual(expectedTestResumeProfile.languages); + expect(profile.experience).toHaveLength( + expectedTestResumeProfile.experienceLength + ); + expect(profile.education).toHaveLength( + expectedTestResumeProfile.educationLength + ); }); test('should extract contact information', () => { const contact = profile.contact; - expect(contact.email).toBeTruthy(); - expect(contact.email).toContain('@'); - - if (contact.linkedin_url) { - expect(contact.linkedin_url).toContain('linkedin.com'); - } + expect(contact).toEqual(expectedTestResumeProfile.contact); }); test('should have reasonable data completeness', () => { - expect(profile.experience.length).toBeGreaterThanOrEqual(0); - expect(profile.education.length).toBeGreaterThanOrEqual(0); - - if (profile.experience.length > 0) { - const firstExp = profile.experience[0]; - expect(firstExp.title).toBeTruthy(); - } - - if (profile.education.length > 0) { - const firstEdu = profile.education[0]; - expect(firstEdu.institution).toBeTruthy(); - } + expect(profile.top_skills).toEqual(expectedTestResumeProfile.top_skills); + expect(profile.languages).toEqual(expectedTestResumeProfile.languages); + expect(profile.summary).toBe(expectedTestResumeProfile.summary); + expect(profile.experience).toHaveLength( + expectedTestResumeProfile.experienceLength + ); + expect(profile.experience[0]).toEqual( + expectedTestResumeProfile.firstExperience + ); + expect(findCartaSeniorEngineerExperience(profile)).toEqual( + expectedTestResumeProfile.cartaSeniorEngineerExperience + ); + expect(profile.education).toHaveLength( + expectedTestResumeProfile.educationLength + ); + expect(profile.education[0]).toEqual( + expectedTestResumeProfile.firstEducation + ); }); }); describe('Test Data Validation', () => { test('should contain expected test data', async () => { - const result = await parseLinkedInPDF(pdfBuffer, { includeRawText: true }); + const result = await parseLinkedInPDF(pdfBuffer, { + includeRawText: true, + }); const profile = result.profile; - // Test email - expect(profile.contact.email).toContain('john.silva@email.com'); - - // Test companies in experience or raw text (companies should be extractable) - const experienceText = JSON.stringify(profile.experience); - const rawText = result.rawText || ''; - - // Check for companies in the extracted data - - const hasTestCompany = - experienceText.includes('DataFlow Inc') || - experienceText.includes('TechFlow Systems') || - experienceText.includes('InnovateTech Solutions') || - rawText.includes('DataFlow Inc') || - rawText.includes('DataFlow') || // Simpler check - rawText.includes('FreshBrew'); - expect(hasTestCompany).toBe(true); - - // Test education - const educationText = JSON.stringify(profile.education); - const hasEducationInfo = - educationText.toLowerCase().includes('austin') || - rawText.toLowerCase().includes('austin'); - expect(hasEducationInfo).toBe(true); + expect(profile.name).toBe(expectedTestResumeProfile.name); + expect(profile.contact).toEqual(expectedTestResumeProfile.contact); + expect(profile.top_skills).toEqual(expectedTestResumeProfile.top_skills); + expect(profile.languages).toEqual(expectedTestResumeProfile.languages); + expect(profile.summary).toBe(expectedTestResumeProfile.summary); + expect(profile.experience).toHaveLength( + expectedTestResumeProfile.experienceLength + ); + expect(profile.experience[0]).toEqual( + expectedTestResumeProfile.firstExperience + ); + expect(findCartaSeniorEngineerExperience(profile)).toEqual( + expectedTestResumeProfile.cartaSeniorEngineerExperience + ); + expect(profile.education).toHaveLength( + expectedTestResumeProfile.educationLength + ); + expect(profile.education[0]).toEqual( + expectedTestResumeProfile.firstEducation + ); + expect(result.warnings).toEqual(expectedTestResumeProfile.warnings); + expect(result.rawText).toEqual(expect.stringContaining('Commure')); + expect(result.rawText).toEqual( + expect.stringContaining('Universidade Veiga de Almeida') + ); + expect(result.rawText).toEqual(expect.stringContaining('Page 7 of 7')); }); }); describe('Error Handling', () => { test('should throw error for empty buffer', async () => { - await expect(parseLinkedInPDF(Buffer.alloc(0))).rejects.toThrow( - 'PDF appears to be empty or unreadable' + await expect(parseLinkedInPDF(Buffer.alloc(0))).rejects.toBeInstanceOf( + LinkedInProfileParseError ); + await expect(parseLinkedInPDF(Buffer.alloc(0))).rejects.toMatchObject({ + code: 'invalid_pdf', + }); }); test('should throw error for empty string', async () => { - await expect(parseLinkedInPDF('')).rejects.toThrow( - 'PDF appears to be empty or unreadable' + await expect(parseLinkedInPDF('')).rejects.toBeInstanceOf( + LinkedInProfileParseError ); + await expect(parseLinkedInPDF('')).rejects.toMatchObject({ + code: 'text_extraction_failed', + }); }); test('should throw error for short text', async () => { - await expect(parseLinkedInPDF('short')).rejects.toThrow( - 'PDF appears to be empty or unreadable' - ); + await expect(parseLinkedInPDF('short')).rejects.toMatchObject({ + code: 'text_extraction_failed', + }); }); }); @@ -182,8 +236,8 @@ describe('LinkedIn PDF Parser Library', () => { describe('Edge Cases and Parser Coverage', () => { test('should handle text with minimal information', async () => { const minimalText = ` - John Doe - john.doe@example.com + Perseus Argos + perseus.argos@example.com Software Engineer Experience @@ -196,28 +250,141 @@ describe('LinkedIn PDF Parser Library', () => { `; const result = await parseLinkedInPDF(minimalText); - expect(result.profile).toBeDefined(); - expect(result.profile.contact.email).toContain('@'); + expect(result.profile).toEqual({ + name: 'Perseus Argos', + headline: undefined, + location: undefined, + contact: { + email: 'perseus.argos@example.com', + }, + top_skills: [], + languages: [], + certifications: [], + volunteer_work: [], + projects: [], + publications: [], + honors_awards: [], + summary: undefined, + experience_groups: [ + { + company: 'Company', + positions: [ + { + dates: { + originalText: '2020-2022', + start: { + iso: '2020', + precision: 'year', + text: '2020', + }, + end: { + iso: '2022', + precision: 'year', + text: '2022', + }, + kind: 'completed', + }, + title: 'Developer', + duration: '2020-2022', + location: '', + description: '', + }, + ], + }, + ], + experience: [ + { + dates: { + originalText: '2020-2022', + start: { + iso: '2020', + precision: 'year', + text: '2020', + }, + end: { + iso: '2022', + precision: 'year', + text: '2022', + }, + kind: 'completed', + }, + title: 'Developer', + company: 'Company', + duration: '2020-2022', + location: '', + description: '', + }, + ], + education: [ + { + institution: 'Computer Science', + degree: '', + year: '', + location: '', + }, + { + institution: 'University', + degree: '', + year: '', + location: '', + }, + ], + }); }); test('should handle text with missing sections', async () => { const sparseText = ` - Jane Smith - jane@test.com + Cassandra Troy + cassandra@example.com No other information available `; const result = await parseLinkedInPDF(sparseText); - expect(result.profile.name).toBeTruthy(); - expect(result.profile.contact.email).toBe('jane@test.com'); + expect(result.profile.name).toBe('Cassandra Troy'); + expect(result.profile.contact.email).toBe('cassandra@example.com'); expect(result.profile.experience).toEqual([]); expect(result.profile.education).toEqual([]); }); + test('groups fallback experiences by contiguous company and preserves honors-awards wiring', async () => { + const result = await parseLinkedInPDF(` + Cassandra Troy + cassandra@example.com + + Honors-Awards + Parser Excellence Award + + Experience + Engineer at Acme + 2020 - 2021 + Senior Engineer at Acme + 2021 - 2022 + Manager at Beta + 2022 - 2023 + Advisor at Acme + 2023 - Present + `); + + expect( + result.profile.experience_groups.map(group => group.company) + ).toEqual(['Acme', 'Beta', 'Acme']); + expect( + result.profile.experience_groups.map(group => group.positions) + ).toEqual([ + [ + expect.objectContaining({ title: 'Engineer' }), + expect.objectContaining({ title: 'Senior Engineer' }), + ], + [expect.objectContaining({ title: 'Manager' })], + [expect.objectContaining({ title: 'Advisor' })], + ]); + expect(result.profile.honors_awards).toEqual(['Parser Excellence Award']); + }); + test('should handle complex language patterns', async () => { const languageText = ` - Test User - test@example.com + Apollo Helios + apollo@example.com Languages English (Native or Bilingual) @@ -227,37 +394,56 @@ describe('LinkedIn PDF Parser Library', () => { `; const result = await parseLinkedInPDF(languageText); - expect(result.profile.languages.length).toBeGreaterThan(0); + expect(result.profile.languages).toEqual([ + { + language: 'English', + proficiency: 'Native or Bilingual', + }, + { + language: 'Spanish', + proficiency: 'Professional Working', + }, + { + language: 'French', + proficiency: 'Elementary', + }, + { + language: 'German', + proficiency: 'Unknown', + }, + ]); }); test('should handle various contact patterns', async () => { const contactText = ` - Contact Person - contact@example.com + Hermes Messenger + hermes.messenger@example.com +1 (555) 123-4567 - https://linkedin.com/in/contactperson + https://linkedin.com/in/hermes-messenger San Francisco, CA `; const result = await parseLinkedInPDF(contactText); - expect(result.profile.contact.email).toBe('contact@example.com'); - expect(result.profile.contact.linkedin_url).toContain('linkedin.com'); + expect(result.profile.contact.email).toBe('hermes.messenger@example.com'); + expect(result.profile.contact.linkedin_url).toBe( + 'https://linkedin.com/in/hermes-messenger' + ); }); test('should handle fallback name extraction patterns', async () => { const nameText = ` - John Smith Extra Info - john@example.com + Perseus Argos Extra Info + perseus@example.com `; const result = await parseLinkedInPDF(nameText); - expect(result.profile.name).toBeTruthy(); + expect(result.profile.name).toBe('Perseus Argos'); }); test('should handle location patterns', async () => { const locationText = ` - Test User - test@example.com + Apollo Helios + apollo@example.com Software Engineer New York, NY @@ -266,13 +452,13 @@ describe('LinkedIn PDF Parser Library', () => { `; const result = await parseLinkedInPDF(locationText); - expect(result.profile.location).toBeTruthy(); + expect(result.profile.location).toBe('New York, NY'); }); test('should handle summary extraction fallback', async () => { const summaryText = ` - Summary User - summary@example.com + Summary Calliope Muse + calliope@example.com This is a longer summary text that describes the professional background and experience of the user in detail Additional information about skills and accomplishments that should be captured in the summary section @@ -283,13 +469,15 @@ describe('LinkedIn PDF Parser Library', () => { `; const result = await parseLinkedInPDF(summaryText); - expect(result.profile.summary).toBeTruthy(); + expect(result.profile.summary).toBe( + 'Calliope Muse calliope@example.com This is a longer summary text that describes the professional background and' + ); }); test('should handle language proficiency patterns', async () => { const languageProficiencyText = ` - Language User - lang@example.com + Hermes Logos + hermes.logos@example.com Languages Portuguese Elementary @@ -298,20 +486,26 @@ describe('LinkedIn PDF Parser Library', () => { `; const result = await parseLinkedInPDF(languageProficiencyText); - expect(result.profile.languages.length).toBeGreaterThan(0); - const hasElementary = result.profile.languages.some(l => - l.proficiency.includes('Elementary') - ); - const hasProfessional = result.profile.languages.some(l => - l.proficiency.includes('Professional') - ); - expect(hasElementary || hasProfessional).toBe(true); + expect(result.profile.languages).toEqual([ + { + language: 'Portuguese', + proficiency: 'Elementary', + }, + { + language: 'Italian', + proficiency: 'Professional', + }, + { + language: 'Chinese', + proficiency: 'Unknown', + }, + ]); }); test('should handle empty skills section', async () => { const noSkillsText = ` - No Skills User - noskills@example.com + Ares Bronze + ares.bronze@example.com Top Skills @@ -325,8 +519,8 @@ describe('LinkedIn PDF Parser Library', () => { test('should handle empty languages section', async () => { const noLanguagesText = ` - No Languages User - nolang@example.com + Echo Nymph + echo.nymph@example.com Languages @@ -340,8 +534,8 @@ describe('LinkedIn PDF Parser Library', () => { test('should handle education without location', async () => { const educationText = ` - Education User - edu@example.com + Athena Owl + athena.owl@example.com Education Computer Science Degree @@ -350,7 +544,29 @@ describe('LinkedIn PDF Parser Library', () => { `; const result = await parseLinkedInPDF(educationText); - expect(result.profile.education.length).toBeGreaterThan(0); + expect(result.profile.education).toEqual([ + { + institution: 'Computer Science Degree', + degree: '', + year: '', + location: '', + }, + { + dates: { + originalText: '2020', + start: { + iso: '2020', + precision: 'year', + text: '2020', + }, + kind: 'single', + }, + institution: 'Stanford University', + degree: '', + year: '2020', + location: '', + }, + ]); }); test('should handle missing profile information gracefully', async () => { @@ -367,19 +583,19 @@ describe('LinkedIn PDF Parser Library', () => { test('should handle specific name fallback patterns', async () => { // Test the fallback name extraction pattern (lines 53-54) const fallbackNameText = ` - John Smith - john@example.com + Perseus Argos + perseus@example.com `; const result = await parseLinkedInPDF(fallbackNameText); - expect(result.profile.name).toBeTruthy(); + expect(result.profile.name).toBe('Perseus Argos'); }); test('should handle summary fallback extraction with line break conditions', async () => { // Test summary fallback with long lines that trigger all conditions (lines 129-142) const longSummaryText = ` - Summary Test User - summarytest@example.com + Summary Apollo Helios + apollo.summary@example.com Short line Medium length line here This is a very long line that should be captured in the summary section because it meets all the length requirements and criteria for inclusion in the profile summary @@ -389,15 +605,16 @@ describe('LinkedIn PDF Parser Library', () => { `; const result = await parseLinkedInPDF(longSummaryText); - expect(result.profile.summary).toBeTruthy(); - expect(result.profile.summary!.length).toBeGreaterThan(50); + expect(result.profile.summary).toBe( + 'Apollo Helios apollo.summary@example.com Short line Medium length line here This is a very long line that should be captured in the summary section because it meets all the length requirements and criteria for inclusion in the profile summary Another qualifying line that meets the length and content requirements for summary inclusion and should be processed correctly Even more qualifying content that should be included in the summary extraction process Final qualifying summary line that completes the summary content extraction process' + ); }); test('should handle language proficiency regex patterns', async () => { // Test the proficiency regex pattern matching (lines 86-90) const proficiencyText = ` - Proficiency User - prof@example.com + Hermes Psychopomp + hermes@example.com Languages French (Intermediate) @@ -405,14 +622,19 @@ describe('LinkedIn PDF Parser Library', () => { `; const result = await parseLinkedInPDF(proficiencyText); - expect(result.profile.languages.length).toBeGreaterThan(0); + expect(result.profile.languages).toEqual([ + { + language: 'French', + proficiency: 'Intermediate', + }, + ]); }); test('should handle single word language fallback', async () => { // Test the single word language fallback (line 98) const singleLangText = ` - Single Lang User - single@example.com + Iris Rainbow + iris@example.com Languages Korean @@ -420,15 +642,23 @@ describe('LinkedIn PDF Parser Library', () => { `; const result = await parseLinkedInPDF(singleLangText); - // Even if no languages are extracted, test that the function handles it gracefully - expect(Array.isArray(result.profile.languages)).toBe(true); + expect(result.profile.languages).toEqual([ + { + language: 'Korean', + proficiency: 'Unknown', + }, + { + language: 'Vietnamese', + proficiency: 'Unknown', + }, + ]); }); test('should handle skills section with no content', async () => { // Test when skills section is found but has no valid skills (line 56 in lists.ts) const emptySkillsText = ` - Empty Skills User - empty@example.com + Hestia Flame + hestia@example.com Top Skills summary @@ -443,55 +673,102 @@ describe('LinkedIn PDF Parser Library', () => { }); test('should handle profile validation edge case', async () => { - // Test the profile validation in index.ts (line 110) const noEmailText = ` - No Email User + Poseidon Pontus Software Engineer at Company Experience Developer `; - try { - await parseLinkedInPDF(noEmailText); - // If it doesn't throw, that's also valid (means it found some email) - } catch (error) { - expect((error as Error).message).toContain( - 'Could not extract basic profile information' - ); - } + const result = await parseLinkedInPDF(noEmailText); + + expect(result.profile.contact.email).toBeUndefined(); + expect(result.warnings).toEqual([ + { + code: 'missing_profile_field', + field: 'profile.contact.email', + message: 'Could not extract contact email', + }, + { + code: 'section_parse_warning', + field: 'entry', + message: + 'Detected an experience section but could not extract entries', + rawText: 'Developer', + section: 'experience', + }, + ]); + }); + + test('should return partial results with warnings when the name is missing', async () => { + const noNameText = ` + noname@example.com + Building durable backend systems for document parsing workflows + + Experience + Principal Engineer + 2020 - 2024 + `; + + const result = await parseLinkedInPDF(noNameText); + + expect(result.profile.contact.email).toBe('noname@example.com'); + expect(result.profile.name).toBeUndefined(); + expect(result.warnings).toEqual([ + { + code: 'missing_profile_field', + field: 'profile.name', + message: 'Could not extract profile name', + }, + { + code: 'section_parse_warning', + field: 'entry', + message: + 'Detected an experience section but could not extract entries', + rawText: 'Principal Engineer 2020 - 2024', + section: 'experience', + }, + ]); }); test('should handle basic-info edge cases for name extraction', async () => { // Test specific name fallback conditions (lines 53-54 in basic-info.ts) const nameEdgeCaseText = ` - John Smith Additional Content - john.edge@example.com + Perseus Argos Additional Content + perseus.edge@example.com `; const result = await parseLinkedInPDF(nameEdgeCaseText); - expect(result.profile.name).toBeTruthy(); + expect(result.profile.name).toBe('Perseus Argos'); }); test('should handle education section edge case', async () => { // Test education line 58 condition const educationEdgeText = ` - Education Edge User - edge@example.com + Minerva Owl + minerva@example.com Education Short `; const result = await parseLinkedInPDF(educationEdgeText); - expect(Array.isArray(result.profile.education)).toBe(true); + expect(result.profile.education).toEqual([ + { + institution: 'Short', + degree: '', + year: '', + location: '', + }, + ]); }); test('should handle lists edge cases', async () => { // Test lists.ts lines that aren't covered const listsEdgeText = ` - Lists Edge User - lists@example.com + Selene Moon + selene@example.com Top Skills Very very very very very very very very very long skill name that exceeds the normal length @@ -501,15 +778,15 @@ describe('LinkedIn PDF Parser Library', () => { `; const result = await parseLinkedInPDF(listsEdgeText); - expect(Array.isArray(result.profile.top_skills)).toBe(true); - expect(Array.isArray(result.profile.languages)).toBe(true); + expect(result.profile.top_skills).toEqual([]); + expect(result.profile.languages).toEqual([]); }); test('should handle summary with break condition', async () => { // Test the specific break condition in summary extraction (lines 141-142) const summaryBreakText = ` - Summary Break User - break@example.com + Summary Ariadne Naxos + ariadne@example.com Short Medium This is exactly the right length line that should trigger the summary extraction and demonstrate the break condition working properly when the accumulated text reaches the specified threshold @@ -518,26 +795,27 @@ describe('LinkedIn PDF Parser Library', () => { `; const result = await parseLinkedInPDF(summaryBreakText); - expect(result.profile.summary).toBeTruthy(); + expect(result.profile.summary).toBe( + 'Ariadne Naxos ariadne@example.com Short Medium This is exactly the right length line that should trigger the summary extraction and demonstrate the break condition working properly when the accumulated text reaches the specified threshold More content after break condition Even more content that should be ignored after break' + ); }); test('should handle basic-info name extraction with multiple spaces', async () => { // Test lines 53-54 in basic-info.ts - name extraction with multiple spaces fallback const nameWithSpacesText = ` - John Smith Extra Content - john@example.com + Perseus Argos Extra Content + perseus@example.com `; const result = await parseLinkedInPDF(nameWithSpacesText); - expect(result.profile.name).toBeTruthy(); - expect(result.profile.name).toBe('John Smith'); + expect(result.profile.name).toBe('Perseus Argos'); }); test('should handle summary extraction fallback conditions', async () => { // Test lines 129-142 in basic-info.ts - summary extraction without Summary section const textWithoutSummarySection = [ - 'Test User', - 'test@example.com', + 'Apollo Helios', + 'apollo@example.com', 'Software Engineer', 'Location: Austin, TX', 'Contact info here', @@ -551,26 +829,30 @@ describe('LinkedIn PDF Parser Library', () => { const result = await parseLinkedInPDF( textWithoutSummarySection.join('\n') ); - expect(result.profile.summary).toBeTruthy(); - expect(result.profile.summary!.length).toBeGreaterThan(50); + expect(result.profile.summary).toBe( + 'extraction because it contains enough content Additional qualifying content that should be included in the summary extraction process for testing coverage More qualifying text for the summary that meets length requirements' + ); }); test('should handle edge cases that increase coverage', async () => { // This test is designed to hit various edge cases for coverage const result = await parseLinkedInPDF(pdfBuffer); - // Just verify the basic functionality works - expect(result.profile.name).toBeTruthy(); - expect(result.profile.contact.email).toBeTruthy(); - expect(Array.isArray(result.profile.top_skills)).toBe(true); - expect(Array.isArray(result.profile.languages)).toBe(true); + expect(result.profile.name).toBe(expectedTestResumeProfile.name); + expect(result.profile.contact.email).toBeUndefined(); + expect(result.profile.top_skills).toEqual( + expectedTestResumeProfile.top_skills + ); + expect(result.profile.languages).toEqual( + expectedTestResumeProfile.languages + ); }); test('should handle education line length validation', async () => { // Test line 58 in education.ts - continue condition for short lines const educationShortText = ` - Education Short User - edushort@example.com + Demeter Grain + demeter@example.com Education a @@ -581,17 +863,30 @@ describe('LinkedIn PDF Parser Library', () => { `; const result = await parseLinkedInPDF(educationShortText); - expect(result.profile.education.length).toBeGreaterThanOrEqual(0); - if (result.profile.education.length > 0) { - expect(result.profile.education[0].institution).toBeTruthy(); - } + expect(result.profile.education).toEqual([ + { + dates: { + originalText: '2020', + start: { + iso: '2020', + precision: 'year', + text: '2020', + }, + kind: 'single', + }, + institution: 'Stanford University', + degree: 'Computer Science', + year: '2020', + location: '', + }, + ]); }); test('should handle specific code coverage cases', async () => { // This test targets the remaining uncovered lines const complexText = ` - John Smith Johnson - john.smith@test.com + Perseus Argos Helios + perseus.argos@example.com Top Skills Languages JavaScript @@ -605,39 +900,39 @@ describe('LinkedIn PDF Parser Library', () => { `; const result = await parseLinkedInPDF(complexText); - expect(result.profile.name).toBeTruthy(); - expect(result.profile.contact.email).toBeTruthy(); - expect(Array.isArray(result.profile.top_skills)).toBe(true); - expect(Array.isArray(result.profile.languages)).toBe(true); + expect(result.profile.name).toBe('Perseus Argos Helios'); + expect(result.profile.contact.email).toBe('perseus.argos@example.com'); + expect(result.profile.top_skills).toEqual([]); + expect(result.profile.languages).toEqual([]); }); test('should handle edge case name patterns', async () => { // Target specific name extraction patterns const namePatternText = ` - Mary Jane Watson Additional - mary@example.com + Ariadne Naxos Knossos Additional + ariadne@example.com `; const result = await parseLinkedInPDF(namePatternText); - expect(result.profile.name).toBeTruthy(); + expect(result.profile.name).toBe('Ariadne Naxos'); }); test('should cover line 53-54 in basic-info.ts', async () => { // Target the specific name extraction pattern with multiple spaces const text = ` - John Smith Additional Text Here - john@test.com + Perseus Argos Additional Text Here + perseus@test.com `; const result = await parseLinkedInPDF(text); - expect(result.profile.name).toBe('John Smith'); + expect(result.profile.name).toBe('Perseus Argos'); }); test('should cover lines 129-142 in basic-info.ts', async () => { // Target the summary extraction fallback logic const text = ` - Test User - test@test.com + Apollo Helios + apollo@test.com Location Info Short line Another short @@ -647,15 +942,16 @@ describe('LinkedIn PDF Parser Library', () => { `; const result = await parseLinkedInPDF(text); - expect(result.profile.summary).toBeTruthy(); - expect(result.profile.summary!.length).toBeGreaterThan(100); + expect(result.profile.summary).toBe( + 'extraction because it meets all the length requirements and is more than 50 characters This is another qualifying line that should be captured in the summary section for proper coverage testing and validation More qualifying content here that meets the requirements' + ); }); test('should cover line 56 in lists.ts', async () => { // Test the continue condition in language parsing const text = ` - Test User - test@example.com + Apollo Helios + apollo@example.com Languages summary @@ -666,27 +962,34 @@ describe('LinkedIn PDF Parser Library', () => { `; const result = await parseLinkedInPDF(text); - expect(Array.isArray(result.profile.languages)).toBe(true); + expect(result.profile.languages).toEqual([]); }); test('should achieve maximum code coverage', async () => { // Combined test for maximum coverage const result = await parseLinkedInPDF(pdfBuffer); - // Just verify the parsing works - expect(result.profile.name).toBeTruthy(); - expect(result.profile.contact.email).toBeTruthy(); - expect(Array.isArray(result.profile.top_skills)).toBe(true); - expect(Array.isArray(result.profile.languages)).toBe(true); - expect(Array.isArray(result.profile.experience)).toBe(true); - expect(Array.isArray(result.profile.education)).toBe(true); + expect(result.profile.name).toBe(expectedTestResumeProfile.name); + expect(result.profile.contact.email).toBeUndefined(); + expect(result.profile.top_skills).toEqual( + expectedTestResumeProfile.top_skills + ); + expect(result.profile.languages).toEqual( + expectedTestResumeProfile.languages + ); + expect(result.profile.experience).toHaveLength( + expectedTestResumeProfile.experienceLength + ); + expect(result.profile.education).toHaveLength( + expectedTestResumeProfile.educationLength + ); }); test('should cover line 58 in education.ts', async () => { // Test short line handling in education const text = ` - Test User - test@example.com + Apollo Helios + apollo@example.com Education ab @@ -696,14 +999,16 @@ describe('LinkedIn PDF Parser Library', () => { const result = await parseLinkedInPDF(text); expect(result.profile.education.length).toBeGreaterThan(0); - expect(result.profile.education[0].institution).toBeTruthy(); + expect(result.profile.education[0].institution).toBe( + 'University of Texas' + ); }); test('should cover lines 53-54 and 129-142 in basic-info.ts', async () => { // Test name extraction with double spaces and summary fallback const text = ` - John Smith Additional text here that should be ignored - test@example.com + Perseus Argos Additional text here that should be ignored + apollo@example.com Short headline Location info Another short line @@ -713,16 +1018,17 @@ describe('LinkedIn PDF Parser Library', () => { `; const result = await parseLinkedInPDF(text); - expect(result.profile.name).toBe('John Smith'); - expect(result.profile.summary).toBeTruthy(); - expect(result.profile.summary!.length).toBeGreaterThan(100); + expect(result.profile.name).toBe('Perseus Argos'); + expect(result.profile.summary).toBe( + 'extraction because it has more than 50 characters and less than 200 characters Another qualifying line for summary extraction that meets the length requirements and should be included in the summary More content to reach the 100 character threshold for the summary extraction logic' + ); }); test('should cover lines 86-90 and 98 in lists.ts', async () => { // Test language proficiency extraction with special patterns const text = ` - Test User - test@example.com + Apollo Helios + apollo@example.com Languages Native Portuguese @@ -737,25 +1043,47 @@ describe('LinkedIn PDF Parser Library', () => { const textWithSummary = text.replace('Languages', 'Languages Summary'); const result2 = await parseLinkedInPDF(textWithSummary); - // Verify both paths work - expect(Array.isArray(result.profile.languages)).toBe(true); - expect(Array.isArray(result2.profile.languages)).toBe(true); + expect(result.profile.languages).toEqual([ + { + language: 'Portuguese', + proficiency: 'Native', + }, + { + language: 'English', + proficiency: 'Unknown', + }, + { + language: 'Spanish', + proficiency: 'Professional', + }, + { + language: 'French', + proficiency: 'Unknown', + }, + ]); + expect(result2.profile.languages).toEqual([]); + expect(result2.profile.summary).toBe( + 'Native Portuguese English Professional Spanish French' + ); }); test('should increase branch coverage for lists.ts', async () => { // Test with PDF buffer to ensure coverage const result = await parseLinkedInPDF(pdfBuffer); - // Just verify arrays are present - expect(Array.isArray(result.profile.languages)).toBe(true); - expect(Array.isArray(result.profile.top_skills)).toBe(true); + expect(result.profile.languages).toEqual( + expectedTestResumeProfile.languages + ); + expect(result.profile.top_skills).toEqual( + expectedTestResumeProfile.top_skills + ); }); test('should cover education edge case line 58', async () => { // Specifically test the continue condition in education parsing const text = ` - Test User - test@example.com + Apollo Helios + apollo@example.com Education a @@ -773,8 +1101,18 @@ describe('LinkedIn PDF Parser Library', () => { const bufferResult = await parseLinkedInPDF(pdfBuffer, { includeRawText: true, }); - expect(bufferResult.rawText).toBeTruthy(); - expect(bufferResult.profile.name).toBeTruthy(); + expect(bufferResult.rawText).toHaveLength(13078); + expect(bufferResult.profile.name).toBe(expectedTestResumeProfile.name); }); }); }); + +function findCartaSeniorEngineerExperience( + profile: LinkedInProfile +): LinkedInProfile['experience'][number] | undefined { + return profile.experience.find( + experience => + experience.company === 'Carta' && + experience.title === 'Senior Software Engineer' + ); +} diff --git a/tests/unit/lists.test.ts b/tests/unit/lists.test.ts new file mode 100644 index 0000000..c1e16ec --- /dev/null +++ b/tests/unit/lists.test.ts @@ -0,0 +1,371 @@ +import { ListParser } from '../../src/parsers/lists.js'; +import type { StructuralLine } from '../../src/utils/structural-lines.js'; + +describe('ListParser', () => { + test('returns empty skills and no warnings when top skills section is absent', () => { + expect( + ListParser.parseSkillsWithWarnings(` + Summary + Builds product systems for operators. + `) + ).toEqual({ + value: [], + warnings: [], + }); + }); + + test('warns when a detected top skills section has no valid skills', () => { + const result = ListParser.parseSkillsWithWarnings(` + Top Skills + 12345 + Page 2 + + Languages + English + `); + + expect(result.value).toEqual([]); + expect(result.warnings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + field: 'item', + rawText: '12345', + section: 'top_skills', + }), + expect.objectContaining({ + field: 'item', + rawText: 'Page 2', + section: 'top_skills', + }), + expect.objectContaining({ + field: 'section', + section: 'top_skills', + }), + ]) + ); + }); + + test('does not treat generic experience lines as top skills', () => { + const skills = ListParser.parseSkills(` + Apollo Helios + apollo@example.com + + Top Skills + TypeScript + Amazon Web Services (AWS) + Northstar Solutions + Principal Engineer + 2020 - 2024 + + Languages + English + `); + + expect(skills).toEqual(['TypeScript', 'Amazon Web Services (AWS)']); + }); + + test('keeps content lines that begin with section-header words', () => { + const skills = ListParser.parseSkills(` + Top Skills + TypeScript + Experience with Kubernetes + Education technology + + Languages + English + `); + + expect(skills).toEqual([ + 'TypeScript', + 'Experience with Kubernetes', + 'Education technology', + ]); + }); + + test('caps top skills at ten entries', () => { + const skills = ListParser.parseSkills(` + Top Skills + Skill 1 + Skill 2 + Skill 3 + Skill 4 + Skill 5 + Skill 6 + Skill 7 + Skill 8 + Skill 9 + Skill 10 + Skill 11 + + Languages + English + `); + + expect(skills).toHaveLength(10); + expect(skills.at(-1)).toBe('Skill 10'); + }); + + test('returns warnings for malformed language rows', () => { + const result = ListParser.parseLanguagesWithWarnings(` + Languages + 12345 + English (Native or Bilingual) + `); + + expect(result.value).toEqual([ + { + language: 'English', + proficiency: 'Native or Bilingual', + }, + ]); + expect(result.warnings).toEqual([ + expect.objectContaining({ + field: 'item', + rawText: '12345', + section: 'languages', + }), + ]); + }); + + test('does not turn repeated language headers into language entries', () => { + const result = ListParser.parseLanguagesWithWarnings(` + Languages + Language + `); + + expect(result.value).toEqual([]); + expect(result.warnings).toEqual([ + expect.objectContaining({ + field: 'section', + section: 'languages', + }), + ]); + }); + + test('extracts structural languages from their visual column only', () => { + const result = ListParser.parseStructuralLanguagesWithWarnings([ + structuralLine({ column: 'right', text: 'Summary', y: 700 }), + structuralLine({ + column: 'right', + text: 'Builds product systems for operators.', + y: 690, + }), + structuralLine({ column: 'left', text: 'Languages', y: 680 }), + structuralLine({ + column: 'left', + text: 'Português (Native or Bilingual)', + y: 670, + }), + structuralLine({ + column: 'right', + text: 'This summary line should not be parsed as a language.', + y: 670, + }), + structuralLine({ + column: 'left', + text: 'Inglês (Professional Working)', + y: 660, + }), + structuralLine({ column: 'right', text: 'Experience', y: 650 }), + ]); + + expect(result).toEqual({ + value: [ + { + language: 'Português', + proficiency: 'Native or Bilingual', + }, + { + language: 'Inglês', + proficiency: 'Professional Working', + }, + ], + warnings: [], + }); + }); + + test('returns no structural languages when language section is absent', () => { + expect( + ListParser.parseStructuralLanguagesWithWarnings([ + structuralLine({ column: 'left', text: 'Summary', y: 700 }), + structuralLine({ + column: 'left', + text: 'Builds product systems for operators.', + y: 680, + }), + ]) + ).toEqual({ + value: [], + warnings: [], + }); + }); + + test('merges wrapped structural languages and stops at honors boundary', () => { + const result = ListParser.parseStructuralLanguagesWithWarnings([ + structuralLine({ column: 'left', text: 'Languages', y: 700 }), + structuralLine({ + column: 'left', + text: 'English (Native or Bilingual)', + y: 680, + }), + structuralLine({ + column: 'left', + text: 'Chinese (Traditional) (Limited', + y: 660, + }), + structuralLine({ column: 'left', text: 'Working)', y: 640 }), + structuralLine({ column: 'left', text: 'Honors-Awards', y: 620 }), + structuralLine({ column: 'left', text: 'Dean Student Advisory', y: 600 }), + ]); + + expect(result).toEqual({ + value: [ + { + language: 'English', + proficiency: 'Native or Bilingual', + }, + { + language: 'Chinese (Traditional)', + proficiency: 'Limited Working', + }, + ], + warnings: [], + }); + }); + + test('merges parenthesized structural language continuations across three lines', () => { + const result = ListParser.parseStructuralLanguagesWithWarnings([ + structuralLine({ column: 'left', text: 'Languages', y: 700 }), + structuralLine({ + column: 'left', + text: 'Chinese (Traditional)', + y: 680, + }), + structuralLine({ column: 'left', text: '(Limited', y: 660 }), + structuralLine({ column: 'left', text: 'Working)', y: 640 }), + structuralLine({ column: 'left', text: 'Experience', y: 620 }), + ]); + + expect(result).toEqual({ + value: [ + { + language: 'Chinese (Traditional)', + proficiency: 'Limited Working', + }, + ], + warnings: [], + }); + }); + + test('salvages unclosed structural language proficiency at section end', () => { + const result = ListParser.parseStructuralLanguagesWithWarnings([ + structuralLine({ column: 'left', text: 'Languages', y: 700 }), + structuralLine({ column: 'left', text: 'Portuguese (Limited', y: 680 }), + ]); + + expect(result).toEqual({ + value: [ + { + language: 'Portuguese', + proficiency: 'Limited', + }, + ], + warnings: [], + }); + }); + + test('merges balanced parenthesized structural language continuations', () => { + const result = ListParser.parseStructuralLanguagesWithWarnings([ + structuralLine({ column: 'left', text: 'Languages', y: 700 }), + structuralLine({ + column: 'left', + text: 'Chinese (Traditional)', + y: 680, + }), + structuralLine({ column: 'left', text: '(Limited Working)', y: 660 }), + structuralLine({ column: 'left', text: 'Experience', y: 640 }), + ]); + + expect(result).toEqual({ + value: [ + { + language: 'Chinese (Traditional)', + proficiency: 'Limited Working', + }, + ], + warnings: [], + }); + }); + + test('ignores blank skill rows and rejects proficiency-only languages', () => { + const skills = ListParser.parseSkillsWithWarnings(` + Top Skills + + TypeScript + + Languages + English + `); + + expect(skills).toEqual({ + value: ['TypeScript'], + warnings: [], + }); + expect(ListParser.parseLanguages('Languages\nItalian')).toEqual([ + { + language: 'Italian', + proficiency: 'Unknown', + }, + ]); + expect(ListParser.parseLanguages('Languages\nNative Portuguese')).toEqual([ + { + language: 'Portuguese', + proficiency: 'Native', + }, + ]); + expect( + ListParser.parseLanguages( + 'Languages\nNative VeryVeryVeryLongLanguageName' + ) + ).toEqual([]); + }); + + test('ignores blank structural language rows', () => { + const result = ListParser.parseStructuralLanguagesWithWarnings([ + structuralLine({ column: 'left', text: 'Languages', y: 700 }), + structuralLine({ column: 'left', text: 'English', y: 680 }), + structuralLine({ column: 'left', text: ' ', y: 660 }), + structuralLine({ column: 'left', text: 'Experience', y: 640 }), + ]); + + expect(result).toEqual({ + value: [ + { + language: 'English', + proficiency: 'Unknown', + }, + ], + warnings: [], + }); + }); +}); + +function structuralLine({ + column, + text, + y, +}: { + column: StructuralLine['column']; + text: string; + y: number; +}): StructuralLine { + return { + column, + fontSize: 10, + height: 10, + text, + width: text.length * 5, + x: column === 'left' ? 20 : 220, + y, + }; +} diff --git a/tests/unit/location-classifier.test.ts b/tests/unit/location-classifier.test.ts new file mode 100644 index 0000000..ee1618a --- /dev/null +++ b/tests/unit/location-classifier.test.ts @@ -0,0 +1,188 @@ +import { classifyLocationText } from '../../src/utils/location-classifier.js'; + +describe('location classifier', () => { + test('rejects empty location text without signals', () => { + expect(classifyLocationText({ text: ' ' })).toEqual({ + isLocation: false, + score: 0, + signals: [], + }); + }); + + test('scores named place, region, and country signals as locations', () => { + expect( + classifyLocationText({ + context: { structuralContext: 'after-duration' }, + text: 'Washington D.C.', + }) + ).toEqual( + expect.objectContaining({ + isLocation: true, + signals: expect.arrayContaining([ + 'known-place', + 'region-code', + 'after-duration', + ]), + }) + ); + + expect( + classifyLocationText({ + context: { structuralContext: 'metadata' }, + text: 'Boston', + }) + ).toEqual( + expect.objectContaining({ + isLocation: false, + score: 3, + signals: expect.arrayContaining(['exact-place', 'proper-shape']), + }) + ); + + expect(classifyLocationText({ text: 'California' })).toEqual( + expect.objectContaining({ + isLocation: true, + signals: expect.arrayContaining(['admin-region', 'proper-shape']), + }) + ); + + expect( + classifyLocationText({ text: 'Greater Los Angeles Area, United States' }) + ).toEqual( + expect.objectContaining({ + isLocation: true, + signals: expect.arrayContaining([ + 'known-place', + 'country-or-region', + 'qualified-area', + ]), + }) + ); + + expect( + classifyLocationText({ + context: { structuralContext: 'after-duration' }, + text: 'Chicago, IL', + }) + ).toEqual( + expect.objectContaining({ + isLocation: true, + signals: expect.arrayContaining(['known-place', 'region-code']), + }) + ); + + expect( + classifyLocationText({ + context: { structuralContext: 'after-duration' }, + text: 'IN, San Diego', + }) + ).toEqual( + expect.objectContaining({ + isLocation: true, + signals: expect.arrayContaining(['known-place', 'region-code']), + }) + ); + }); + + test('rejects generic geo-token and title-bearing phrases without strong evidence', () => { + expect( + classifyLocationText({ + context: { structuralContext: 'after-duration' }, + text: 'Platform Region', + }).isLocation + ).toBe(false); + + expect( + classifyLocationText({ + context: { structuralContext: 'after-duration' }, + text: 'Corporate Finance Los Angeles Metropolitan Area', + }).isLocation + ).toBe(false); + }); + + test('recognizes sample city-region and translated country locations', () => { + for (const location of [ + 'Minneapolis, Minnesota', + 'Smithfield, Rhode Island', + 'Charlottesville, Virginia', + 'Dubai, Vereinigte Arabische Emirate', + 'Reno, Nevada Area', + 'Kauai, Hawaii', + ]) { + expect( + classifyLocationText({ + context: { structuralContext: 'after-duration' }, + text: location, + }).isLocation + ).toBe(true); + } + }); + + test('recognizes standard ZIP+4 postal codes', () => { + expect(classifyLocationText({ text: '12345-6789' })).toEqual( + expect.objectContaining({ + isLocation: true, + signals: expect.arrayContaining(['postal-code']), + }) + ); + + expect(classifyLocationText({ text: '21941-911' })).toEqual( + expect.objectContaining({ + isLocation: true, + signals: expect.arrayContaining(['postal-code']), + }) + ); + + expect(classifyLocationText({ text: '12345-67' }).isLocation).toBe(false); + }); + + test('rejects duration ranges with alternate dash characters', () => { + for (const text of [ + '2020 ‐ Present', + '2020 ‑ 2021', + '2020 ‒ current', + '2020 — Present', + '2020 − 2021', + ]) { + expect(classifyLocationText({ text })).toEqual( + expect.objectContaining({ + isLocation: false, + signals: expect.arrayContaining(['duration']), + }) + ); + } + }); + + test('does not treat standalone country codes as location lines', () => { + expect( + classifyLocationText({ + context: { structuralContext: 'after-duration' }, + text: 'US', + }).isLocation + ).toBe(false); + }); + + test('rejects place-word organization names and prose with ambiguous region-code words', () => { + for (const text of [ + 'Los Angeles Animal Services', + 'Tokyo Forex', + 'Keidanren (Japan Business Federation)', + 'schools that generate meaningful results for families in New York', + 'built, IN', + 'built, ME', + 'built, OR', + ]) { + const result = classifyLocationText({ + context: { structuralContext: 'after-duration' }, + text, + }); + const hasRegionCodeSignal = result.signals.some(signal => + signal.includes('region-code') + ); + + expect(result.isLocation).toBe(false); + expect(hasRegionCodeSignal).toBe(false); + expect(result.signals).not.toContain('comma-region'); + } + }); +}); diff --git a/tests/unit/node-directory-entry.test.ts b/tests/unit/node-directory-entry.test.ts new file mode 100644 index 0000000..a41f056 --- /dev/null +++ b/tests/unit/node-directory-entry.test.ts @@ -0,0 +1,79 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { getNodeDirectoryEntryKind } from '../../src/node-directory-entry.js'; + +describe('getNodeDirectoryEntryKind', () => { + let directoryPath: string; + + beforeEach(() => { + directoryPath = fs.mkdtempSync( + path.join(os.tmpdir(), 'node-directory-entry-') + ); + }); + + afterEach(() => { + fs.rmSync(directoryPath, { force: true, recursive: true }); + }); + + test('classifies files and directories', () => { + fs.writeFileSync(path.join(directoryPath, 'profile.pdf'), ''); + fs.mkdirSync(path.join(directoryPath, 'nested')); + + expect(readEntryKind('profile.pdf')).toBe('file'); + expect(readEntryKind('nested')).toBe('directory'); + }); + + test('classifies symlinks by their resolved target', () => { + fs.writeFileSync(path.join(directoryPath, 'profile.pdf'), ''); + fs.mkdirSync(path.join(directoryPath, 'nested')); + fs.symlinkSync('profile.pdf', path.join(directoryPath, 'profile-link.pdf')); + fs.symlinkSync('nested', path.join(directoryPath, 'nested-link')); + + expect(readEntryKind('profile-link.pdf')).toBe('file'); + expect(readEntryKind('nested-link')).toBe('directory'); + }); + + test('classifies broken symlinks as other', () => { + fs.symlinkSync('missing.pdf', path.join(directoryPath, 'missing-link.pdf')); + + expect(readEntryKind('missing-link.pdf')).toBe('other'); + }); + + test('classifies non-file directory entries as other', () => { + const entry: fs.Dirent = { + isBlockDevice: () => false, + isCharacterDevice: () => false, + isDirectory: () => false, + isFIFO: () => true, + isFile: () => false, + isSocket: () => false, + isSymbolicLink: () => false, + name: 'pipe', + parentPath: directoryPath, + path: directoryPath, + }; + + expect(getNodeDirectoryEntryKind(directoryPath, entry)).toBe('other'); + }); + + test('classifies symlinks to special targets as other', () => { + fs.symlinkSync('/dev/null', path.join(directoryPath, 'special-link')); + + expect(readEntryKind('special-link')).toBe('other'); + }); + + function readEntryKind( + fileName: string + ): ReturnType { + const entry = fs + .readdirSync(directoryPath, { withFileTypes: true }) + .find(candidate => candidate.name === fileName); + + if (entry === undefined) { + throw new Error(`Missing test directory entry: ${fileName}`); + } + + return getNodeDirectoryEntryKind(directoryPath, entry); + } +}); diff --git a/tests/unit/pdf-source-debug.test.ts b/tests/unit/pdf-source-debug.test.ts new file mode 100644 index 0000000..3bf2d04 --- /dev/null +++ b/tests/unit/pdf-source-debug.test.ts @@ -0,0 +1,29 @@ +import * as fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { extractLinkedInPDFSourceDebug } from '../../src/index.js'; + +describe('PDF source debug artifacts', () => { + test('extracts parser structural evidence from a PDF buffer', async () => { + const profilePdfPath = fileURLToPath( + new URL('../fixtures/Profile.pdf', import.meta.url) + ); + const artifacts = await extractLinkedInPDFSourceDebug( + fs.readFileSync(profilePdfPath) + ); + + expect(artifacts.rawText).toEqual(expect.stringContaining('Harold Martin')); + expect(artifacts.textItems.length).toBeGreaterThan(0); + expect(artifacts.structuralLines).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + text: expect.stringContaining('Harold Martin'), + }), + ]) + ); + expect(artifacts.layout).toEqual( + expect.objectContaining({ + type: expect.stringMatching(/^(single|two)-column$/), + }) + ); + }); +}); diff --git a/tests/unit/profile-fixture.test.ts b/tests/unit/profile-fixture.test.ts new file mode 100644 index 0000000..459113f --- /dev/null +++ b/tests/unit/profile-fixture.test.ts @@ -0,0 +1,147 @@ +import * as fs from 'fs'; +import { fileURLToPath } from 'node:url'; +import { parseLinkedInPDF, type ParseResult } from '../../src/index.js'; + +describe('Profile.pdf fixture', () => { + const profilePdfPath = fileURLToPath( + new URL('../fixtures/Profile.pdf', import.meta.url) + ); + let result: ParseResult; + + beforeAll(async () => { + if (!fs.existsSync(profilePdfPath)) { + throw new Error(`Profile fixture not found at ${profilePdfPath}`); + } + + result = await parseLinkedInPDF(fs.readFileSync(profilePdfPath), { + includeRawText: true, + }); + }); + + test('extracts the main profile identity block', () => { + expect(result.profile.name).toBe('Harold Martin'); + expect(result.profile.headline).toBe('CTO @ SVRN'); + expect(result.profile.location).toBe( + 'Los Angeles, California, United States' + ); + }); + + test('extracts complete contact details without treating URL fragments as phone numbers', () => { + expect(result.profile.contact.email).toBe('harold.martin@gmail.com'); + expect(result.profile.contact.linkedin_url).toBe( + 'https://linkedin.com/in/harold-martin-98526971' + ); + expect(result.profile.contact.phone).toBeUndefined(); + }); + + test('extracts sidebar skills from the left column', () => { + expect(result.profile.top_skills).toEqual([ + 'Python', + 'Amazon Web Services (AWS)', + 'ElasticSearch', + ]); + }); + + test('extracts experience entries in visual order', () => { + const expectedExperience = [ + { + company: 'SVRN', + title: 'Chief Technology Officer', + duration: 'November 2025 - Present', + }, + { + company: 'Self-employed', + title: 'Mobile and AI Consultant', + duration: 'January 2024 - December 2025', + }, + { + company: 'Jump', + title: 'Mobile Lead', + duration: 'December 2022 - November 2023', + location: 'Los Angeles, California, United States', + }, + { + company: 'AllTrails', + title: 'Senior Android Engineer', + duration: 'November 2021 - December 2022', + }, + { + company: 'Tinder, Inc.', + title: 'Senior Android Engineer', + duration: 'July 2017 - November 2021', + location: 'Greater Los Angeles Area', + }, + { + company: 'WikiRealty', + title: 'Lead Engineer', + duration: 'January 2015 - January 2016', + location: 'Santa Monica, CA', + }, + { + company: 'Whisper', + title: 'Technical Manager', + duration: 'May 2014 - January 2015', + location: 'Venice, CA', + }, + { + company: 'OpenX', + title: 'Software Engineer', + duration: 'June 2012 - May 2014', + location: 'Pasadena, CA', + }, + { + company: 'California Institute of Technology', + title: 'Undergraduate Researcher', + duration: 'June 2011 - September 2011', + location: 'Pasadena, CA', + }, + { + company: 'Intel Corporation', + title: 'Platform Engineer Intern', + duration: 'June 2007 - September 2007', + location: 'Dupont, WA', + }, + ]; + + expect(result.profile.experience).toHaveLength(expectedExperience.length); + expectedExperience.forEach((expected, index) => { + expect(result.profile.experience[index]).toEqual( + expect.objectContaining(expected) + ); + }); + }); + + test('extracts education separately from experience', () => { + expect(result.profile.education).toEqual([ + { + institution: 'California Institute of Technology', + degree: 'BS, Chemical Engineering', + year: '2006 - 2012', + dates: { + originalText: '2006 - 2012', + start: { + iso: '2006', + precision: 'year', + text: '2006', + }, + end: { + iso: '2012', + precision: 'year', + text: '2012', + }, + kind: 'completed', + }, + location: '', + }, + ]); + }); + + test('includes raw text from both PDF pages for fixture demos', () => { + expect(result.rawText).toEqual(expect.stringContaining('Harold Martin')); + expect(result.rawText).toEqual(expect.stringContaining('SVRN')); + expect(result.rawText).toEqual( + expect.stringContaining('Intel Corporation') + ); + expect(result.rawText).toEqual(expect.stringContaining('Page 2 of 2')); + }); +}); diff --git a/tests/unit/profile-text.test.ts b/tests/unit/profile-text.test.ts new file mode 100644 index 0000000..4be9f0b --- /dev/null +++ b/tests/unit/profile-text.test.ts @@ -0,0 +1,91 @@ +import { + isLikelyLocationText, + looksLikeOrganizationNameText, + looksLikePositionTitleText, +} from '../../src/utils/profile-text.js'; + +describe('profile text heuristics', () => { + test('matches position keywords as whole words only', () => { + expect(looksLikePositionTitleText('Lead Engineer')).toBe(true); + expect(looksLikePositionTitleText('Producer, SHARK WRANGLERS')).toBe(true); + expect(looksLikePositionTitleText('2022 Youth Fellow')).toBe(true); + expect(looksLikePositionTitleText('Contributing Writer')).toBe(true); + expect(looksLikePositionTitleText('Managing Partner')).toBe(true); + expect(looksLikePositionTitleText('Mentor')).toBe(true); + expect(looksLikePositionTitleText('Business & Technology Executive')).toBe( + true + ); + expect(looksLikePositionTitleText('Sr. Programmer / Project Lead')).toBe( + true + ); + expect(looksLikePositionTitleText('International Bank')).toBe(false); + expect(looksLikeOrganizationNameText('International Bank')).toBe(true); + }); + + test('keeps dotted position titles from looking like organizations', () => { + expect(looksLikePositionTitleText('Manager.')).toBe(true); + expect(looksLikeOrganizationNameText('Manager.')).toBe(false); + expect(looksLikePositionTitleText('I was responsible for hiring.')).toBe( + false + ); + expect(looksLikePositionTitleText('manager.')).toBe(false); + expect(looksLikeOrganizationNameText('manager.')).toBe(false); + expect(looksLikePositionTitleText('Engineering Manager • Led')).toBe(false); + expect(looksLikeOrganizationNameText('Engineering Manager • Led')).toBe( + false + ); + expect(looksLikePositionTitleText('Engineering Manager...')).toBe(false); + expect(looksLikeOrganizationNameText('Engineering Manager...')).toBe(false); + expect(looksLikePositionTitleText('Engineering Manager…')).toBe(false); + expect(looksLikeOrganizationNameText('Engineering Manager…')).toBe(false); + expect( + looksLikePositionTitleText( + 'Executive Produced by Achilles Pelides & Circe Aeaea' + ) + ).toBe(false); + }); + + test('accepts only one allowlisted trailing title parenthetical', () => { + expect(looksLikePositionTitleText('Lead Engineer (Contractor)')).toBe(true); + expect(looksLikePositionTitleText('Lead Engineer (R&D)')).toBe(false); + expect(looksLikePositionTitleText('Lead Engineer (R&D) (Contractor)')).toBe( + false + ); + }); + + test('supports accented organization words without promoting locations', () => { + expect(looksLikeOrganizationNameText('Ação Labs')).toBe(true); + expect(looksLikeOrganizationNameText('Ampli & Co')).toBe(true); + expect(looksLikeOrganizationNameText('Fulldome Film Society')).toBe(true); + expect(looksLikeOrganizationNameText('Stage Venture Partners')).toBe(true); + expect(looksLikeOrganizationNameText('São Paulo Tech')).toBe(true); + expect(looksLikeOrganizationNameText('Remote')).toBe(false); + }); + + test('does not mistake organization suffixes for locations', () => { + expect(looksLikeOrganizationNameText('Google, LLC')).toBe(true); + expect(looksLikeOrganizationNameText('Google, Inc')).toBe(true); + expect(looksLikeOrganizationNameText('Los Angeles, California')).toBe( + false + ); + expect( + looksLikeOrganizationNameText('Los Angeles, California, United States') + ).toBe(false); + }); + + test('recognizes Bay Area profile locations', () => { + expect(isLikelyLocationText('San Francisco Bay Area')).toBe(true); + }); + + test('recognizes country-only and greater area profile locations', () => { + expect(isLikelyLocationText('United States')).toBe(true); + expect(isLikelyLocationText('Greater Minneapolis-St. Paul Area')).toBe( + true + ); + }); + + test('recognizes comma-separated locations with 3-letter country codes', () => { + expect(isLikelyLocationText('San Francisco, CA, USA')).toBe(true); + expect(isLikelyLocationText('Toronto, ON, CAN')).toBe(true); + }); +}); diff --git a/tests/unit/public-api-improvements.test.ts b/tests/unit/public-api-improvements.test.ts new file mode 100644 index 0000000..20f773b --- /dev/null +++ b/tests/unit/public-api-improvements.test.ts @@ -0,0 +1,197 @@ +import { jest } from '@jest/globals'; +import { + LinkedInProfileParseError, + ParseResultSchema, + parseLinkedInPDF, + parseLinkedInPDFStrict, + safeParseLinkedInPDF, +} from '../../src/index.js'; +import { normalizeLinkedInProfileParseError } from '../../src/errors.js'; +import { StructuralParser } from '../../src/parsers/structural-parser.js'; + +describe('public parser diagnostics and typed errors', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('returns diagnostics for a full LinkedIn-like profile', async () => { + const result = await parseLinkedInPDF(` + Orion Helios + Principal Engineer + San Francisco, CA + https://linkedin.com/in/orion-helios + + Summary + Builds reliable parsing systems for professional profile exports. + + Experience + Principal Engineer at Fixture Co + January 2020 - Present + Experience + This long sentence mentions Summary but is not a header because it is too long. + + Education + BS Computer Science + Example University + + Top Skills + TypeScript + Parsing + `); + + expect(result.diagnostics.sectionsFound).toEqual([ + 'summary', + 'experience', + 'education', + 'top_skills', + ]); + expect(result.diagnostics.confidence).toBeGreaterThanOrEqual(0.75); + expect(result.diagnostics.isLikelyLinkedInExport).toBe(true); + expect(result.diagnostics.isEmpty).toBe(false); + }); + + test('distinguishes sparse LinkedIn-like input from random readable text', async () => { + const sparseResult = await parseLinkedInPDF(` + Sparse Candidate + sparse@example.com + LinkedIn + Available on request for additional professional details. + `); + const randomResult = await parseLinkedInPDF(` + Quarterly memo + Revenue changed + Expenses changed + The appendix contains totals + Nothing here is a profile + End of report + `); + + expect(sparseResult.diagnostics.isLikelyLinkedInExport).toBe(true); + expect(sparseResult.diagnostics.isEmpty).toBe(false); + expect(randomResult.diagnostics.isLikelyLinkedInExport).toBe(false); + expect(randomResult.diagnostics.isEmpty).toBe(true); + expect(randomResult.diagnostics.confidence).toBe(0); + }); + + test('throws typed errors for empty text input', async () => { + await expect(parseLinkedInPDF('')).rejects.toMatchObject({ + code: 'text_extraction_failed', + message: 'Input text is empty or too short', + }); + await expect(parseLinkedInPDF('')).rejects.toBeInstanceOf( + LinkedInProfileParseError + ); + }); + + test('throws typed errors for invalid PDF input', async () => { + await expect(parseLinkedInPDF(Buffer.alloc(0))).rejects.toMatchObject({ + code: 'invalid_pdf', + }); + await expect(parseLinkedInPDF(Buffer.alloc(0))).rejects.toBeInstanceOf( + LinkedInProfileParseError + ); + }); + + test('throws typed errors when PDF extraction returns too little text', async () => { + jest.spyOn(StructuralParser, 'extractStructuredText').mockResolvedValue({ + layout: { + type: 'single-column', + }, + textItems: [], + }); + + await expect(parseLinkedInPDF(new Uint8Array([1, 2, 3]))).rejects.toEqual( + expect.objectContaining({ + code: 'text_extraction_failed', + message: 'PDF appears to be empty or unreadable', + }) + ); + }); + + test('classifies encrypted PDF extraction failures', async () => { + jest + .spyOn(StructuralParser, 'extractStructuredText') + .mockRejectedValue(new Error('PasswordException: No password given')); + + await expect(parseLinkedInPDF(new Uint8Array([1, 2, 3]))).rejects.toEqual( + expect.objectContaining({ + code: 'encrypted_pdf', + }) + ); + }); + + test('classifies unsupported PDF extraction failures', async () => { + jest + .spyOn(StructuralParser, 'extractStructuredText') + .mockRejectedValue(new Error('Unsupported PDF feature')); + + await expect(parseLinkedInPDF(new Uint8Array([1, 2, 3]))).rejects.toEqual( + expect.objectContaining({ + code: 'unsupported_pdf', + }) + ); + }); + + test('normalizes unknown text parser failures as typed errors', () => { + expect( + normalizeLinkedInProfileParseError({ + cause: 'plain text failure', + inputKind: 'text', + }) + ).toEqual( + expect.objectContaining({ + cause: 'plain text failure', + code: 'text_extraction_failed', + message: 'Input text could not be parsed', + }) + ); + }); + + test('strict parser validates the parse result schema', async () => { + const schemaFailure = ParseResultSchema.safeParse({}); + + if (schemaFailure.success) { + throw new Error('Expected an empty object to fail ParseResultSchema'); + } + + const schemaError = schemaFailure.error; + + jest.spyOn(ParseResultSchema, 'safeParse').mockReturnValue(schemaFailure); + + await expect( + parseLinkedInPDFStrict(` + Orion Helios + orion@example.com + LinkedIn + Available on request for additional professional details. + `) + ).rejects.toMatchObject({ + cause: schemaError, + code: 'schema_validation_failed', + }); + }); + + test('strict parser returns schema-valid results on success', async () => { + const result = await parseLinkedInPDFStrict(` + Orion Helios + orion@example.com + LinkedIn + Available on request for additional professional details. + `); + + expect(result.profile.name).toBe('Orion Helios'); + expect(result.diagnostics.isLikelyLinkedInExport).toBe(true); + }); + + test('safe parser returns typed failure results', async () => { + const result = await safeParseLinkedInPDF('short'); + + expect(result.success).toBe(false); + + if (!result.success) { + expect(result.error).toBeInstanceOf(LinkedInProfileParseError); + expect(result.error.code).toBe('text_extraction_failed'); + expect(result.error.message).toBe('Input text is empty or too short'); + } + }); +}); diff --git a/tests/unit/sample-script-helpers.test.ts b/tests/unit/sample-script-helpers.test.ts new file mode 100644 index 0000000..e2da08a --- /dev/null +++ b/tests/unit/sample-script-helpers.test.ts @@ -0,0 +1,54 @@ +import { + optionValue, + sampleWarningFailureDetailLines, +} from '../../scripts/lib/sample-script-helpers.mjs'; + +describe('sample script helpers', () => { + const originalArgv = process.argv; + + afterEach(() => { + process.argv = originalArgv; + }); + + test('returns option values by flag name', () => { + process.argv = ['node', 'script.mjs', '--samples', 'fixtures']; + + expect(optionValue('--samples')).toBe('fixtures'); + }); + + test('rejects missing option values', () => { + process.argv = ['node', 'script.mjs', '--samples', '--output', 'out']; + + expect(() => optionValue('--samples')).toThrow( + 'Missing value for --samples' + ); + }); + + test('formats section warnings and parse failures together', () => { + expect( + sampleWarningFailureDetailLines([ + { + pdfFileName: 'Profile.pdf', + warnings: [ + { + code: 'section_parse_warning', + entry: 2, + field: 'title', + message: 'could not classify line', + rawText: 'Lead', + section: 'experience', + }, + ], + }, + { + parseError: 'PDF appears to be empty or unreadable', + pdfFileName: 'Broken.pdf', + warnings: [], + }, + ]) + ).toEqual([ + 'Profile.pdf experience.title#2 could not classify line: Lead', + 'Broken.pdf parse_error PDF appears to be empty or unreadable', + ]); + }); +}); diff --git a/tests/unit/schemas.test.ts b/tests/unit/schemas.test.ts new file mode 100644 index 0000000..7408947 --- /dev/null +++ b/tests/unit/schemas.test.ts @@ -0,0 +1,141 @@ +import { + ExperienceSchema, + LinkedInProfileSchema, + ParseDiagnosticsSchema, + ParseResultSchema, + ParseWarningSchema, + WarningSectionSchema, +} from '../../src/index.js'; +import { WARNING_SECTIONS } from '../../src/warning-sections.js'; + +describe('exported Zod schemas', () => { + test('validates profile and result shapes', () => { + const profile = LinkedInProfileSchema.parse({ + contact: { + email: 'person@example.com', + }, + top_skills: ['TypeScript'], + languages: [], + certifications: [], + volunteer_work: [], + projects: [], + publications: [], + honors_awards: [], + experience: [ + { + title: 'Engineer', + company: 'Northstar AI', + duration: '2020 - 2022', + dates: { + originalText: '2020 - 2022', + start: { iso: '2020', precision: 'year', text: '2020' }, + end: { iso: '2022', precision: 'year', text: '2022' }, + kind: 'completed', + }, + }, + ], + experience_groups: [ + { + company: 'Northstar AI', + positions: [ + { + title: 'Engineer', + duration: '2020 - 2022', + dates: { + originalText: '2020 - 2022', + start: { iso: '2020', precision: 'year', text: '2020' }, + end: { iso: '2022', precision: 'year', text: '2022' }, + kind: 'completed', + }, + }, + ], + }, + ], + education: [], + }); + + expect(profile.experience[0].dates?.start?.iso).toBe('2020'); + expect( + ParseResultSchema.safeParse({ + diagnostics: { + confidence: 0.82, + isEmpty: false, + isLikelyLinkedInExport: true, + sectionsFound: ['experience', 'education'], + }, + profile, + warnings: [], + }).success + ).toBe(true); + }); + + test('safeParse rejects invalid schema inputs', () => { + expect(ExperienceSchema.safeParse({ title: 'Engineer' }).success).toBe( + false + ); + expect(ParseWarningSchema.safeParse({ code: 'unknown' }).success).toBe( + false + ); + expect( + ParseDiagnosticsSchema.safeParse({ + confidence: 1.2, + isEmpty: false, + isLikelyLinkedInExport: true, + sectionsFound: [], + }).success + ).toBe(false); + expect( + ExperienceSchema.safeParse({ + title: 'Engineer', + company: 'Northstar AI', + duration: '2020 - Present', + dates: { + kind: 'current', + originalText: '2020 - Present', + start: { iso: '2020', precision: 'year', text: '2020' }, + end: { iso: '2022', precision: 'year', text: '2022' }, + }, + }).success + ).toBe(false); + }); + + test('validates section warning shapes', () => { + expect( + ParseWarningSchema.parse({ + code: 'section_parse_warning', + section: 'experience', + entry: 1, + field: 'dates', + message: 'Could not parse date range', + rawText: 'whenever', + }) + ).toEqual( + expect.objectContaining({ + code: 'section_parse_warning', + section: 'experience', + }) + ); + }); + + test('uses the same section values for warnings and diagnostics', () => { + for (const section of WARNING_SECTIONS) { + expect(WarningSectionSchema.safeParse(section).success).toBe(true); + expect( + ParseWarningSchema.safeParse({ + code: 'section_parse_warning', + field: 'section', + message: 'Could not parse section', + section, + }).success + ).toBe(true); + expect( + ParseDiagnosticsSchema.safeParse({ + confidence: 0.5, + isEmpty: false, + isLikelyLinkedInExport: true, + sectionsFound: [section], + }).success + ).toBe(true); + } + }); +}); diff --git a/tests/unit/source-coverage-helpers.test.ts b/tests/unit/source-coverage-helpers.test.ts new file mode 100644 index 0000000..2069ce8 --- /dev/null +++ b/tests/unit/source-coverage-helpers.test.ts @@ -0,0 +1,894 @@ +import { + collectOutputValues, + containsDelimitedPhrase, + createSourceCoverageReport, + createSourceSegmentsFromLayoutText, +} from '../../scripts/lib/source-coverage-helpers.mjs'; + +describe('source coverage helpers', () => { + test('classifies right-column profile text separately from left-column contact text', () => { + const sourceView = createSourceSegmentsFromLayoutText( + [ + 'Contact', + ' Cassandra Troy', + 'cassandra@example.com Staff Engineer', + ' San Francisco, California', + '', + ' Summary', + ' Builds robust parsers.', + '', + ' Experience', + ' Example Co', + ' Staff Engineer', + ].join('\n') + ); + + expect(sourceView.mainColumnStart).toBeGreaterThan(12); + expect(sourceView.segments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + column: 'main', + section: 'identity', + text: 'Cassandra Troy', + }), + expect.objectContaining({ + column: 'sidebar', + section: 'contact', + text: 'cassandra@example.com', + }), + expect.objectContaining({ + column: 'main', + section: 'summary', + text: 'Builds robust parsers.', + }), + expect.objectContaining({ + column: 'main', + section: 'experience', + text: 'Example Co', + }), + ]) + ); + }); + + test('requires source text to match output values in the same inferred section', () => { + const report = createSourceCoverageReport({ + layoutText: ['Summary', 'Engineer', 'Experience', 'Engineer'].join('\n'), + parsedJson: { + profile: { + name: '', + headline: '', + location: '', + contact: {}, + top_skills: [], + languages: [], + certifications: [], + volunteer_work: [], + projects: [], + publications: [], + honors_awards: [], + summary: 'Engineer', + experience: [], + experience_groups: [], + education: [], + }, + warnings: [], + }, + pdfFileName: 'same-section.pdf', + }); + + expect(report.unmatchedSourceSegments).toEqual([ + expect.objectContaining({ + section: 'experience', + text: 'Engineer', + }), + ]); + }); + + test('classifies volunteering experience headings as volunteer work', () => { + const sourceView = createSourceSegmentsFromLayoutText( + ['Volunteering Experience', 'Board Member'].join('\n') + ); + + expect(sourceView.segments).toEqual([ + expect.objectContaining({ + section: 'volunteer_work', + text: 'Board Member', + }), + ]); + }); + + test('reports token-only matches separately from exact source matches', () => { + const report = createSourceCoverageReport({ + layoutText: ['Experience', 'Staff Engineer, ML'].join('\n'), + parsedJson: { + profile: { + name: '', + headline: '', + location: '', + contact: {}, + top_skills: [], + languages: [], + certifications: [], + volunteer_work: [], + projects: [], + publications: [], + honors_awards: [], + summary: '', + experience: [ + { + company: 'Example Co', + title: 'Staff Engineer ML', + }, + ], + experience_groups: [], + education: [], + }, + warnings: [], + }, + pdfFileName: 'loose.pdf', + }); + + expect(report.unmatchedSourceSegmentCount).toBe(0); + expect(report.looseSourceMatches).toEqual([ + expect.objectContaining({ + matchedValue: 'Staff Engineer ML', + text: 'Staff Engineer, ML', + }), + ]); + }); + + test('treats punctuation-spacing differences as exact source matches', () => { + const report = createSourceCoverageReport({ + layoutText: ['Experience', 'London , England'].join('\n'), + parsedJson: parsedJsonWithProfile({ + experience: [ + { + location: 'London, England', + }, + ], + }), + pdfFileName: 'punctuation-spacing.pdf', + }); + + expect(report.unmatchedSourceSegmentCount).toBe(0); + expect(report.looseSourceMatchCount).toBe(0); + expect(report.untracedOutputValueCount).toBe(0); + }); + + test('matches wrapped adjacent same-section source segments exactly', () => { + const report = createSourceCoverageReport({ + layoutText: [ + 'Languages Experience', + 'Chinese (Traditional) (Limited Manhattan Venture Partners', + 'Working) Vice President', + ].join('\n'), + parsedJson: parsedJsonWithProfile({ + experience: [ + { + company: 'Manhattan Venture Partners', + title: 'Vice President', + }, + ], + languages: [ + { + language: 'Chinese (Traditional)', + proficiency: 'Limited Working', + }, + ], + }), + pdfFileName: 'wrapped-language.pdf', + }); + + expect(report.unmatchedSourceSegmentCount).toBe(0); + expect(report.looseSourceMatchCount).toBe(0); + expect(report.untracedOutputValueCount).toBe(0); + }); + + test('classifies standalone experience locations without confusing the next company', () => { + const sourceView = createSourceSegmentsFromLayoutText( + [ + 'Experience', + 'Foundation Law Group LLP', + 'Partner', + 'August 2017 - Present (8 years 10 months)', + 'Los Angeles', + 'Foundation Law Group built client tools.', + 'Arent Fox', + 'Partner', + 'January 2013 - August 2017 (4 years 8 months)', + 'DLA Piper', + 'Partner', + 'January 2006 - December 2012 (7 years)', + 'Los Angeles', + ].join('\n') + ); + + expect(sourceView.segments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + experienceGroupIndex: 0, + experiencePositionIndex: 0, + fieldRole: 'location', + text: 'Los Angeles', + }), + expect.objectContaining({ + experienceGroupIndex: 2, + experiencePositionIndex: 2, + fieldRole: 'organization', + text: 'DLA Piper', + }), + ]) + ); + }); + + test('reports location lines misclassified into experience descriptions', () => { + const report = createSourceCoverageReport({ + layoutText: [ + 'Experience', + 'Foundation Law Group LLP', + 'Partner', + 'August 2017 - Present (8 years 10 months)', + 'Los Angeles', + 'Built durable client tools.', + ].join('\n'), + parsedJson: parsedJsonWithProfile({ + experience: [ + { + company: 'Foundation Law Group LLP', + title: 'Partner', + duration: 'August 2017 - Present', + description: 'Los Angeles Built durable client tools.', + }, + ], + }), + pdfFileName: 'experience-location-description-mismatch.pdf', + }); + + expect(report.unmatchedSourceSegmentCount).toBe(0); + expect(report.untracedOutputValueCount).toBe(0); + expect(report.fieldMismatchOutputMatchCount).toBe(1); + expect(report.fieldMismatchOutputMatches).toEqual([ + expect.objectContaining({ + outputFieldRole: 'description', + path: 'profile.experience[0].description', + sourceFieldRole: 'location', + sourceText: 'Los Angeles', + }), + ]); + expect( + report.sections.find(section => section.section === 'experience') + ).toEqual(expect.objectContaining({ fieldMismatchOutputMatchCount: 1 })); + }); + + test('reports single-word whitelisted locations misclassified into experience descriptions', () => { + const report = createSourceCoverageReport({ + layoutText: [ + 'Experience', + 'Example Co', + 'Principal Engineer', + 'January 2020 - Present', + 'London', + 'Built durable client tools.', + ].join('\n'), + parsedJson: parsedJsonWithProfile({ + experience: [ + { + company: 'Example Co', + title: 'Principal Engineer', + duration: 'January 2020 - Present', + description: 'London Built durable client tools.', + }, + ], + }), + pdfFileName: 'single-word-location-description-mismatch.pdf', + }); + + expect(report.fieldMismatchOutputMatches).toEqual([ + expect.objectContaining({ + outputFieldRole: 'description', + path: 'profile.experience[0].description', + sourceFieldRole: 'location', + sourceText: 'London', + }), + ]); + }); + + test('accepts standalone experience locations when they remain in location fields', () => { + const report = createSourceCoverageReport({ + layoutText: [ + 'Experience', + 'Foundation Law Group LLP', + 'Partner', + 'August 2017 - Present (8 years 10 months)', + 'Los Angeles', + 'Built durable client tools.', + ].join('\n'), + parsedJson: parsedJsonWithProfile({ + experience: [ + { + company: 'Foundation Law Group LLP', + title: 'Partner', + duration: 'August 2017 - Present', + location: 'Los Angeles', + description: 'Built durable client tools.', + }, + ], + }), + pdfFileName: 'experience-location-field.pdf', + }); + + expect(report.fieldMismatchOutputMatchCount).toBe(0); + }); + + test('classifies abbreviated standalone experience locations with periods', () => { + const sourceView = createSourceSegmentsFromLayoutText( + [ + 'Experience', + 'Example Co', + 'Principal Engineer', + 'January 2020 - Present', + 'St. Louis', + 'Built durable client tools.', + ].join('\n') + ); + + expect(sourceView.segments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + fieldRole: 'location', + text: 'St. Louis', + }), + ]) + ); + }); + + test('classifies standalone uppercase region-code locations', () => { + const sourceView = createSourceSegmentsFromLayoutText( + [ + 'Experience', + 'Example Co', + 'Principal Engineer', + 'January 2020 - Present', + 'UK', + 'Built durable client tools.', + 'Second Co', + 'Staff Engineer', + 'January 2018 - December 2019', + 'USA', + 'Built durable internal tools.', + 'Third Co', + 'Advisor', + 'January 2016 - December 2017', + 'U.S.', + ].join('\n') + ); + + expect(sourceView.segments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + fieldRole: 'location', + text: 'UK', + }), + expect.objectContaining({ + fieldRole: 'location', + text: 'USA', + }), + expect.objectContaining({ + fieldRole: 'location', + text: 'U.S.', + }), + ]) + ); + }); + + test('does not classify ambiguous standalone region codes as locations', () => { + for (const regionCode of [ + 'IN', + 'ME', + 'OR', + 'Platform, IN', + 'Platform, ME', + 'Platform, OR', + ]) { + const sourceView = createSourceSegmentsFromLayoutText( + [ + 'Experience', + 'Example Co', + 'Principal Engineer', + 'January 2020 - Present', + regionCode, + 'Built durable client tools.', + ].join('\n') + ); + + expect(sourceView.segments).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + fieldRole: 'location', + text: regionCode, + }), + ]) + ); + } + }); + + test('keeps unambiguous comma-separated region-code locations', () => { + const sourceView = createSourceSegmentsFromLayoutText( + [ + 'Experience', + 'Example Co', + 'Principal Engineer', + 'January 2020 - Present', + 'Platform, TX', + 'Built durable client tools.', + ].join('\n') + ); + + expect(sourceView.segments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + fieldRole: 'location', + text: 'Platform, TX', + }), + ]) + ); + }); + + test('normalizes diacritics for standalone location lookups', () => { + const sourceView = createSourceSegmentsFromLayoutText( + [ + 'Experience', + 'Example Co', + 'Principal Engineer', + 'January 2020 - Present', + 'São Paulo', + 'Built durable client tools.', + ].join('\n') + ); + + expect(sourceView.segments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + fieldRole: 'location', + text: 'São Paulo', + }), + ]) + ); + }); + + test('classifies longer standalone area locations with country tokens', () => { + const sourceView = createSourceSegmentsFromLayoutText( + [ + 'Experience', + 'Example Co', + 'Principal Engineer', + 'January 2020 - Present', + 'Greater Los Angeles Area, United States', + 'Built durable client tools.', + ].join('\n') + ); + + expect(sourceView.segments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + fieldRole: 'location', + text: 'Greater Los Angeles Area, United States', + }), + ]) + ); + }); + + test('keeps generic geo-token phrases in descriptions without stronger location evidence', () => { + const sourceView = createSourceSegmentsFromLayoutText( + [ + 'Experience', + 'Example Co', + 'Principal Engineer', + 'January 2020 - Present', + 'Platform Region', + 'Built durable client tools.', + ].join('\n') + ); + const report = createSourceCoverageReport({ + layoutText: [ + 'Experience', + 'Example Co', + 'Principal Engineer', + 'January 2020 - Present', + 'Platform Region', + 'Built durable client tools.', + ].join('\n'), + parsedJson: parsedJsonWithProfile({ + experience: [ + { + company: 'Example Co', + title: 'Principal Engineer', + duration: 'January 2020 - Present', + description: 'Platform Region Built durable client tools.', + }, + ], + }), + pdfFileName: 'generic-geo-token-description.pdf', + }); + + expect(sourceView.segments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + fieldRole: 'description', + text: 'Platform Region', + }), + ]) + ); + expect(report.fieldMismatchOutputMatchCount).toBe(0); + expect(report.untracedOutputValueCount).toBe(0); + expect(report.unmatchedSourceSegments).toEqual( + expect.not.arrayContaining([ + expect.objectContaining({ + fieldRole: 'location', + text: 'Platform Region', + }), + ]) + ); + }); + + test('keeps title-bearing area phrases in descriptions', () => { + const report = createSourceCoverageReport({ + layoutText: [ + 'Experience', + 'Creative Artists Agency', + 'Chief of Staff to the CEO - Evolution Media', + 'April 2013 - April 2014', + 'Corporate Finance Los Angeles Metropolitan Area', + ].join('\n'), + parsedJson: parsedJsonWithProfile({ + experience: [ + { + company: 'Creative Artists Agency', + title: 'Chief of Staff to the CEO - Evolution Media', + duration: 'April 2013 - April 2014', + description: 'Corporate Finance Los Angeles Metropolitan Area', + }, + ], + }), + pdfFileName: 'title-bearing-area-description.pdf', + }); + + expect(report.fieldMismatchOutputMatchCount).toBe(0); + expect(report.untracedOutputValueCount).toBe(0); + }); + + test('keeps place-word organization names out of location field roles', () => { + const sourceView = createSourceSegmentsFromLayoutText( + [ + 'Experience', + 'Los Angeles Animal Services', + 'Commissioner', + 'September 2003 - August 2005', + 'Tokyo Forex', + 'SVP', + 'August 1992 - August 1994', + ].join('\n') + ); + + expect(sourceView.segments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + fieldRole: 'organization', + text: 'Los Angeles Animal Services', + }), + expect.objectContaining({ + fieldRole: 'organization', + text: 'Tokyo Forex', + }), + ]) + ); + expect(sourceView.segments).toEqual( + expect.not.arrayContaining([ + expect.objectContaining({ + fieldRole: 'location', + text: 'Los Angeles Animal Services', + }), + expect.objectContaining({ + fieldRole: 'location', + text: 'Tokyo Forex', + }), + ]) + ); + }); + + test('does not treat unsynced region codes as standalone locations', () => { + const sourceView = createSourceSegmentsFromLayoutText( + [ + 'Experience', + 'Example Co', + 'Principal Engineer', + 'January 2020 - Present', + 'Hartford, CT', + 'Built durable client tools.', + ].join('\n') + ); + + expect(sourceView.segments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + fieldRole: 'description', + text: 'Hartford, CT', + }), + ]) + ); + }); + + test('classifies full-state city locations as metadata source fields', () => { + const sourceView = createSourceSegmentsFromLayoutText( + [ + 'Experience', + 'Parametric', + 'Senior Investment Analyst', + 'September 2016 - May 2019', + 'Minneapolis, Minnesota', + 'Built overlay solutions.', + ].join('\n') + ); + + expect(sourceView.segments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + fieldRole: 'location', + text: 'Minneapolis, Minnesota', + }), + ]) + ); + + const report = createSourceCoverageReport({ + layoutText: [ + 'Experience', + 'Parametric', + 'Senior Investment Analyst', + 'September 2016 - May 2019', + 'Minneapolis, Minnesota', + 'Built overlay solutions.', + ].join('\n'), + parsedJson: parsedJsonWithProfile({ + experience: [ + { + company: 'Parametric', + title: 'Senior Investment Analyst', + duration: 'September 2016 - May 2019', + location: 'Minneapolis, Minnesota', + description: 'Built overlay solutions.', + }, + ], + }), + pdfFileName: 'full-state-location.pdf', + }); + + expect(report.fieldMismatchOutputMatchCount).toBe(0); + expect(report.untracedOutputValueCount).toBe(0); + }); + + test('empty delimited phrases do not match', () => { + expect(containsDelimitedPhrase('Los Angeles', '')).toBe(false); + }); + + test('does not classify comma phrases as locations without geo evidence', () => { + const sourceView = createSourceSegmentsFromLayoutText( + [ + 'Experience', + 'Example Co', + 'Operations Lead', + 'January 2020 - Present', + 'Strategy, Operations', + 'Led durable programs.', + ].join('\n') + ); + + expect(sourceView.segments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + fieldRole: 'description', + text: 'Strategy, Operations', + }), + ]) + ); + }); + + test('uses experience ordinals when identical locations appear in multiple entries', () => { + const report = createSourceCoverageReport({ + layoutText: [ + 'Experience', + 'Alpha LLP', + 'Partner', + 'August 2017 - Present (8 years 10 months)', + 'Los Angeles', + 'Built durable client tools.', + 'Beta LLP', + 'Partner', + 'January 2006 - December 2012 (7 years)', + 'Los Angeles', + ].join('\n'), + parsedJson: parsedJsonWithProfile({ + experience: [ + { + company: 'Alpha LLP', + title: 'Partner', + duration: 'August 2017 - Present', + description: 'Built durable client tools.', + }, + { + company: 'Beta LLP', + title: 'Partner', + duration: 'January 2006 - December 2012', + description: 'Los Angeles', + }, + ], + }), + pdfFileName: 'repeated-location-mismatch.pdf', + }); + + expect(report.fieldMismatchOutputMatches).toEqual([ + expect.objectContaining({ + path: 'profile.experience[1].description', + sourceLineNumber: 10, + sourceText: 'Los Angeles', + }), + ]); + }); + + test('traces output values found in another source section separately', () => { + const report = createSourceCoverageReport({ + layoutText: ['Summary', 'Reach Cassandra at cassandra@example.com.'].join( + '\n' + ), + parsedJson: parsedJsonWithProfile({ + contact: { + email: 'cassandra@example.com', + }, + summary: 'Reach Cassandra at cassandra@example.com.', + }), + pdfFileName: 'cross-section-email.pdf', + }); + + expect(report.unmatchedSourceSegmentCount).toBe(0); + expect(report.looseSourceMatchCount).toBe(0); + expect(report.untracedOutputValueCount).toBe(0); + expect(report.crossSectionOutputMatchCount).toBe(1); + expect(report.crossSectionOutputMatches).toEqual([ + expect.objectContaining({ + matchedSection: 'summary', + path: 'profile.contact.email', + section: 'contact', + }), + ]); + }); + + test('traces cross-section output values across combined source segments', () => { + const report = createSourceCoverageReport({ + layoutText: [ + 'Summary', + 'Reach Cassandra at', + 'cassandra@example.com.', + ].join('\n'), + parsedJson: parsedJsonWithProfile({ + contact: { + email: 'cassandra@example.com', + }, + summary: 'Reach Cassandra at cassandra@example.com.', + }), + pdfFileName: 'combined-cross-section-email.pdf', + }); + + expect(report.unmatchedSourceSegmentCount).toBe(0); + expect(report.looseSourceMatchCount).toBe(0); + expect(report.untracedOutputValueCount).toBe(0); + expect(report.crossSectionOutputMatches).toEqual([ + expect.objectContaining({ + matchedSection: 'summary', + path: 'profile.contact.email', + section: 'contact', + }), + ]); + }); + + test('does not require derived date fields or warnings to trace to PDF text', () => { + const values = collectOutputValues({ + profile: { + experience: [ + { + dates: { + kind: 'current', + start: { + iso: '2024-07', + precision: 'month', + text: 'July 2024', + }, + }, + }, + ], + languages: [ + { + name: 'Esperanto', + proficiency: 'Unknown', + }, + ], + }, + warnings: [ + { + code: 'missing_profile_field', + message: 'Could not extract contact email', + }, + ], + }); + + expect(values).toEqual([ + { + path: 'profile.experience[0].dates.start.text', + section: 'experience', + value: 'July 2024', + }, + { + path: 'profile.languages[0].name', + section: 'languages', + value: 'Esperanto', + }, + ]); + }); + + test('matches wrapped URL source text against normalized output URLs', () => { + const report = createSourceCoverageReport({ + layoutText: [ + 'Contact', + 'www.linkedin.com/in/', + 'cassandra-troy (LinkedIn)', + ].join('\n'), + parsedJson: { + profile: { + name: '', + headline: '', + location: '', + contact: { + linkedin_url: 'https://linkedin.com/in/cassandra-troy', + }, + top_skills: [], + languages: [], + certifications: [], + volunteer_work: [], + projects: [], + publications: [], + honors_awards: [], + summary: '', + experience: [], + experience_groups: [], + education: [], + }, + warnings: [], + }, + pdfFileName: 'wrapped-url.pdf', + }); + + expect(report.untracedOutputValueCount).toBe(0); + }); +}); + +function parsedJsonWithProfile(profile: Record) { + return { + profile: { + name: '', + headline: '', + location: '', + contact: {}, + top_skills: [], + languages: [], + certifications: [], + volunteer_work: [], + projects: [], + publications: [], + honors_awards: [], + summary: '', + experience: [], + experience_groups: [], + education: [], + ...profile, + }, + warnings: [], + }; +} diff --git a/tests/unit/structural-parser.test.ts b/tests/unit/structural-parser.test.ts new file mode 100644 index 0000000..3d69793 --- /dev/null +++ b/tests/unit/structural-parser.test.ts @@ -0,0 +1,355 @@ +import { StructuralParser } from '../../src/parsers/structural-parser.js'; +import { createStructuralLines } from '../../src/utils/structural-lines.js'; +import type { TextItem } from '../../src/types/structural.js'; + +function item({ + pageIndex, + text, + width, + x, + y, +}: { + pageIndex?: number; + text: string; + width?: number; + x: number; + y: number; +}): TextItem { + return { + text, + x, + y, + ...(pageIndex === undefined ? {} : { pageIndex }), + fontSize: 10, + fontFamily: 'Helvetica', + width: width ?? text.length * 5, + height: 10, + }; +} + +function twoColumnPageItems(pageIndex: number): TextItem[] { + const pageYOffset = pageIndex * -10000; + const leftItems = Array.from({ length: 2 }, (_, index) => + item({ + pageIndex, + text: `left ${pageIndex}-${index}`, + x: 30, + y: pageYOffset + 700 - index * 20, + }) + ); + const rightItems = Array.from({ length: 15 }, (_, index) => + item({ + pageIndex, + text: `right ${pageIndex}-${index}`, + x: 220, + y: pageYOffset + 700 - index * 20, + }) + ); + + return [...leftItems, ...rightItems]; +} + +describe('StructuralParser', () => { + test('treats exactly ten left-column items as a two-column layout', () => { + const leftItems = Array.from({ length: 10 }, (_, index) => + item({ text: `left ${index}`, x: 40, y: 700 - index * 20 }) + ); + const rightItems = Array.from({ length: 21 }, (_, index) => + item({ text: `right ${index}`, x: 220, y: 700 - index * 20 }) + ); + + const groups = StructuralParser.groupTextByProximity( + [...leftItems, ...rightItems], + 5 + ); + + expect( + groups.every( + group => + group.every(groupItem => groupItem.x < 150) || + group.every(groupItem => groupItem.x >= 150) + ) + ).toBe(true); + }); + + test('treats compact seven-item sidebars as a two-column layout', () => { + const leftItems = Array.from({ length: 7 }, (_, index) => + item({ text: `left ${index}`, x: 22, y: 700 - index * 20 }) + ); + const rightItems = Array.from({ length: 40 }, (_, index) => + item({ text: `right ${index}`, x: 224, y: 700 - index * 20 }) + ); + + const groups = StructuralParser.groupTextByProximity( + [...leftItems, ...rightItems], + 5 + ); + + expect(groups).toHaveLength(47); + expect( + groups.every( + group => + group.every(groupItem => groupItem.x < 150) || + group.every(groupItem => groupItem.x >= 150) + ) + ).toBe(true); + }); + + test('keeps narrow column gaps as a single-column layout', () => { + const leftItems = Array.from({ length: 7 }, (_, index) => ({ + ...item({ text: `left ${index}`, x: 40, y: 700 - index * 20 }), + width: 0, + })); + const rightItems = Array.from({ length: 21 }, (_, index) => + item({ text: `right ${index}`, x: 155, y: 700 - index * 20 }) + ); + + const layout = StructuralParser['detectLayout']([ + ...leftItems, + ...rightItems, + ]); + + expect(layout).toEqual(expect.objectContaining({ type: 'single-column' })); + }); + + test('keeps extended sidebar labels out of the main column', () => { + const leftItems = [ + item({ text: 'Contact', x: 22, y: 740 }), + item({ text: 'medium.com/@example', x: 22, y: 700 }), + item({ text: '(Blog)', x: 159, y: 700 }), + item({ text: 'Top Skills', x: 22, y: 660 }), + item({ text: 'Early Stage Investment', x: 22, y: 640 }), + ]; + const rightItems = Array.from({ length: 20 }, (_, index) => + item({ + text: index === 0 ? 'Summary' : `Main summary line ${index}`, + x: 224, + y: 720 - index * 18, + }) + ); + const layout = StructuralParser.detectLayout([...leftItems, ...rightItems]); + const lines = createStructuralLines({ + layout, + textItems: [...leftItems, ...rightItems], + }); + + expect(layout.type).toBe('two-column'); + expect( + lines.find(line => line.text === 'medium.com/@example (Blog)') + ).toEqual(expect.objectContaining({ column: 'left' })); + expect(lines.find(line => line.text === 'Summary')).toEqual( + expect.objectContaining({ column: 'right' }) + ); + }); + + test('keeps page layouts aligned with sparse page indexes', () => { + const singleColumnPageItem = item({ + pageIndex: 2, + text: 'Single page content', + x: 30, + y: -19300, + }); + const textItems = [...twoColumnPageItems(0), singleColumnPageItem]; + const layout = StructuralParser.detectLayout(textItems); + const lines = createStructuralLines({ + layout, + textItems: [singleColumnPageItem], + }); + + expect(layout.type).toBe('two-column'); + expect(layout.pageLayouts?.[0]?.type).toBe('two-column'); + expect(layout.pageLayouts?.[1]).toBeUndefined(); + expect(layout.pageLayouts?.[2]?.type).toBe('single-column'); + expect(lines).toEqual([ + expect.objectContaining({ + column: 'single', + text: 'Single page content', + }), + ]); + }); + + test('infers one-based flattened page offsets from negative y values', () => { + const pageOneItems = twoColumnPageItems(1).map( + ({ pageIndex: _pageIndex, ...pageItem }) => pageItem + ); + const layout = StructuralParser.detectLayout(pageOneItems); + + expect(layout.type).toBe('two-column'); + expect(layout.pageLayouts?.[0]).toBeUndefined(); + expect(layout.pageLayouts?.[1]?.type).toBe('two-column'); + }); + + test('returns no groups or structural lines for empty inputs', () => { + expect(StructuralParser.groupTextByProximity([])).toEqual([]); + expect( + createStructuralLines({ + layout: { + type: 'single-column', + }, + textItems: [], + }) + ).toEqual([]); + }); + + test('sorts structural lines with the same y position by x position', () => { + const lines = createStructuralLines({ + layout: { + type: 'two-column', + sidebarBounds: { + left: 20, + right: 100, + top: 700, + bottom: 700, + }, + mainBounds: { + left: 220, + right: 300, + top: 700, + bottom: 700, + }, + }, + textItems: [ + item({ text: 'Right', x: 220, y: 700 }), + item({ text: 'Left', x: 20, y: 700 }), + ], + }); + + expect(lines.map(line => line.text)).toEqual(['Left', 'Right']); + }); + + test('does not join the pronoun I into the following word', () => { + const lines = createStructuralLines({ + layout: { + type: 'single-column', + }, + textItems: [ + item({ text: 'I', x: 220, y: 700 }), + item({ text: 'lead', x: 230, y: 700 }), + ], + }); + + expect(lines).toHaveLength(1); + expect(lines[0].text).toBe('I lead'); + }); + + test('does not join single-letter domain acronyms into the following word', () => { + const lines = createStructuralLines({ + layout: { + type: 'single-column', + }, + textItems: [ + item({ text: 'Series', x: 220, y: 700 }), + item({ text: 'A', x: 260, y: 700 }), + item({ text: 'interest', x: 270, y: 700 }), + item({ text: 'Model', x: 220, y: 680 }), + item({ text: 'Y', x: 260, y: 680 }), + item({ text: 'production', x: 270, y: 680 }), + item({ text: 'Gen', x: 220, y: 660 }), + item({ text: 'Z', x: 250, y: 660 }), + item({ text: 'brand', x: 260, y: 660 }), + item({ text: 'S/S', x: 220, y: 640 }), + item({ text: 'collection', x: 250, y: 640 }), + ], + }); + + expect(lines.map(line => line.text)).toEqual([ + 'Series A interest', + 'Model Y production', + 'Gen Z brand', + 'S/S collection', + ]); + }); + + test('does not join words after ampersand abbreviations', () => { + const lines = createStructuralLines({ + layout: { + type: 'single-column', + }, + textItems: [ + item({ text: 'P&L', x: 220, y: 700 }), + item({ text: 'management', x: 245, y: 700 }), + ], + }); + + expect(lines).toHaveLength(1); + expect(lines[0].text).toBe('P&L management'); + }); + + test('uses global two-column detection when individual pages are too sparse', () => { + const sparsePageItems = [0, 1].flatMap(pageIndex => { + const pageYOffset = pageIndex * -10000; + + return [ + item({ + pageIndex, + text: `left ${pageIndex}`, + x: 30, + y: pageYOffset + 700, + }), + ...Array.from({ length: 8 }, (_, index) => + item({ + pageIndex, + text: `right ${pageIndex}-${index}`, + x: 220, + y: pageYOffset + 700 - index * 20, + }) + ), + ]; + }); + + const layout = StructuralParser.detectLayout(sparsePageItems); + + expect(layout.type).toBe('two-column'); + expect(layout.pageLayouts?.every(page => page.type === 'single-column')).toBe( + true + ); + }); + + test('rejects two-column layouts with insufficient visual gap', () => { + const leftItems = Array.from({ length: 2 }, (_, index) => + item({ + text: `left ${index}`, + width: 160, + x: 30, + y: 700 - index * 20, + }) + ); + const rightItems = Array.from({ length: 15 }, (_, index) => + item({ text: `right ${index}`, x: 190, y: 700 - index * 20 }) + ); + + expect(StructuralParser.detectLayout([...leftItems, ...rightItems])).toEqual( + expect.objectContaining({ type: 'single-column' }) + ); + }); + + test('covers empty bounds merging and default proximity grouping', () => { + expect(StructuralParser['mergeBounds']([undefined])).toBeUndefined(); + expect( + StructuralParser['groupItemsByY']([ + item({ text: 'A', x: 10, y: 700 }), + item({ text: 'B', x: 20, y: 696 }), + item({ text: 'C', x: 20, y: 680 }), + ]) + ).toHaveLength(2); + }); + + test('falls back when a left-column item has no measured width', () => { + const leftItems = [ + item({ text: 'left 0', width: 0, x: 30, y: 700 }), + item({ text: 'left 1', x: 30, y: 680 }), + ]; + const rightItems = Array.from({ length: 15 }, (_, index) => + item({ text: `right ${index}`, x: 220, y: 700 - index * 20 }) + ); + + expect(StructuralParser.detectLayout([...leftItems, ...rightItems])).toEqual( + expect.objectContaining({ + sidebarBounds: expect.objectContaining({ + right: 130, + }), + type: 'two-column', + }) + ); + }); +}); diff --git a/tests/unit/structural-sections.test.ts b/tests/unit/structural-sections.test.ts new file mode 100644 index 0000000..6aae35f --- /dev/null +++ b/tests/unit/structural-sections.test.ts @@ -0,0 +1,41 @@ +import { extractStructuralSectionLines } from '../../src/utils/structural-sections.js'; +import type { StructuralLine } from '../../src/utils/structural-lines.js'; + +describe('extractStructuralSectionLines', () => { + test('stops collecting a section after a boundary header in the same column', () => { + const result = extractStructuralSectionLines({ + section: 'education', + structuralLines: [ + structuralLine({ text: 'Education', y: 700 }), + structuralLine({ text: 'Example University', y: 680 }), + structuralLine({ text: 'Courses', y: 660 }), + structuralLine({ text: 'Course Detail', y: 640 }), + ], + }); + + expect(result).toEqual({ + hasSection: true, + lines: [expect.objectContaining({ text: 'Example University' })], + }); + }); +}); + +function structuralLine({ + column = 'right', + text, + y, +}: { + column?: StructuralLine['column']; + text: string; + y: number; +}): StructuralLine { + return { + column, + fontSize: 10, + height: 10, + text, + width: text.length * 5, + x: column === 'left' ? 20 : 220, + y, + }; +} diff --git a/tests/unit/verify-samples.test.ts b/tests/unit/verify-samples.test.ts new file mode 100644 index 0000000..05ad33e --- /dev/null +++ b/tests/unit/verify-samples.test.ts @@ -0,0 +1,337 @@ +import { + sampleVerificationSteps, + verifySamples, +} from '../../scripts/verify-samples.mjs'; + +interface FakeDirectoryEntry { + kind: 'directory' | 'file' | 'other'; + name: string; +} + +interface FakeCommandInvocation { + args: string[]; + command: string; +} + +interface FakeCommandResult { + exitCode: number; + stderr: string; + stdout: string; +} + +interface FakeDependenciesParams { + entries?: FakeDirectoryEntry[]; + exists?: boolean; + results?: FakeCommandResult[]; +} + +function fakeDependencies({ + entries = [], + exists = true, + results = [], +}: FakeDependenciesParams = {}): { + commands: FakeCommandInvocation[]; + dependencies: { + directoryExists: () => Promise; + listDirectory: () => Promise; + runCommand: (command: FakeCommandInvocation) => Promise; + }; +} { + const commands: FakeCommandInvocation[] = []; + const queuedResults = [...results]; + + return { + commands, + dependencies: { + async directoryExists() { + return exists; + }, + async listDirectory() { + return entries; + }, + async runCommand(command) { + commands.push(command); + + return ( + queuedResults.shift() ?? { + exitCode: 0, + stderr: '', + stdout: `${command.command} ${command.args.join(' ')} ok\n`, + } + ); + }, + }, + }; +} + +const samplePairEntries: FakeDirectoryEntry[] = [ + { + kind: 'file', + name: 'Profile.pdf', + }, + { + kind: 'file', + name: 'Profile.json', + }, +]; + +describe('sample verification wrapper', () => { + test('uses one build and then built sample verification commands', () => { + expect(sampleVerificationSteps('samples')).toEqual([ + { + args: ['run', 'build'], + command: 'pnpm', + label: 'Build package', + stopOnFailure: true, + }, + { + args: ['bin/cli.js', 'verify-json', 'samples'], + command: 'node', + label: 'Verify sample JSON baselines', + stopOnFailure: false, + }, + { + args: ['scripts/check-sample-warnings.mjs', '--samples', 'samples'], + command: 'node', + label: 'Check sample section warnings', + stopOnFailure: false, + }, + { + args: [ + 'scripts/sample-completeness-audit.mjs', + '--samples', + 'samples', + '--strict', + ], + command: 'node', + label: 'Audit sample source coverage', + stopOnFailure: false, + }, + ]); + }); + + test('adds a suspect JSON generation step when bootstrapping PDFs', () => { + expect( + sampleVerificationSteps('samples', { shouldGenerateJson: true }) + ).toEqual([ + { + args: ['run', 'build'], + command: 'pnpm', + label: 'Build package', + stopOnFailure: true, + }, + { + args: ['bin/cli.js', 'write-json', 'samples'], + command: 'node', + label: 'Generate suspect sample JSON baselines', + stopOnFailure: true, + }, + { + args: ['bin/cli.js', 'verify-json', 'samples'], + command: 'node', + label: 'Verify sample JSON baselines', + stopOnFailure: false, + }, + { + args: ['scripts/check-sample-warnings.mjs', '--samples', 'samples'], + command: 'node', + label: 'Check sample section warnings', + stopOnFailure: false, + }, + { + args: [ + 'scripts/sample-completeness-audit.mjs', + '--samples', + 'samples', + '--strict', + ], + command: 'node', + label: 'Audit sample source coverage', + stopOnFailure: false, + }, + ]); + }); + + test('fails before commands when the local samples directory is absent', async () => { + const { commands, dependencies } = fakeDependencies({ exists: false }); + + const result = await verifySamples({ + dependencies, + samplesDir: 'samples', + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Sample directory not found'); + expect(result.stderr).toContain('local and gitignored'); + expect(commands).toEqual([]); + }); + + test('fails before commands when there are no matching PDF JSON pairs', async () => { + const { commands, dependencies } = fakeDependencies({ + entries: [ + { + kind: 'file', + name: 'OnlyPdf.pdf', + }, + { + kind: 'file', + name: 'OnlyJson.json', + }, + ], + }); + + const result = await verifySamples({ + dependencies, + samplesDir: 'samples', + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('No matching PDF/JSON sample pairs'); + expect(result.stderr).toContain('Found 1 PDF file(s) and 1 JSON file(s)'); + expect(commands).toEqual([]); + }); + + test('runs all sample checks after a successful build', async () => { + const { commands, dependencies } = fakeDependencies({ + entries: samplePairEntries, + }); + + const result = await verifySamples({ + dependencies, + samplesDir: 'samples', + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Local sample verification passed.'); + expect(commands).toEqual( + sampleVerificationSteps('samples').map(({ args, command }) => ({ + args, + command, + })) + ); + expect(commands.filter(command => command.command === 'pnpm')).toHaveLength( + 1 + ); + }); + + test('generates suspect JSON when PDFs exist without JSON baselines', async () => { + const { commands, dependencies } = fakeDependencies({ + entries: [ + { + kind: 'file', + name: 'Profile.pdf', + }, + ], + }); + + const result = await verifySamples({ + dependencies, + samplesDir: 'samples', + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Generate suspect sample JSON baselines'); + expect(commands).toEqual( + sampleVerificationSteps('samples', { shouldGenerateJson: true }).map( + ({ args, command }) => ({ + args, + command, + }) + ) + ); + }); + + test('stops after suspect JSON generation failure', async () => { + const allSteps = sampleVerificationSteps('samples', { + shouldGenerateJson: true, + }); + const generationStepIndex = allSteps.findIndex( + step => step.label === 'Generate suspect sample JSON baselines' + ); + const executedSteps = allSteps.slice(0, generationStepIndex + 1); + const skippedLabels = allSteps + .slice(generationStepIndex + 1) + .map(step => step.label); + const { commands, dependencies } = fakeDependencies({ + entries: [ + { + kind: 'file', + name: 'Profile.pdf', + }, + ], + results: [ + { + exitCode: 0, + stderr: '', + stdout: 'build ok\n', + }, + { + exitCode: 1, + stderr: 'write-json failed\n', + stdout: '', + }, + ], + }); + + const result = await verifySamples({ + dependencies, + samplesDir: 'samples', + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + 'Generate suspect sample JSON baselines exited with code 1' + ); + expect(commands).toEqual( + executedSteps.map(({ args, command }) => ({ + args, + command, + })) + ); + for (const skippedLabel of skippedLabels) { + expect(result.stdout).not.toContain(skippedLabel); + } + }); + + test('aggregates non-build sample command failures', async () => { + const { commands, dependencies } = fakeDependencies({ + entries: samplePairEntries, + results: [ + { + exitCode: 0, + stderr: '', + stdout: 'build ok\n', + }, + { + exitCode: 1, + stderr: 'verify-json failed\n', + stdout: '', + }, + { + exitCode: 0, + stderr: '', + stdout: 'warnings ok\n', + }, + { + exitCode: 2, + stderr: 'audit failed\n', + stdout: '', + }, + ], + }); + + const result = await verifySamples({ + dependencies, + samplesDir: 'samples', + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain( + 'Verify sample JSON baselines exited with code 1' + ); + expect(result.stderr).toContain( + 'Audit sample source coverage exited with code 2' + ); + expect(commands).toHaveLength(4); + }); +}); diff --git a/utils/generate-pdf.ts b/utils/generate-pdf.ts deleted file mode 100644 index aae9a8f..0000000 --- a/utils/generate-pdf.ts +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/env node -/** - * Script para gerar um PDF de teste com estrutura similar ao Profile.pdf - * usando Node.js e Puppeteer ou Chrome headless. - */ - -import { spawn } from 'child_process'; -import { existsSync } from 'fs'; -import { join } from 'path'; - -const HTML_FILE = join(process.cwd(), 'test_resume.html'); -const PDF_FILE = join(process.cwd(), 'test_resume.pdf'); - -// Possíveis caminhos do Chrome no macOS, Linux e Windows -const CHROME_PATHS = [ - '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', // macOS - '/Applications/Chromium.app/Contents/MacOS/Chromium', // macOS Chromium - 'google-chrome', // Linux - 'chromium', // Linux - 'chromium-browser', // Linux - 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', // Windows - 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', // Windows x86 -]; - -async function findChrome(): Promise { - for (const path of CHROME_PATHS) { - if (existsSync(path)) { - return path; - } - } - - // Tenta os comandos que podem estar no PATH - for (const cmd of ['google-chrome', 'chromium', 'chromium-browser']) { - try { - const result = await runCommand('which', [cmd]); - if (result.success) { - return cmd; - } - } catch { - // Ignore e continue - } - } - - return null; -} - -interface CommandResult { - success: boolean; - stdout: string; - stderr: string; -} - -function runCommand(command: string, args: string[]): Promise { - return new Promise((resolve) => { - const child = spawn(command, args, { stdio: 'pipe' }); - - let stdout = ''; - let stderr = ''; - - child.stdout?.on('data', (data) => { - stdout += data.toString(); - }); - - child.stderr?.on('data', (data) => { - stderr += data.toString(); - }); - - child.on('close', (code) => { - resolve({ - success: code === 0, - stdout: stdout.trim(), - stderr: stderr.trim() - }); - }); - - child.on('error', (error) => { - resolve({ - success: false, - stdout: '', - stderr: error.message - }); - }); - }); -} - -async function htmlToPdfChrome(): Promise { - console.log('🔍 Procurando pelo Chrome/Chromium...'); - - const chromePath = await findChrome(); - if (!chromePath) { - console.log('❌ Chrome/Chromium não encontrado no sistema'); - return false; - } - - console.log(`✅ Chrome encontrado: ${chromePath}`); - console.log('🔄 Convertendo HTML para PDF...'); - - const args = [ - '--headless', - '--disable-gpu', - '--disable-dev-shm-usage', - '--no-sandbox', - `--print-to-pdf=${PDF_FILE}`, - '--print-to-pdf-no-header', - '--run-all-compositor-stages-before-draw', - '--virtual-time-budget=2000', - '--disable-background-timer-throttling', - '--disable-backgrounding-occluded-windows', - '--disable-renderer-backgrounding', - '--disable-features=TranslateUI', - '--disable-extensions', - '--disable-web-security', - `file://${HTML_FILE}` - ]; - - const result = await runCommand(chromePath, args); - - if (result.success) { - console.log(`✅ PDF gerado com sucesso: ${PDF_FILE}`); - return true; - } else { - console.log(`❌ Erro ao gerar PDF: ${result.stderr || 'Erro desconhecido'}`); - return false; - } -} - -async function main(): Promise { - console.log('📄 Gerando PDF de teste com dados fictícios...'); - console.log(''); - - // Verifica se o arquivo HTML existe - if (!existsSync(HTML_FILE)) { - console.log(`❌ Arquivo HTML não encontrado: ${HTML_FILE}`); - console.log('💡 Certifique-se de que o arquivo test_resume.html existe na raiz do projeto.'); - return false; - } - - console.log(`📂 Arquivo HTML encontrado: ${HTML_FILE}`); - - // Tenta converter usando Chrome - if (await htmlToPdfChrome()) { - console.log(''); - console.log('🎉 PDF gerado com sucesso!'); - console.log(`📁 Arquivo criado: ${PDF_FILE}`); - return true; - } - - console.log(''); - console.log('❌ Não foi possível gerar o PDF automaticamente.'); - console.log(''); - console.log('💡 Alternativas:'); - console.log(' 1. Instale o Google Chrome ou Chromium'); - console.log(' 2. Abra test_resume.html em um navegador'); - console.log(' 3. Use "Imprimir > Salvar como PDF" para criar manualmente'); - console.log(''); - console.log('📦 Ou instale o Puppeteer para conversão automática:'); - console.log(' npm install puppeteer-core'); - - return false; -} - -// Executa apenas se chamado diretamente (ES module check) -import { fileURLToPath } from 'url'; -const __filename = fileURLToPath(import.meta.url); -const isMainModule = process.argv[1] === __filename; - -if (isMainModule) { - main().catch((error) => { - console.error('💥 Erro inesperado:', error.message); - process.exit(1); - }); -} - -export { main as generatePDF }; \ No newline at end of file