@pranavraut033/ats-checker 1.3.3 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { A as ATSProfile, S as SkillAliases, K as KeywordRegistry, L as LLMConfig, J as JSONSchema, a as LLMResult, b as LLMBudget, c as AnalyzeResumeInput, d as ATSAnalysisResult } from './scoring-BCShrnki.mjs';
2
- export { s as ATSBreakdown, k as ATSConfig, j as ATSRule, e as ATSWeights, w as AnalyzeResumeInputV2, f as KeywordCategory, h as KeywordDensityConfig, g as KeywordEntry, t as KeywordWeight, u as LLMClient, v as LLMFeatures, x as LLMUsageStats, N as NormalizedWeights, o as ParsedAchievement, P as ParsedDateRange, n as ParsedExperienceEntry, r as ParsedJobDescription, p as ParsedLanguage, q as ParsedResume, R as ResolvedATSConfig, m as ResumeSection, l as RuleContext, i as SectionPenaltyConfig } from './scoring-BCShrnki.mjs';
1
+ import { A as ATSProfile, S as SkillAliases, K as KeywordRegistry, L as LLMConfig, J as JSONSchema, a as LLMResult, b as LLMBudget, c as AnalyzeResumeInput, d as ATSAnalysisResult } from './scoring-pZZR3soa.mjs';
2
+ export { t as ATSBreakdown, k as ATSConfig, j as ATSRule, e as ATSWeights, x as AnalyzeResumeInputV2, f as KeywordCategory, h as KeywordDensityConfig, g as KeywordEntry, u as KeywordWeight, v as LLMClient, w as LLMFeatures, y as LLMUsageStats, N as NormalizedWeights, o as ParsedAchievement, P as ParsedDateRange, n as ParsedExperienceEntry, s as ParsedJobDescription, p as ParsedLanguage, q as ParsedResume, R as ResolvedATSConfig, m as ResumeSection, l as RuleContext, i as SectionPenaltyConfig, r as SkillExperienceRequirement } from './scoring-pZZR3soa.mjs';
3
3
 
4
4
  declare const defaultKeywordRegistry: KeywordRegistry;
5
5
  declare const defaultSkillAliases: SkillAliases;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { A as ATSProfile, S as SkillAliases, K as KeywordRegistry, L as LLMConfig, J as JSONSchema, a as LLMResult, b as LLMBudget, c as AnalyzeResumeInput, d as ATSAnalysisResult } from './scoring-BCShrnki.js';
2
- export { s as ATSBreakdown, k as ATSConfig, j as ATSRule, e as ATSWeights, w as AnalyzeResumeInputV2, f as KeywordCategory, h as KeywordDensityConfig, g as KeywordEntry, t as KeywordWeight, u as LLMClient, v as LLMFeatures, x as LLMUsageStats, N as NormalizedWeights, o as ParsedAchievement, P as ParsedDateRange, n as ParsedExperienceEntry, r as ParsedJobDescription, p as ParsedLanguage, q as ParsedResume, R as ResolvedATSConfig, m as ResumeSection, l as RuleContext, i as SectionPenaltyConfig } from './scoring-BCShrnki.js';
1
+ import { A as ATSProfile, S as SkillAliases, K as KeywordRegistry, L as LLMConfig, J as JSONSchema, a as LLMResult, b as LLMBudget, c as AnalyzeResumeInput, d as ATSAnalysisResult } from './scoring-pZZR3soa.js';
2
+ export { t as ATSBreakdown, k as ATSConfig, j as ATSRule, e as ATSWeights, x as AnalyzeResumeInputV2, f as KeywordCategory, h as KeywordDensityConfig, g as KeywordEntry, u as KeywordWeight, v as LLMClient, w as LLMFeatures, y as LLMUsageStats, N as NormalizedWeights, o as ParsedAchievement, P as ParsedDateRange, n as ParsedExperienceEntry, s as ParsedJobDescription, p as ParsedLanguage, q as ParsedResume, R as ResolvedATSConfig, m as ResumeSection, l as RuleContext, i as SectionPenaltyConfig, r as SkillExperienceRequirement } from './scoring-pZZR3soa.js';
3
3
 
4
4
  declare const defaultKeywordRegistry: KeywordRegistry;
5
5
  declare const defaultSkillAliases: SkillAliases;
package/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { clamp, mergeKeywordRegistries, defaultKeywordRegistry, softwareEngineerProfile, buildCategoryIndex, deriveSkillAliases, normalizeWhitespace, splitLines, normalizeSkills, tokenize, unique, containsTableLikeStructure, normalizeForComparison, STOP_WORDS, normalizeSkill, countFrequencies, escapeRegExp } from './chunk-ZJ5E4H7Z.mjs';
2
- export { defaultKeywordRegistry, defaultProfiles, defaultSkillAliases } from './chunk-ZJ5E4H7Z.mjs';
1
+ import { ROLE_NOUNS, unique, clamp, mergeKeywordRegistries, defaultKeywordRegistry, softwareEngineerProfile, buildCategoryIndex, deriveSkillAliases, normalizeWhitespace, splitLines, normalizeSkills, tokenize, containsTableLikeStructure, normalizeForComparison, STOP_WORDS, normalizeSkill, countFrequencies, escapeRegExp } from './chunk-IY7OVF6Z.mjs';
2
+ export { defaultKeywordRegistry, defaultProfiles, defaultSkillAliases } from './chunk-IY7OVF6Z.mjs';
3
3
 
4
4
  // src/utils/languages.ts
5
5
  var KNOWN_LANGUAGES = [
@@ -159,8 +159,9 @@ function extractPreferredSkills(lines) {
159
159
  }
160
160
  return preferred;
161
161
  }
162
+ var ROLE_NOUN_RE = new RegExp(`(${ROLE_NOUNS.join("|")})`, "gi");
162
163
  function extractRoleKeywords(text) {
163
- const roleMatches = text.match(/(engineer|developer|manager|scientist|analyst|designer|architect|director|consultant|lead|vp)/gi) ?? [];
164
+ const roleMatches = text.match(ROLE_NOUN_RE) ?? [];
164
165
  const fallback = roleMatches.length === 0 ? [text.split(/\n/)[0] ?? ""] : [];
165
166
  return unique(tokenize([...roleMatches, ...fallback].join(" ")));
166
167
  }
@@ -191,6 +192,32 @@ function isLanguageRequired(lang, jobDescription) {
191
192
  return LANG_SECTION_RE.test(line) || LANG_REQUIREMENT_HINT_RE.test(line);
192
193
  });
193
194
  }
195
+ var YEARS_BEFORE_SKILL_RE = /(\d{1,2})\+?\s*years?\s*(?:of|in|with)?\s*((?:[a-z0-9][a-z0-9.#+\-/]*\s*){1,4})/gi;
196
+ var SKILL_BEFORE_YEARS_RE = /((?:[a-z0-9][a-z0-9.#+\-/]*[\s,]*){1,4})\(?(\d{1,2})\+?\s*years?\)?/gi;
197
+ function extractSkillExperienceRequirements(lines, skillVocab, aliases) {
198
+ const requirements = /* @__PURE__ */ new Map();
199
+ const record = (rawWord, years) => {
200
+ if (!Number.isFinite(years) || years <= 0 || years > 60) return;
201
+ const canonical = normalizeSkill(rawWord, aliases);
202
+ if (!skillVocab.has(rawWord.toLowerCase()) && !skillVocab.has(canonical)) return;
203
+ const existing = requirements.get(canonical);
204
+ if (existing === void 0 || years > existing) {
205
+ requirements.set(canonical, years);
206
+ }
207
+ };
208
+ for (const line of lines) {
209
+ for (const re of [YEARS_BEFORE_SKILL_RE, SKILL_BEFORE_YEARS_RE]) {
210
+ re.lastIndex = 0;
211
+ let match;
212
+ while (match = re.exec(line)) {
213
+ const years = Number.parseInt(re === YEARS_BEFORE_SKILL_RE ? match[1] : match[2], 10);
214
+ const candidateWords = tokenize(re === YEARS_BEFORE_SKILL_RE ? match[2] : match[1]);
215
+ for (const word of candidateWords) record(word, years);
216
+ }
217
+ }
218
+ }
219
+ return [...requirements.entries()].map(([skill, years]) => ({ skill, years })).sort((a, b) => a.skill.localeCompare(b.skill));
220
+ }
194
221
  function extractDegreeLevels(text) {
195
222
  const found = /* @__PURE__ */ new Set();
196
223
  for (const [pattern, canonical] of DEGREE_VARIANTS) {
@@ -221,6 +248,11 @@ function parseJobDescription(jobDescription, config) {
221
248
  const bodyTokens = tokenize(normalizedText).filter(isSkillLike);
222
249
  const roleKeywords = extractRoleKeywords(jobDescription);
223
250
  const keywords = unique([...requiredSkills, ...preferredSkills, ...roleKeywords, ...bodyTokens]);
251
+ const skillExperienceRequirements = extractSkillExperienceRequirements(
252
+ lines,
253
+ skillVocab,
254
+ config.skillAliases
255
+ );
224
256
  return {
225
257
  raw: jobDescription,
226
258
  normalizedText,
@@ -229,6 +261,7 @@ function parseJobDescription(jobDescription, config) {
229
261
  roleKeywords,
230
262
  keywords,
231
263
  minExperienceYears: extractMinExperience(jobDescription),
264
+ skillExperienceRequirements,
232
265
  educationRequirements: extractDegreeLevels(jobDescription),
233
266
  keywordSurfaceForms: collectKeywordSurfaceForms(jobDescription, config.skillAliases),
234
267
  // A language only counts as required if its mention carries a requirement/level cue
@@ -425,6 +458,10 @@ var STRONG_VERBS = [
425
458
  "increased"
426
459
  ];
427
460
  var WEAK_VERBS = ["worked", "helped", "performed", "responsible", "assisted", "participated", "involved"];
461
+ var TITLE_SENIORITY_PREFIXES = ["senior", "lead", "principal", "staff", "vp", "director"];
462
+ var TITLE_MODIFIER_WORDS = ["software", "full\\s*stack", "frontend", "backend"];
463
+ var TITLE_PREFIX_WORDS = unique([...TITLE_SENIORITY_PREFIXES, ...TITLE_MODIFIER_WORDS, ...ROLE_NOUNS]);
464
+ var TITLE_RE = new RegExp(`^(${TITLE_PREFIX_WORDS.join("|")})[^,(-]*`, "i");
428
465
  var METRIC_RE = /\d|%|\$|\bk\+|\bm\+/i;
429
466
  function classifyAchievement(line) {
430
467
  const lower = line.toLowerCase();
@@ -502,21 +539,26 @@ function parseExperience(sectionContent, referenceDate) {
502
539
  const achievements = [];
503
540
  for (const line of lines) {
504
541
  const range = parseDateRange(line, referenceDate);
542
+ const titleMatch = line.match(TITLE_RE);
543
+ const title = titleMatch ? titleMatch[0].trim() : void 0;
505
544
  if (range) {
506
- const previous = entries[entries.length - 1];
507
- if (previous && !previous.dates) {
508
- previous.dates = range;
545
+ if (title) {
546
+ jobTitles.push(title.toLowerCase());
547
+ entries.push({ title, dates: range, description: line });
509
548
  } else {
510
- entries.push({ dates: range });
549
+ const previous = entries[entries.length - 1];
550
+ if (previous && !previous.dates) {
551
+ previous.dates = range;
552
+ } else {
553
+ entries.push({ dates: range });
554
+ }
511
555
  }
512
556
  if (range.durationInMonths) {
513
557
  rangesInMonths.push(range.durationInMonths);
514
558
  }
515
559
  continue;
516
560
  }
517
- const titleMatch = line.match(/^(Senior|Lead|Principal|Staff|VP|Director|Consultant|Architect|Software|Full\s*Stack|Frontend|Backend|Engineer|Developer|Manager|Analyst)[^,-]*/i);
518
- if (titleMatch) {
519
- const title = titleMatch[0].trim();
561
+ if (title) {
520
562
  jobTitles.push(title.toLowerCase());
521
563
  const entry = { title, description: line };
522
564
  entries.push(entry);
@@ -538,6 +580,14 @@ function parseEducation(sectionContent) {
538
580
  function collectKeywords(text) {
539
581
  return unique(tokenize(text));
540
582
  }
583
+ var EMAIL_RE = /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/i;
584
+ var PHONE_RE = /\+?\(?\d{1,4}\)?(?:[\s.-]?\d{2,4}){2,4}/;
585
+ function extractContact(text) {
586
+ const email = text.match(EMAIL_RE)?.[0];
587
+ const phone = text.match(PHONE_RE)?.[0]?.trim();
588
+ if (!email && !phone) return void 0;
589
+ return { email, phone };
590
+ }
541
591
  function parseResume(resumeText, config) {
542
592
  const normalizedText = normalizeWhitespace(resumeText);
543
593
  const { sections, detected } = extractSections(resumeText);
@@ -573,6 +623,12 @@ function parseResume(resumeText, config) {
573
623
  warnings.push(`${section} section not detected`);
574
624
  }
575
625
  }
626
+ const contact = extractContact(resumeText);
627
+ if (!contact?.email) {
628
+ warnings.push(
629
+ "No email address detected \u2014 most ATS require a parseable contact email"
630
+ );
631
+ }
576
632
  return {
577
633
  raw: resumeText,
578
634
  normalizedText,
@@ -588,6 +644,7 @@ function parseResume(resumeText, config) {
588
644
  totalExperienceYears,
589
645
  keywords: collectKeywords(normalizedText),
590
646
  languages: parseLanguageMentions(resumeText),
647
+ contact,
591
648
  warnings
592
649
  };
593
650
  }
@@ -637,6 +694,11 @@ var RuleEngine = class {
637
694
  totalPenalty += 5;
638
695
  warnings.push("Few recognizable sections found (penalty 5)");
639
696
  }
697
+ if (!input.resume.contact?.email) {
698
+ const penalty = this.config.sectionPenalties.missingContact;
699
+ totalPenalty += penalty;
700
+ warnings.push(`Missing contact email (penalty ${penalty})`);
701
+ }
640
702
  return { totalPenalty, warnings };
641
703
  }
642
704
  evaluate(input) {
@@ -708,8 +770,9 @@ function scoreExperience(resume, job, config) {
708
770
  const yearCoverage = clamp(resume.totalExperienceYears / requiredYears, 0, 2);
709
771
  const yearsComponent = clamp(yearCoverage, 0, 1) * EXPERIENCE_YEARS_WEIGHT;
710
772
  const jobRoleSet = new Set(job.roleKeywords.map((value) => value.toLowerCase()));
711
- const titleMatches = resume.jobTitles.filter((title) => jobRoleSet.has(title.toLowerCase()));
712
- const titleCoverage = jobRoleSet.size === 0 ? 1 : titleMatches.length / jobRoleSet.size;
773
+ const resumeTitleTokens = new Set(resume.jobTitles.flatMap((t) => tokenize(t)));
774
+ const matchedRoles = job.roleKeywords.filter((rk) => resumeTitleTokens.has(rk.toLowerCase()));
775
+ const titleCoverage = jobRoleSet.size === 0 ? 1 : matchedRoles.length / jobRoleSet.size;
713
776
  const roleComponent = clamp(titleCoverage, 0, 1) * EXPERIENCE_ROLE_WEIGHT;
714
777
  const score = clamp((yearsComponent + roleComponent) * 100, 0, 100);
715
778
  const missingYears = Math.max(requiredYears - resume.totalExperienceYears, 0);
@@ -785,6 +848,17 @@ function scoreKeywords(resume, job, config) {
785
848
  keywordWeights
786
849
  };
787
850
  }
851
+ function computeSkillExperienceGaps(resume, job, config) {
852
+ if (job.skillExperienceRequirements.length === 0) return [];
853
+ const resumeSkills = new Set(normalizeSkills(resume.skills, config.skillAliases));
854
+ const gaps = [];
855
+ for (const { skill, years } of job.skillExperienceRequirements) {
856
+ if (resumeSkills.has(skill) && resume.totalExperienceYears < years) {
857
+ gaps.push({ skill, requiredYears: years, resumeYears: resume.totalExperienceYears });
858
+ }
859
+ }
860
+ return gaps.sort((a, b) => a.skill.localeCompare(b.skill));
861
+ }
788
862
  function scoreEducation(resume, job) {
789
863
  if (job.educationRequirements.length === 0) {
790
864
  return 100;
@@ -818,6 +892,7 @@ function calculateScore(resume, job, config) {
818
892
  resume.languages,
819
893
  job.requiredLanguages
820
894
  );
895
+ const skillExperienceGaps = computeSkillExperienceGaps(resume, job, config);
821
896
  return {
822
897
  score: clamp(Number(weightedScore.toFixed(2)), 0, 100),
823
898
  breakdown,
@@ -831,6 +906,7 @@ function calculateScore(resume, job, config) {
831
906
  achievementStrength,
832
907
  matchedLanguages,
833
908
  missingLanguages,
909
+ skillExperienceGaps,
834
910
  suggestions: [],
835
911
  warnings: [],
836
912
  // detectedSections / parsedExperienceYears / experienceGap / experienceEntries: filled by index.ts
@@ -859,7 +935,10 @@ var DEFAULT_SECTION_PENALTIES = {
859
935
  missingSummary: 4,
860
936
  missingExperience: 10,
861
937
  missingSkills: 8,
862
- missingEducation: 6
938
+ missingEducation: 6,
939
+ // Warning-only by default — see determinism note in the contact/parseability plan step;
940
+ // callers who want it to actually move the score can override via config.sectionPenalties.
941
+ missingContact: 0
863
942
  };
864
943
  function normalizeWeights(weights) {
865
944
  const total = weights.skills + weights.experience + weights.keywords + weights.education;
@@ -980,6 +1059,16 @@ var SuggestionEngine = class {
980
1059
  "Your resume may use a multi-column layout. Export as a single-column PDF or paste plain text \u2014 most ATS systems and this parser work best with a linear layout."
981
1060
  );
982
1061
  }
1062
+ if (!input.resume.contact?.email) {
1063
+ suggestions.push(
1064
+ "Add a clearly formatted email address near the top of your resume so ATS and recruiters can contact you."
1065
+ );
1066
+ }
1067
+ for (const gap of input.score.skillExperienceGaps) {
1068
+ suggestions.push(
1069
+ `The role asks for ${gap.requiredYears}+ years of ${gap.skill}; make that duration explicit in your experience section.`
1070
+ );
1071
+ }
983
1072
  return { suggestions, warnings };
984
1073
  }
985
1074
  };
@@ -1145,7 +1234,8 @@ No explanations. No markdown. No additional text.`;
1145
1234
  });
1146
1235
  const timeoutPromise = new Promise((_, reject) => {
1147
1236
  const id = globalThis.setTimeout(() => reject(new Error(`LLM call timeout after ${this.timeoutMs}ms`)), this.timeoutMs);
1148
- void clientPromise.finally(() => globalThis.clearTimeout(id));
1237
+ void clientPromise.finally(() => globalThis.clearTimeout(id)).catch(() => {
1238
+ });
1149
1239
  });
1150
1240
  void timeoutPromise.catch(() => {
1151
1241
  });
@@ -1643,6 +1733,7 @@ function analyzeResume(input) {
1643
1733
  achievementStrength: scoring.achievementStrength,
1644
1734
  matchedLanguages: scoring.matchedLanguages,
1645
1735
  missingLanguages: scoring.missingLanguages,
1736
+ skillExperienceGaps: scoring.skillExperienceGaps,
1646
1737
  experienceGap: scoring.experienceGap,
1647
1738
  detectedSections: parsedResume.detectedSections,
1648
1739
  parsedExperienceYears: parsedResume.totalExperienceYears,
@@ -1714,6 +1805,7 @@ async function analyzeResumeAsync(input) {
1714
1805
  achievementStrength: scoring.achievementStrength,
1715
1806
  matchedLanguages: scoring.matchedLanguages,
1716
1807
  missingLanguages: scoring.missingLanguages,
1808
+ skillExperienceGaps: scoring.skillExperienceGaps,
1717
1809
  experienceGap: scoring.experienceGap,
1718
1810
  detectedSections: parsedResume.detectedSections,
1719
1811
  parsedExperienceYears: parsedResume.totalExperienceYears,