@pranavraut033/ats-checker 1.4.0 → 2.0.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.mjs CHANGED
@@ -1,5 +1,5 @@
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';
1
+ import { ROLE_NOUNS, unique, clamp, mergeKeywordRegistries, defaultKeywordRegistry, softwareEngineerProfile, buildCategoryIndex, deriveSkillAliases, normalizeWhitespace, splitLines, detectFormatting, normalizeSkills, tokenize, normalizeForComparison, STOP_WORDS, normalizeSkill, countFrequencies, escapeRegExp, fuzzyEqual, stem } from './chunk-JAFFS7YZ.mjs';
2
+ export { defaultKeywordRegistry, defaultProfiles, defaultSkillAliases } from './chunk-JAFFS7YZ.mjs';
3
3
 
4
4
  // src/utils/languages.ts
5
5
  var KNOWN_LANGUAGES = [
@@ -64,11 +64,11 @@ var LEVEL_RANK = {
64
64
  muttersprache: 6,
65
65
  muttersprachler: 6,
66
66
  // French
67
- "d\xE9butant": 1,
68
- "\xE9l\xE9mentaire": 1,
69
- "limit\xE9": 2,
70
- "interm\xE9diaire": 3,
71
- "avanc\xE9": 4,
67
+ d\u00E9butant: 1,
68
+ \u00E9l\u00E9mentaire: 1,
69
+ limit\u00E9: 2,
70
+ interm\u00E9diaire: 3,
71
+ avanc\u00E9: 4,
72
72
  courant: 5,
73
73
  natif: 6,
74
74
  "langue maternelle": 6,
@@ -133,6 +133,67 @@ function diffLanguages(resumeLanguages, requiredLanguages) {
133
133
  return { matched, missing };
134
134
  }
135
135
 
136
+ // src/utils/titles.ts
137
+ var SENIORITY_SIGNALS = [
138
+ { seniority: "principal", pattern: /\bprincipal\b/i },
139
+ { seniority: "lead", pattern: /\b(lead|staff|head of|director)\b/i },
140
+ { seniority: "senior", pattern: /\b(senior|sr\.?)\b/i },
141
+ {
142
+ seniority: "junior",
143
+ pattern: /\b(junior|jr\.?|entry[\s-]?level|intern(?:ship)?|associate)\b/i
144
+ }
145
+ ];
146
+ var SENIORITY_RANK = {
147
+ junior: 0,
148
+ mid: 1,
149
+ senior: 2,
150
+ lead: 3,
151
+ principal: 4
152
+ };
153
+ function inferSeniority(titles) {
154
+ let best;
155
+ for (const title of titles) {
156
+ for (const { seniority, pattern } of SENIORITY_SIGNALS) {
157
+ if (pattern.test(title)) {
158
+ if (!best || SENIORITY_RANK[seniority] > SENIORITY_RANK[best]) {
159
+ best = seniority;
160
+ }
161
+ break;
162
+ }
163
+ }
164
+ }
165
+ return best;
166
+ }
167
+ function normalizeTitle(title) {
168
+ return title.toLowerCase().replace(/[.,/#!$%^&*;:{}=_`~()]/g, " ").replace(/\s+/g, " ").trim();
169
+ }
170
+ function titleTokens(title) {
171
+ const tokens = normalizeTitle(title).split(" ").filter((t) => t && !STOP_WORDS.has(t)).map((t) => stem(t));
172
+ return new Set(tokens);
173
+ }
174
+ function titleMatch(resumeTitles, jdRoleKeywords) {
175
+ const keywords = jdRoleKeywords.map((k) => stem(normalizeTitle(k))).filter(Boolean);
176
+ if (keywords.length === 0) {
177
+ return 0;
178
+ }
179
+ const resumeTokenSets = resumeTitles.map(titleTokens);
180
+ const roleNounStems = new Set(ROLE_NOUNS.map((n) => stem(n)));
181
+ let matched = 0;
182
+ for (const keyword of keywords) {
183
+ const isRoleNoun = roleNounStems.has(keyword);
184
+ const hit = resumeTokenSets.some((tokens) => {
185
+ if (tokens.has(keyword)) {
186
+ return true;
187
+ }
188
+ return isRoleNoun && [...tokens].some((t) => roleNounStems.has(t) && t === keyword);
189
+ });
190
+ if (hit) {
191
+ matched += 1;
192
+ }
193
+ }
194
+ return matched / keywords.length;
195
+ }
196
+
136
197
  // src/core/parser/jd.parser.ts
137
198
  var DEGREE_VARIANTS = [
138
199
  [/\b(?:bachelor(?:'s)?|b\.s\.?|bs\.?|bsc\.?|licence)\b/i, "bachelor"],
@@ -159,14 +220,49 @@ function extractPreferredSkills(lines) {
159
220
  }
160
221
  return preferred;
161
222
  }
223
+ var REQUIRED_HEADER_RE = /^(?:requirements?|must[\s-]?haves?|required\s+skills?|required\s+qualifications?|qualifications?|minimum\s+qualifications?)\s*:?\s*$/i;
224
+ var PREFERRED_HEADER_RE = /^(?:preferred(?:\s+skills?|\s+qualifications?)?|nice[\s-]?to[\s-]?haves?|bonus(?:\s+points?)?|pluses?)\s*:?\s*$/i;
225
+ var OTHER_HEADER_RE = /^(?:responsibilities|duties|about(?:\s+the)?(?:\s+role|\s+us|\s+company|\s+team)?|what\s+you.?ll\s+do|who\s+you\s+are|benefits|perks|compensation|how\s+to\s+apply|overview|summary|about)\s*:?\s*$/i;
226
+ function extractHeaderScopedSkills(lines) {
227
+ const required = [];
228
+ const preferred = [];
229
+ let scope = null;
230
+ for (const line of lines) {
231
+ if (REQUIRED_HEADER_RE.test(line)) {
232
+ scope = "required";
233
+ continue;
234
+ }
235
+ if (PREFERRED_HEADER_RE.test(line)) {
236
+ scope = "preferred";
237
+ continue;
238
+ }
239
+ if (OTHER_HEADER_RE.test(line)) {
240
+ scope = null;
241
+ continue;
242
+ }
243
+ if (scope === "required") {
244
+ required.push(...tokenize(line));
245
+ } else if (scope === "preferred") {
246
+ preferred.push(...tokenize(line));
247
+ }
248
+ }
249
+ return { required, preferred };
250
+ }
162
251
  var ROLE_NOUN_RE = new RegExp(`(${ROLE_NOUNS.join("|")})`, "gi");
163
252
  function extractRoleKeywords(text) {
164
253
  const roleMatches = text.match(ROLE_NOUN_RE) ?? [];
165
254
  const fallback = roleMatches.length === 0 ? [text.split(/\n/)[0] ?? ""] : [];
166
255
  return unique(tokenize([...roleMatches, ...fallback].join(" ")));
167
256
  }
257
+ function extractTitleContextLines(text) {
258
+ const lines = splitLines(text);
259
+ const roleLines = lines.filter((line) => line.match(ROLE_NOUN_RE) !== null);
260
+ return roleLines.length > 0 ? roleLines : lines.slice(0, 1);
261
+ }
168
262
  function extractMinExperience(text) {
169
- const match = text.match(/(\d{1,2})\+?\s*(?:years?|yrs\.?|jahre?|ans?|années?)/i);
263
+ const match = text.match(
264
+ /(\d{1,2})\+?\s*(?:years?|yrs\.?|jahre?|ans?|années?)/i
265
+ );
170
266
  if (!match) return void 0;
171
267
  const parsed = Number.parseInt(match[1], 10);
172
268
  return parsed <= 60 ? parsed : void 0;
@@ -199,7 +295,8 @@ function extractSkillExperienceRequirements(lines, skillVocab, aliases) {
199
295
  const record = (rawWord, years) => {
200
296
  if (!Number.isFinite(years) || years <= 0 || years > 60) return;
201
297
  const canonical = normalizeSkill(rawWord, aliases);
202
- if (!skillVocab.has(rawWord.toLowerCase()) && !skillVocab.has(canonical)) return;
298
+ if (!skillVocab.has(rawWord.toLowerCase()) && !skillVocab.has(canonical))
299
+ return;
203
300
  const existing = requirements.get(canonical);
204
301
  if (existing === void 0 || years > existing) {
205
302
  requirements.set(canonical, years);
@@ -210,8 +307,13 @@ function extractSkillExperienceRequirements(lines, skillVocab, aliases) {
210
307
  re.lastIndex = 0;
211
308
  let match;
212
309
  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]);
310
+ const years = Number.parseInt(
311
+ re === YEARS_BEFORE_SKILL_RE ? match[1] : match[2],
312
+ 10
313
+ );
314
+ const candidateWords = tokenize(
315
+ re === YEARS_BEFORE_SKILL_RE ? match[2] : match[1]
316
+ );
215
317
  for (const word of candidateWords) record(word, years);
216
318
  }
217
319
  }
@@ -233,21 +335,42 @@ function parseJobDescription(jobDescription, config) {
233
335
  skillVocab.add(canonical.toLowerCase());
234
336
  for (const alias of aliases) skillVocab.add(alias.toLowerCase());
235
337
  }
236
- for (const s of config.profile?.mandatorySkills ?? []) skillVocab.add(s.toLowerCase());
237
- for (const s of config.profile?.optionalSkills ?? []) skillVocab.add(s.toLowerCase());
338
+ for (const s of config.profile?.mandatorySkills ?? [])
339
+ skillVocab.add(s.toLowerCase());
340
+ for (const s of config.profile?.optionalSkills ?? [])
341
+ skillVocab.add(s.toLowerCase());
238
342
  const isSkillLike = (t) => {
239
343
  if (skillVocab.has(t)) return true;
240
344
  if (/[.#+]/.test(t) && /[a-z]/.test(t)) return true;
241
- if (t.includes("/")) return t.split("/").some((p) => p.length >= 2 && !STOP_WORDS.has(p));
345
+ if (t.includes("/"))
346
+ return t.split("/").some((p) => p.length >= 2 && !STOP_WORDS.has(p));
242
347
  return false;
243
348
  };
244
- const requiredSkillsRaw = extractRequiredSkills(lines).filter(isSkillLike);
245
- const preferredSkillsRaw = extractPreferredSkills(lines).filter(isSkillLike);
246
- const requiredSkills = normalizeSkills(requiredSkillsRaw, config.skillAliases);
247
- const preferredSkills = normalizeSkills(preferredSkillsRaw, config.skillAliases);
349
+ const headerScoped = extractHeaderScopedSkills(lines);
350
+ const requiredSkillsRaw = unique([
351
+ ...extractRequiredSkills(lines),
352
+ ...headerScoped.required
353
+ ]).filter(isSkillLike);
354
+ const preferredSkillsRaw = unique([
355
+ ...extractPreferredSkills(lines),
356
+ ...headerScoped.preferred
357
+ ]).filter(isSkillLike);
358
+ const requiredSkills = normalizeSkills(
359
+ requiredSkillsRaw,
360
+ config.skillAliases
361
+ );
362
+ const preferredSkills = normalizeSkills(
363
+ preferredSkillsRaw,
364
+ config.skillAliases
365
+ );
248
366
  const bodyTokens = tokenize(normalizedText).filter(isSkillLike);
249
367
  const roleKeywords = extractRoleKeywords(jobDescription);
250
- const keywords = unique([...requiredSkills, ...preferredSkills, ...roleKeywords, ...bodyTokens]);
368
+ const keywords = unique([
369
+ ...requiredSkills,
370
+ ...preferredSkills,
371
+ ...roleKeywords,
372
+ ...bodyTokens
373
+ ]);
251
374
  const skillExperienceRequirements = extractSkillExperienceRequirements(
252
375
  lines,
253
376
  skillVocab,
@@ -263,12 +386,16 @@ function parseJobDescription(jobDescription, config) {
263
386
  minExperienceYears: extractMinExperience(jobDescription),
264
387
  skillExperienceRequirements,
265
388
  educationRequirements: extractDegreeLevels(jobDescription),
266
- keywordSurfaceForms: collectKeywordSurfaceForms(jobDescription, config.skillAliases),
389
+ keywordSurfaceForms: collectKeywordSurfaceForms(
390
+ jobDescription,
391
+ config.skillAliases
392
+ ),
267
393
  // A language only counts as required if its mention carries a requirement/level cue
268
394
  // or sits in a "Languages:" line — plain references ("our Berlin office") don't count.
269
395
  requiredLanguages: parseLanguageMentions(jobDescription).filter(
270
396
  (lang) => isLanguageRequired(lang, jobDescription)
271
- )
397
+ ),
398
+ seniority: inferSeniority(extractTitleContextLines(jobDescription))
272
399
  };
273
400
  }
274
401
 
@@ -366,7 +493,9 @@ function parseDateRange(text, referenceDate) {
366
493
  }
367
494
  const startToken = parseDateToken(rangeMatch[1]);
368
495
  const endRaw = rangeMatch[2];
369
- const isPresent = /present|current|now|aktuell|heute|actuellement|présent|actuel/i.test(endRaw);
496
+ const isPresent = /present|current|now|aktuell|heute|actuellement|présent|actuel/i.test(
497
+ endRaw
498
+ );
370
499
  const endToken = isPresent ? void 0 : parseDateToken(endRaw);
371
500
  if (!startToken) {
372
501
  return null;
@@ -414,27 +543,92 @@ function sumExperienceYears(ranges) {
414
543
  totalMonths += curEnd - curStart + 1;
415
544
  return Number((totalMonths / 12).toFixed(2));
416
545
  }
417
- const months = ranges.reduce((total, r) => total + (r.durationInMonths ?? 0), 0);
546
+ const months = ranges.reduce(
547
+ (total, r) => total + (r.durationInMonths ?? 0),
548
+ 0
549
+ );
418
550
  return Number((months / 12).toFixed(2));
419
551
  }
552
+ function detectEmploymentGaps(entries, minGapMonths = 3) {
553
+ const toIndex = (year, month) => year * 12 + month;
554
+ const intervals = entries.filter(
555
+ (e) => e.dates?.startYear !== void 0 && e.dates?.endYear !== void 0
556
+ ).map((e) => ({
557
+ s: toIndex(e.dates.startYear, e.dates.startMonth ?? 1),
558
+ e: toIndex(e.dates.endYear, e.dates.endMonth ?? 12),
559
+ label: e.title || e.company || "previous role"
560
+ })).sort((a, b) => a.s - b.s || b.e - a.e);
561
+ if (intervals.length < 2) {
562
+ return [];
563
+ }
564
+ const gaps = [];
565
+ let curEnd = intervals[0].e;
566
+ let curEndLabel = intervals[0].label;
567
+ for (let i = 1; i < intervals.length; i++) {
568
+ const interval = intervals[i];
569
+ if (interval.s <= curEnd + 1) {
570
+ if (interval.e > curEnd) {
571
+ curEnd = interval.e;
572
+ curEndLabel = interval.label;
573
+ }
574
+ continue;
575
+ }
576
+ const gapMonths = interval.s - curEnd - 1;
577
+ if (gapMonths >= minGapMonths) {
578
+ gaps.push({ afterRole: curEndLabel, months: gapMonths });
579
+ }
580
+ curEnd = interval.e;
581
+ curEndLabel = interval.label;
582
+ }
583
+ return gaps;
584
+ }
420
585
 
421
586
  // src/core/parser/resume.parser.ts
422
587
  var SECTION_ALIASES = {
423
- summary: ["summary", "profile", "about", "zusammenfassung", "profil", "r\xE9sum\xE9", "\xE0 propos"],
588
+ summary: [
589
+ "summary",
590
+ "profile",
591
+ "about",
592
+ "zusammenfassung",
593
+ "profil",
594
+ "r\xE9sum\xE9",
595
+ "\xE0 propos"
596
+ ],
424
597
  experience: [
425
598
  "experience",
426
599
  "work experience",
427
600
  "professional experience",
428
601
  "employment",
602
+ "work history",
603
+ "employment history",
429
604
  "erfahrung",
430
605
  "berufserfahrung",
431
606
  "exp\xE9rience",
432
607
  "exp\xE9rience professionnelle"
433
608
  ],
434
- skills: ["skills", "technical skills", "technologies", "f\xE4higkeiten", "kenntnisse", "comp\xE9tences"],
435
- education: ["education", "academics", "academic background", "ausbildung", "formation", "\xE9tudes"],
609
+ skills: [
610
+ "skills",
611
+ "technical skills",
612
+ "technologies",
613
+ "f\xE4higkeiten",
614
+ "kenntnisse",
615
+ "comp\xE9tences"
616
+ ],
617
+ education: [
618
+ "education",
619
+ "academics",
620
+ "academic background",
621
+ "ausbildung",
622
+ "formation",
623
+ "\xE9tudes"
624
+ ],
436
625
  projects: ["projects", "portfolio", "projekte", "projets"],
437
- certifications: ["certifications", "licenses", "zertifizierungen", "certifications professionnelles"]
626
+ certifications: [
627
+ "certifications",
628
+ "licenses",
629
+ "zertifizierungen",
630
+ "certifications professionnelles"
631
+ ]
438
632
  };
439
633
  var STRONG_VERBS = [
440
634
  "led",
@@ -457,32 +651,90 @@ var STRONG_VERBS = [
457
651
  "reduced",
458
652
  "increased"
459
653
  ];
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]);
654
+ var WEAK_VERBS = [
655
+ "worked",
656
+ "helped",
657
+ "performed",
658
+ "responsible",
659
+ "assisted",
660
+ "participated",
661
+ "involved"
662
+ ];
663
+ var TITLE_SENIORITY_PREFIXES = [
664
+ "senior",
665
+ "lead",
666
+ "principal",
667
+ "staff",
668
+ "vp",
669
+ "director"
670
+ ];
671
+ var TITLE_MODIFIER_WORDS = [
672
+ "software",
673
+ "full\\s*stack",
674
+ "frontend",
675
+ "backend"
676
+ ];
677
+ var TITLE_PREFIX_WORDS = unique([
678
+ ...TITLE_SENIORITY_PREFIXES,
679
+ ...TITLE_MODIFIER_WORDS,
680
+ ...ROLE_NOUNS
681
+ ]);
464
682
  var TITLE_RE = new RegExp(`^(${TITLE_PREFIX_WORDS.join("|")})[^,(-]*`, "i");
465
683
  var METRIC_RE = /\d|%|\$|\bk\+|\bm\+/i;
466
684
  function classifyAchievement(line) {
467
685
  const lower = line.toLowerCase();
468
686
  const hasMetric = METRIC_RE.test(line);
469
- const hasStrongVerb = STRONG_VERBS.some((verb) => new RegExp(`\\b${verb}\\b`).test(lower));
470
- const hasWeakVerb = WEAK_VERBS.some((verb) => new RegExp(`\\b${verb}\\b`).test(lower));
687
+ const hasStrongVerb = STRONG_VERBS.some(
688
+ (verb) => new RegExp(`\\b${verb}\\b`).test(lower)
689
+ );
690
+ const hasWeakVerb = WEAK_VERBS.some(
691
+ (verb) => new RegExp(`\\b${verb}\\b`).test(lower)
692
+ );
471
693
  if (hasStrongVerb && hasMetric) {
472
- return { text: line, strength: "strong", reason: "strong verb + quantified impact" };
694
+ return {
695
+ text: line,
696
+ strength: "strong",
697
+ reason: "strong verb + quantified impact"
698
+ };
473
699
  }
474
700
  if (hasWeakVerb) {
475
701
  return { text: line, strength: "weak", reason: "weak verb" };
476
702
  }
477
703
  return { text: line, strength: "weak", reason: "no quantified impact" };
478
704
  }
705
+ var HEADER_TAIL_RE = /^[\s:.\-–—,]*(?:\d{4}\s*(?:[-–—]|to)\s*(?:\d{4}|present|current|now)|\d{4})?[\s:.\-–—,]*$/i;
706
+ var MAX_HEADER_LINE_LENGTH = 60;
479
707
  function detectSection(line) {
480
- const normalized = line.trim().toLowerCase();
708
+ const trimmed = line.trim();
709
+ if (!trimmed || trimmed.length > MAX_HEADER_LINE_LENGTH) return null;
710
+ const normalized = trimmed.toLowerCase();
481
711
  for (const [section, aliases] of Object.entries(SECTION_ALIASES)) {
482
712
  for (const alias of aliases) {
483
713
  const safeAlias = escapeRegExp(alias);
484
- const headerPattern = new RegExp(`^${safeAlias}(\\s*:)?$`, "i");
485
- if (headerPattern.test(normalized)) {
714
+ const headerPattern = new RegExp(`^${safeAlias}(.*)$`, "i");
715
+ const match = normalized.match(headerPattern);
716
+ if (match && HEADER_TAIL_RE.test(match[1])) {
717
+ return section;
718
+ }
719
+ }
720
+ }
721
+ const coreLine = normalized.replace(/[\s:.\-–—,]+$/, "");
722
+ const lineWords = coreLine.split(/\s+/).filter(Boolean);
723
+ for (const [section, aliases] of Object.entries(SECTION_ALIASES)) {
724
+ for (const alias of aliases) {
725
+ const aliasWords = alias.split(/\s+/);
726
+ if (lineWords.length < aliasWords.length) continue;
727
+ const candidateWords = lineWords.slice(0, aliasWords.length);
728
+ const allFuzzy = aliasWords.every(
729
+ (aliasWord, i) => aliasWord !== candidateWords[i] && fuzzyEqual(stem(aliasWord), stem(candidateWords[i]))
730
+ );
731
+ if (!allFuzzy) continue;
732
+ const prefixPattern = new RegExp(
733
+ `^\\s*${candidateWords.map((w) => escapeRegExp(w)).join("\\s+")}(.*)$`,
734
+ "i"
735
+ );
736
+ const prefixMatch = normalized.match(prefixPattern);
737
+ if (prefixMatch && HEADER_TAIL_RE.test(prefixMatch[1])) {
486
738
  return section;
487
739
  }
488
740
  }
@@ -514,12 +766,42 @@ function extractSections(text) {
514
766
  flush();
515
767
  return { sections, detected: unique(detected) };
516
768
  }
517
- function parseSkills(sectionContent, aliases) {
769
+ function parseExplicitSkillList(sectionContent) {
518
770
  if (!sectionContent) return [];
519
771
  const hasBullets = /[•·‣▪○●◦]/.test(sectionContent);
520
772
  const normalized = hasBullets ? sectionContent.replace(/\n/g, " ") : sectionContent;
521
- const raw = normalized.split(/[,;\n]|[•·‣▪○●◦]/).map((skill) => skill.trim().replace(/^[-•·‣▪○●◦\s]+|[-•·‣▪○●◦\s]+$/g, "").trim()).filter(Boolean);
522
- return normalizeSkills(raw, aliases);
773
+ return normalized.split(/[,;\n]|[•·‣▪○●◦]/).map(
774
+ (skill) => skill.trim().replace(/^[-•·‣▪○●◦\s]+|[-•·‣▪○●◦\s]+$/g, "").trim()
775
+ ).filter(Boolean);
776
+ }
777
+ function extractKnownSkillsFromText(text, aliases) {
778
+ if (!text) return [];
779
+ const canonicalSet = new Set(
780
+ Object.keys(aliases).map((c) => c.toLowerCase())
781
+ );
782
+ const found = [];
783
+ for (const token of tokenize(text)) {
784
+ const canonical = normalizeSkill(token, aliases);
785
+ if (canonicalSet.has(canonical)) {
786
+ found.push(canonical);
787
+ }
788
+ }
789
+ return unique(found);
790
+ }
791
+ function parseSkills(sections, aliases) {
792
+ const explicit = normalizeSkills(
793
+ parseExplicitSkillList(sections.skills),
794
+ aliases
795
+ );
796
+ const fromExperience = extractKnownSkillsFromText(
797
+ sections.experience,
798
+ aliases
799
+ );
800
+ const fromSummary = extractKnownSkillsFromText(sections.summary, aliases);
801
+ return normalizeSkills(
802
+ unique([...explicit, ...fromExperience, ...fromSummary]),
803
+ aliases
804
+ );
523
805
  }
524
806
  function parseActionVerbs(text) {
525
807
  const words = tokenize(text);
@@ -528,7 +810,7 @@ function parseActionVerbs(text) {
528
810
  weak: WEAK_VERBS.filter((verb) => words.includes(verb))
529
811
  };
530
812
  }
531
- function parseExperience(sectionContent, referenceDate) {
813
+ function parseExperience(sectionContent, referenceDate, aliases) {
532
814
  if (!sectionContent) {
533
815
  return { entries: [], rangesInMonths: [], jobTitles: [], achievements: [] };
534
816
  }
@@ -539,18 +821,18 @@ function parseExperience(sectionContent, referenceDate) {
539
821
  const achievements = [];
540
822
  for (const line of lines) {
541
823
  const range = parseDateRange(line, referenceDate);
542
- const titleMatch = line.match(TITLE_RE);
543
- const title = titleMatch ? titleMatch[0].trim() : void 0;
824
+ const titleMatch2 = line.match(TITLE_RE);
825
+ const title = titleMatch2 ? titleMatch2[0].trim() : void 0;
544
826
  if (range) {
545
827
  if (title) {
546
828
  jobTitles.push(title.toLowerCase());
547
- entries.push({ title, dates: range, description: line });
829
+ entries.push({ title, dates: range, description: line, skills: [] });
548
830
  } else {
549
831
  const previous = entries[entries.length - 1];
550
832
  if (previous && !previous.dates) {
551
833
  previous.dates = range;
552
834
  } else {
553
- entries.push({ dates: range });
835
+ entries.push({ dates: range, skills: [] });
554
836
  }
555
837
  }
556
838
  if (range.durationInMonths) {
@@ -560,7 +842,11 @@ function parseExperience(sectionContent, referenceDate) {
560
842
  }
561
843
  if (title) {
562
844
  jobTitles.push(title.toLowerCase());
563
- const entry = { title, description: line };
845
+ const entry = {
846
+ title,
847
+ description: line,
848
+ skills: []
849
+ };
564
850
  entries.push(entry);
565
851
  achievements.push(classifyAchievement(line));
566
852
  continue;
@@ -571,7 +857,15 @@ function parseExperience(sectionContent, referenceDate) {
571
857
  }
572
858
  achievements.push(classifyAchievement(line));
573
859
  }
574
- return { entries, rangesInMonths, jobTitles: unique(jobTitles), achievements };
860
+ for (const entry of entries) {
861
+ entry.skills = extractKnownSkillsFromText(entry.description, aliases);
862
+ }
863
+ return {
864
+ entries,
865
+ rangesInMonths,
866
+ jobTitles: unique(jobTitles),
867
+ achievements
868
+ };
575
869
  }
576
870
  function parseEducation(sectionContent) {
577
871
  if (!sectionContent) return [];
@@ -582,18 +876,36 @@ function collectKeywords(text) {
582
876
  }
583
877
  var EMAIL_RE = /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/i;
584
878
  var PHONE_RE = /\+?\(?\d{1,4}\)?(?:[\s.-]?\d{2,4}){2,4}/;
879
+ var LINKEDIN_RE = /(?:https?:\/\/)?(?:[a-z]{2,3}\.)?linkedin\.com\/(?:in|pub)\/[a-z0-9\-_%]+\/?/i;
880
+ var LOCATION_RE = /^[A-Z][A-Za-z.'-]+(?:\s[A-Z][A-Za-z.'-]+)*,\s*[A-Z][A-Za-z]{1,20}$/;
881
+ function extractLocation(text) {
882
+ const candidateLines = splitLines(text).slice(0, 8);
883
+ for (const line of candidateLines) {
884
+ if (EMAIL_RE.test(line) || /\d/.test(line)) continue;
885
+ if (LOCATION_RE.test(line)) {
886
+ return line;
887
+ }
888
+ }
889
+ return void 0;
890
+ }
585
891
  function extractContact(text) {
586
892
  const email = text.match(EMAIL_RE)?.[0];
587
893
  const phone = text.match(PHONE_RE)?.[0]?.trim();
588
- if (!email && !phone) return void 0;
589
- return { email, phone };
894
+ const linkedin = text.match(LINKEDIN_RE)?.[0];
895
+ const location = extractLocation(text);
896
+ if (!email && !phone && !linkedin && !location) return void 0;
897
+ return { email, phone, linkedin, location };
590
898
  }
591
899
  function parseResume(resumeText, config) {
592
900
  const normalizedText = normalizeWhitespace(resumeText);
593
901
  const { sections, detected } = extractSections(resumeText);
594
- const skills = parseSkills(sections.skills, config.skillAliases);
902
+ const skills = parseSkills(sections, config.skillAliases);
595
903
  const actionVerbs = parseActionVerbs(normalizedText);
596
- const experienceData = parseExperience(sections.experience, config.referenceDate);
904
+ const experienceData = parseExperience(
905
+ sections.experience,
906
+ config.referenceDate,
907
+ config.skillAliases
908
+ );
597
909
  const educationEntries = parseEducation(sections.education);
598
910
  let totalExperienceYears = sumExperienceYears(
599
911
  experienceData.entries.map((entry) => entry.dates).filter((range) => Boolean(range))
@@ -606,7 +918,12 @@ function parseResume(resumeText, config) {
606
918
  totalExperienceYears = parsed <= 60 ? parsed : 0;
607
919
  }
608
920
  }
609
- const requiredSections = ["summary", "experience", "skills", "education"];
921
+ const requiredSections = [
922
+ "summary",
923
+ "experience",
924
+ "skills",
925
+ "education"
926
+ ];
610
927
  const warnings = [];
611
928
  const lineCount = splitLines(resumeText).length;
612
929
  if (resumeText.trim().length < 100) {
@@ -645,6 +962,9 @@ function parseResume(resumeText, config) {
645
962
  keywords: collectKeywords(normalizedText),
646
963
  languages: parseLanguageMentions(resumeText),
647
964
  contact,
965
+ seniority: inferSeniority(experienceData.jobTitles),
966
+ employmentGaps: detectEmploymentGaps(experienceData.entries),
967
+ formatting: detectFormatting(resumeText),
648
968
  warnings
649
969
  };
650
970
  }
@@ -655,7 +975,12 @@ var RuleEngine = class {
655
975
  this.config = config;
656
976
  }
657
977
  applySectionPenalties(resume) {
658
- const requiredSections = ["summary", "experience", "skills", "education"];
978
+ const requiredSections = [
979
+ "summary",
980
+ "experience",
981
+ "skills",
982
+ "education"
983
+ ];
659
984
  let totalPenalty = 0;
660
985
  const warnings = [];
661
986
  const penaltyBySection = {
@@ -681,14 +1006,16 @@ var RuleEngine = class {
681
1006
  const sectionResult = this.applySectionPenalties(input.resume);
682
1007
  totalPenalty += sectionResult.totalPenalty;
683
1008
  warnings.push(...sectionResult.warnings);
684
- if (containsTableLikeStructure(input.resume.raw)) {
1009
+ if (input.resume.formatting.hasTables) {
685
1010
  totalPenalty += 8;
686
1011
  warnings.push("Detected table-like or columnar formatting (penalty 8)");
687
1012
  }
688
1013
  if (input.overusedKeywords && input.overusedKeywords.length > 0) {
689
1014
  const penalty = input.overusedKeywords.length * this.config.keywordDensity.overusePenalty;
690
1015
  totalPenalty += penalty;
691
- warnings.push(`Keyword stuffing detected for: ${input.overusedKeywords.join(", ")} (penalty ${penalty})`);
1016
+ warnings.push(
1017
+ `Keyword stuffing detected for: ${input.overusedKeywords.join(", ")} (penalty ${penalty})`
1018
+ );
692
1019
  }
693
1020
  if (input.resume.detectedSections.length < 3) {
694
1021
  totalPenalty += 5;
@@ -729,9 +1056,31 @@ var RuleEngine = class {
729
1056
  // src/core/scoring/scorer.ts
730
1057
  var REQUIRED_SKILL_WEIGHT = 0.7;
731
1058
  var OPTIONAL_SKILL_WEIGHT = 0.3;
732
- var EXPERIENCE_YEARS_WEIGHT = 0.75;
733
- var EXPERIENCE_ROLE_WEIGHT = 0.25;
734
- var ALL_CATEGORIES = ["technical", "tool", "concept", "soft", "marketing", "domain"];
1059
+ var EXPERIENCE_YEARS_WEIGHT = 0.65;
1060
+ var EXPERIENCE_ROLE_WEIGHT = 0.2;
1061
+ var EXPERIENCE_SENIORITY_WEIGHT = 0.15;
1062
+ var ALL_CATEGORIES = [
1063
+ "technical",
1064
+ "tool",
1065
+ "concept",
1066
+ "soft",
1067
+ "marketing",
1068
+ "domain"
1069
+ ];
1070
+ var SENIORITY_RANK2 = {
1071
+ junior: 0,
1072
+ mid: 1,
1073
+ senior: 2,
1074
+ lead: 3,
1075
+ principal: 4
1076
+ };
1077
+ var DEGREE_RANK = {
1078
+ associate: 1,
1079
+ bachelor: 2,
1080
+ master: 3,
1081
+ mba: 3,
1082
+ phd: 4
1083
+ };
735
1084
  function emptyCategoryBuckets() {
736
1085
  const buckets = {};
737
1086
  for (const category of ALL_CATEGORIES) {
@@ -739,18 +1088,32 @@ function emptyCategoryBuckets() {
739
1088
  }
740
1089
  return buckets;
741
1090
  }
742
- function scoreSkills(resume, job, config) {
1091
+ function scoreSkills(resume, job, config, matchOptions) {
743
1092
  const profileRequired = config.profile?.mandatorySkills ?? [];
744
1093
  const profileOptional = config.profile?.optionalSkills ?? [];
745
1094
  const required = new Set(
746
- normalizeSkills([...job.requiredSkills, ...profileRequired], config.skillAliases)
1095
+ normalizeSkills(
1096
+ [...job.requiredSkills, ...profileRequired],
1097
+ config.skillAliases,
1098
+ matchOptions
1099
+ )
747
1100
  );
748
1101
  const optional = new Set(
749
- normalizeSkills([...job.preferredSkills, ...profileOptional], config.skillAliases)
1102
+ normalizeSkills(
1103
+ [...job.preferredSkills, ...profileOptional],
1104
+ config.skillAliases,
1105
+ matchOptions
1106
+ )
1107
+ );
1108
+ const resumeSkills = new Set(
1109
+ normalizeSkills(resume.skills, config.skillAliases, matchOptions)
1110
+ );
1111
+ const matchedRequired = [...required].filter(
1112
+ (skill) => resumeSkills.has(skill)
1113
+ );
1114
+ const matchedOptional = [...optional].filter(
1115
+ (skill) => resumeSkills.has(skill)
750
1116
  );
751
- const resumeSkills = new Set(normalizeSkills(resume.skills, config.skillAliases));
752
- const matchedRequired = [...required].filter((skill) => resumeSkills.has(skill));
753
- const matchedOptional = [...optional].filter((skill) => resumeSkills.has(skill));
754
1117
  const requiredCoverage = required.size === 0 ? 1 : matchedRequired.length / required.size;
755
1118
  const optionalCoverage = optional.size === 0 ? 1 : matchedOptional.length / optional.size;
756
1119
  const score = clamp(
@@ -762,30 +1125,45 @@ function scoreSkills(resume, job, config) {
762
1125
  const missing = [...required].filter((skill) => !resumeSkills.has(skill)).sort();
763
1126
  return { score, matched, missing };
764
1127
  }
1128
+ function computeSeniorityMatch(resume, job) {
1129
+ const resumeSeniority = resume.seniority;
1130
+ const required = job.seniority;
1131
+ const met = !resumeSeniority || !required || SENIORITY_RANK2[resumeSeniority] >= SENIORITY_RANK2[required];
1132
+ return { resume: resumeSeniority, required, met };
1133
+ }
765
1134
  function scoreExperience(resume, job, config) {
1135
+ const seniorityMatch = computeSeniorityMatch(resume, job);
766
1136
  const requiredYears = job.minExperienceYears ?? config.profile?.minExperience ?? 0;
767
1137
  if (!requiredYears) {
768
- return { score: 100, missingYears: 0 };
1138
+ return { score: 100, missingYears: 0, seniorityMatch };
769
1139
  }
770
1140
  const yearCoverage = clamp(resume.totalExperienceYears / requiredYears, 0, 2);
771
1141
  const yearsComponent = clamp(yearCoverage, 0, 1) * EXPERIENCE_YEARS_WEIGHT;
772
- const jobRoleSet = new Set(job.roleKeywords.map((value) => value.toLowerCase()));
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;
1142
+ const titleCoverage = job.roleKeywords.length === 0 ? 1 : titleMatch(resume.jobTitles, job.roleKeywords);
776
1143
  const roleComponent = clamp(titleCoverage, 0, 1) * EXPERIENCE_ROLE_WEIGHT;
777
- const score = clamp((yearsComponent + roleComponent) * 100, 0, 100);
1144
+ const seniorityComponent = (seniorityMatch.met ? 1 : 0) * EXPERIENCE_SENIORITY_WEIGHT;
1145
+ const score = clamp(
1146
+ (yearsComponent + roleComponent + seniorityComponent) * 100,
1147
+ 0,
1148
+ 100
1149
+ );
778
1150
  const missingYears = Math.max(requiredYears - resume.totalExperienceYears, 0);
779
- return { score, missingYears: Number(missingYears.toFixed(2)) };
1151
+ return {
1152
+ score,
1153
+ missingYears: Number(missingYears.toFixed(2)),
1154
+ seniorityMatch
1155
+ };
780
1156
  }
781
1157
  function keywordWeightOf(keyword, requiredSet, preferredSet, jdFrequencies) {
782
1158
  const base = requiredSet.has(keyword) ? 3 : preferredSet.has(keyword) ? 2 : 1;
783
1159
  const freqBonus = Math.min((jdFrequencies[keyword] ?? 1) - 1, 3) * 0.25;
784
1160
  return base + freqBonus;
785
1161
  }
786
- function scoreKeywords(resume, job, config) {
1162
+ function scoreKeywords(resume, job, config, matchOptions) {
787
1163
  const jobKeywordSet = new Set(
788
- job.keywords.map((k) => normalizeSkill(k, config.skillAliases))
1164
+ job.keywords.map(
1165
+ (k) => normalizeSkill(k, config.skillAliases, matchOptions)
1166
+ )
789
1167
  );
790
1168
  if (jobKeywordSet.size === 0) {
791
1169
  return {
@@ -798,20 +1176,32 @@ function scoreKeywords(resume, job, config) {
798
1176
  };
799
1177
  }
800
1178
  const resumeTokens = tokenize(resume.normalizedText).map(
801
- (t) => normalizeSkill(t, config.skillAliases)
1179
+ (t) => normalizeSkill(t, config.skillAliases, matchOptions)
802
1180
  );
803
1181
  const resumeTokenSet = new Set(resumeTokens);
804
1182
  const resumeFrequencies = countFrequencies(resumeTokens);
805
1183
  const requiredSet = new Set(job.requiredSkills);
806
1184
  const preferredSet = new Set(job.preferredSkills);
807
1185
  const jdFrequencies = countFrequencies(
808
- tokenize(job.normalizedText).map((t) => normalizeSkill(t, config.skillAliases))
1186
+ tokenize(job.normalizedText).map(
1187
+ (t) => normalizeSkill(t, config.skillAliases, matchOptions)
1188
+ )
809
1189
  );
810
1190
  const weightOf = (keyword) => keywordWeightOf(keyword, requiredSet, preferredSet, jdFrequencies);
811
- const matchedKeywords = [...jobKeywordSet].filter((keyword) => resumeTokenSet.has(keyword));
812
- const missingKeywords = [...jobKeywordSet].filter((keyword) => !resumeTokenSet.has(keyword));
813
- const totalWeight = [...jobKeywordSet].reduce((sum, keyword) => sum + weightOf(keyword), 0);
814
- const matchedWeight = matchedKeywords.reduce((sum, keyword) => sum + weightOf(keyword), 0);
1191
+ const matchedKeywords = [...jobKeywordSet].filter(
1192
+ (keyword) => resumeTokenSet.has(keyword)
1193
+ );
1194
+ const missingKeywords = [...jobKeywordSet].filter(
1195
+ (keyword) => !resumeTokenSet.has(keyword)
1196
+ );
1197
+ const totalWeight = [...jobKeywordSet].reduce(
1198
+ (sum, keyword) => sum + weightOf(keyword),
1199
+ 0
1200
+ );
1201
+ const matchedWeight = matchedKeywords.reduce(
1202
+ (sum, keyword) => sum + weightOf(keyword),
1203
+ 0
1204
+ );
815
1205
  const score = clamp(matchedWeight / totalWeight * 100, 0, 100);
816
1206
  const totalTokens = resumeTokens.length || 1;
817
1207
  const overusedKeywords = matchedKeywords.filter((keyword) => {
@@ -848,13 +1238,37 @@ function scoreKeywords(resume, job, config) {
848
1238
  keywordWeights
849
1239
  };
850
1240
  }
851
- function computeSkillExperienceGaps(resume, job, config) {
1241
+ function computePerSkillExperience(resume, config, matchOptions) {
1242
+ const bySkill = /* @__PURE__ */ new Map();
1243
+ for (const entry of resume.experience) {
1244
+ if (!entry.dates || entry.skills.length === 0) continue;
1245
+ const skillsInRole = normalizeSkills(
1246
+ entry.skills,
1247
+ config.skillAliases,
1248
+ matchOptions
1249
+ );
1250
+ for (const skill of skillsInRole) {
1251
+ const list = bySkill.get(skill) ?? [];
1252
+ list.push(entry.dates);
1253
+ bySkill.set(skill, list);
1254
+ }
1255
+ }
1256
+ return [...bySkill.entries()].map(([skill, dates]) => ({ skill, years: sumExperienceYears(dates) })).sort((a, b) => a.skill.localeCompare(b.skill));
1257
+ }
1258
+ function computeSkillExperienceGaps(resume, job, config, matchOptions, perSkillExperience) {
852
1259
  if (job.skillExperienceRequirements.length === 0) return [];
853
- const resumeSkills = new Set(normalizeSkills(resume.skills, config.skillAliases));
1260
+ const resumeSkills = new Set(
1261
+ normalizeSkills(resume.skills, config.skillAliases, matchOptions)
1262
+ );
1263
+ const yearsBySkill = new Map(
1264
+ perSkillExperience.map((p) => [p.skill, p.years])
1265
+ );
854
1266
  const gaps = [];
855
1267
  for (const { skill, years } of job.skillExperienceRequirements) {
856
- if (resumeSkills.has(skill) && resume.totalExperienceYears < years) {
857
- gaps.push({ skill, requiredYears: years, resumeYears: resume.totalExperienceYears });
1268
+ if (!resumeSkills.has(skill)) continue;
1269
+ const resumeYears = yearsBySkill.get(skill) ?? 0;
1270
+ if (resumeYears < years) {
1271
+ gaps.push({ skill, requiredYears: years, resumeYears });
858
1272
  }
859
1273
  }
860
1274
  return gaps.sort((a, b) => a.skill.localeCompare(b.skill));
@@ -863,36 +1277,120 @@ function scoreEducation(resume, job) {
863
1277
  if (job.educationRequirements.length === 0) {
864
1278
  return 100;
865
1279
  }
866
- const resumeDegreeLevels = extractDegreeLevels(resume.educationEntries.join(" "));
867
- const matched = job.educationRequirements.filter(
868
- (requirement) => resumeDegreeLevels.includes(requirement)
1280
+ const resumeDegreeLevels = extractDegreeLevels(
1281
+ resume.educationEntries.join(" ")
869
1282
  );
870
- if (matched.length === 0) {
1283
+ if (resumeDegreeLevels.length === 0) {
871
1284
  return 0;
872
1285
  }
873
- return clamp(matched.length / job.educationRequirements.length * 100, 0, 100);
1286
+ const resumeMaxRank = Math.max(
1287
+ ...resumeDegreeLevels.map((d) => DEGREE_RANK[d] ?? 0)
1288
+ );
1289
+ const perRequirementCredit = job.educationRequirements.map(
1290
+ (requirement) => {
1291
+ if (resumeDegreeLevels.includes(requirement)) return 1;
1292
+ const requiredRank = DEGREE_RANK[requirement] ?? 0;
1293
+ const diff = resumeMaxRank - requiredRank;
1294
+ if (diff >= 1) return 0.85;
1295
+ if (diff === 0) return 0.6;
1296
+ if (diff === -1) return 0.35;
1297
+ return 0;
1298
+ }
1299
+ );
1300
+ const avgCredit = perRequirementCredit.reduce((sum, credit) => sum + credit, 0) / perRequirementCredit.length;
1301
+ return clamp(avgCredit * 100, 0, 100);
1302
+ }
1303
+ function scoreParseability(formatting, contact, detectedSections) {
1304
+ let score = 100;
1305
+ const deductions = [];
1306
+ const deduct = (points, reason) => {
1307
+ score -= points;
1308
+ deductions.push({ reason, points });
1309
+ };
1310
+ if (formatting.hasTables) {
1311
+ deduct(
1312
+ 20,
1313
+ "Table or columnar formatting detected \u2014 ATS parsers often misalign fields extracted from tables"
1314
+ );
1315
+ }
1316
+ if (formatting.hasMultiColumn) {
1317
+ deduct(
1318
+ 20,
1319
+ "Multi-column layout detected \u2014 text may extract out of reading order in real ATS systems"
1320
+ );
1321
+ }
1322
+ if (formatting.hasSpecialChars) {
1323
+ deduct(
1324
+ 10,
1325
+ "Special or control characters detected \u2014 likely icon fonts or a lossy text extraction"
1326
+ );
1327
+ }
1328
+ if (formatting.nonStandardBullets) {
1329
+ deduct(
1330
+ 8,
1331
+ "Non-standard bullet characters detected \u2014 some ATS parsers won't recognize them as list items"
1332
+ );
1333
+ }
1334
+ if (formatting.likelyScanned) {
1335
+ deduct(
1336
+ 30,
1337
+ "Resume text looks scanned or image-based with little extractable text"
1338
+ );
1339
+ }
1340
+ if (!contact?.email || !formatting.contactParseable) {
1341
+ deduct(15, "No parseable contact email detected");
1342
+ }
1343
+ if (detectedSections.length < 3) {
1344
+ deduct(10, "Fewer than 3 standard resume sections were detected");
1345
+ }
1346
+ return {
1347
+ score: clamp(score, 0, 100),
1348
+ report: {
1349
+ ...formatting,
1350
+ detectedSectionCount: detectedSections.length,
1351
+ deductions
1352
+ }
1353
+ };
874
1354
  }
875
1355
  function calculateScore(resume, job, config) {
876
- const skillsResult = scoreSkills(resume, job, config);
1356
+ const matchOptions = {
1357
+ fuzzy: config.matching.fuzzy,
1358
+ maxDistance: config.matching.threshold
1359
+ };
1360
+ const skillsResult = scoreSkills(resume, job, config, matchOptions);
877
1361
  const experienceResult = scoreExperience(resume, job, config);
878
- const keywordResult = scoreKeywords(resume, job, config);
1362
+ const keywordResult = scoreKeywords(resume, job, config, matchOptions);
879
1363
  const educationScore = scoreEducation(resume, job);
1364
+ const parseabilityResult = scoreParseability(
1365
+ resume.formatting,
1366
+ resume.contact,
1367
+ resume.detectedSections
1368
+ );
880
1369
  const breakdown = {
881
1370
  skills: skillsResult.score,
882
1371
  experience: experienceResult.score,
883
1372
  keywords: keywordResult.score,
1373
+ parseability: parseabilityResult.score,
884
1374
  education: educationScore
885
1375
  };
886
- const weightedScore = breakdown.skills * config.weights.skills + breakdown.experience * config.weights.experience + breakdown.keywords * config.weights.keywords + breakdown.education * config.weights.education;
1376
+ const weightedScore = breakdown.skills * config.weights.skills + breakdown.experience * config.weights.experience + breakdown.keywords * config.weights.keywords + breakdown.parseability * config.weights.parseability + breakdown.education * config.weights.education;
887
1377
  const achievementStrength = {
888
1378
  strong: resume.achievements.filter((a) => a.strength === "strong").length,
889
1379
  weak: resume.achievements.filter((a) => a.strength === "weak").length
890
1380
  };
891
- const { matched: matchedLanguages, missing: missingLanguages } = diffLanguages(
892
- resume.languages,
893
- job.requiredLanguages
1381
+ const { matched: matchedLanguages, missing: missingLanguages } = diffLanguages(resume.languages, job.requiredLanguages);
1382
+ const perSkillExperience = computePerSkillExperience(
1383
+ resume,
1384
+ config,
1385
+ matchOptions
1386
+ );
1387
+ const skillExperienceGaps = computeSkillExperienceGaps(
1388
+ resume,
1389
+ job,
1390
+ config,
1391
+ matchOptions,
1392
+ perSkillExperience
894
1393
  );
895
- const skillExperienceGaps = computeSkillExperienceGaps(resume, job, config);
896
1394
  return {
897
1395
  score: clamp(Number(weightedScore.toFixed(2)), 0, 100),
898
1396
  breakdown,
@@ -915,16 +1413,21 @@ function calculateScore(resume, job, config) {
915
1413
  parsedExperienceYears: 0,
916
1414
  experienceEntries: [],
917
1415
  missingExperienceYears: experienceResult.missingYears,
918
- educationScore
1416
+ educationScore,
1417
+ parseabilityReport: parseabilityResult.report,
1418
+ employmentGaps: resume.employmentGaps,
1419
+ seniorityMatch: experienceResult.seniorityMatch,
1420
+ perSkillExperience
919
1421
  };
920
1422
  }
921
1423
 
922
1424
  // src/core/scoring/weights.ts
923
1425
  var DEFAULT_WEIGHTS = {
924
- skills: 0.3,
925
- experience: 0.3,
1426
+ skills: 0.25,
1427
+ experience: 0.2,
926
1428
  keywords: 0.25,
927
- education: 0.15
1429
+ parseability: 0.2,
1430
+ education: 0.1
928
1431
  };
929
1432
  var DEFAULT_KEYWORD_DENSITY = {
930
1433
  min: 25e-4,
@@ -936,18 +1439,19 @@ var DEFAULT_SECTION_PENALTIES = {
936
1439
  missingExperience: 10,
937
1440
  missingSkills: 8,
938
1441
  missingEducation: 6,
939
- // Warning-only by defaultsee 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
1442
+ // Raised from 0 (warning-only) in v1a real ATS treats an unparseable contact email as a
1443
+ // near-knockout (no way to reach the candidate at all), so this now actually moves the score.
1444
+ missingContact: 12
942
1445
  };
943
1446
  function normalizeWeights(weights) {
944
- const total = weights.skills + weights.experience + weights.keywords + weights.education;
1447
+ const total = weights.skills + weights.experience + weights.keywords + weights.parseability + weights.education;
945
1448
  if (total === 0) {
946
- const equal = 1 / 4;
1449
+ const equal = 1 / 5;
947
1450
  return {
948
1451
  skills: equal,
949
1452
  experience: equal,
950
1453
  keywords: equal,
1454
+ parseability: equal,
951
1455
  education: equal,
952
1456
  normalizedTotal: 1
953
1457
  };
@@ -956,6 +1460,7 @@ function normalizeWeights(weights) {
956
1460
  skills: weights.skills / total,
957
1461
  experience: weights.experience / total,
958
1462
  keywords: weights.keywords / total,
1463
+ parseability: weights.parseability / total,
959
1464
  education: weights.education / total,
960
1465
  normalizedTotal: 1
961
1466
  };
@@ -965,12 +1470,19 @@ function resolveConfig(config = {}) {
965
1470
  skills: config.weights?.skills ?? DEFAULT_WEIGHTS.skills,
966
1471
  experience: config.weights?.experience ?? DEFAULT_WEIGHTS.experience,
967
1472
  keywords: config.weights?.keywords ?? DEFAULT_WEIGHTS.keywords,
1473
+ parseability: config.weights?.parseability ?? DEFAULT_WEIGHTS.parseability,
968
1474
  education: config.weights?.education ?? DEFAULT_WEIGHTS.education
969
1475
  };
970
- const keywordRegistry = mergeKeywordRegistries(defaultKeywordRegistry, config.keywordRegistry ?? []);
1476
+ const keywordRegistry = mergeKeywordRegistries(
1477
+ defaultKeywordRegistry,
1478
+ config.keywordRegistry ?? []
1479
+ );
971
1480
  const resolved = {
972
1481
  weights: normalizeWeights(weights),
973
- skillAliases: { ...deriveSkillAliases(keywordRegistry), ...config.skillAliases ?? {} },
1482
+ skillAliases: {
1483
+ ...deriveSkillAliases(keywordRegistry),
1484
+ ...config.skillAliases ?? {}
1485
+ },
974
1486
  keywordRegistry,
975
1487
  categoryIndex: buildCategoryIndex(keywordRegistry),
976
1488
  profile: config.profile ?? softwareEngineerProfile,
@@ -981,6 +1493,13 @@ function resolveConfig(config = {}) {
981
1493
  ...config.sectionPenalties ?? {}
982
1494
  },
983
1495
  allowPartialMatches: config.allowPartialMatches ?? true,
1496
+ // Fuzzy/stem matching is on by default in v2 (typos, word-form variants like "developing"
1497
+ // vs "develop", "ReactJS" vs "react") — callers who want v1's exact-match-only behavior can
1498
+ // override via config.matching = { fuzzy: false }.
1499
+ matching: {
1500
+ fuzzy: config.matching?.fuzzy ?? true,
1501
+ threshold: config.matching?.threshold
1502
+ },
984
1503
  referenceDate: config.referenceDate ? new Date(config.referenceDate) : void 0
985
1504
  };
986
1505
  return resolved;
@@ -993,13 +1512,17 @@ function formatList(values, max = 6) {
993
1512
  return trimmed.join(", ") + (uniqueValues.length > max ? "..." : "");
994
1513
  }
995
1514
  function buildAliasReplacementSuggestions(resume, job, config) {
996
- const jobKeywordSet = new Set(job.keywords.map((k) => normalizeSkill(k, config.skillAliases)));
1515
+ const jobKeywordSet = new Set(
1516
+ job.keywords.map((k) => normalizeSkill(k, config.skillAliases))
1517
+ );
997
1518
  const replacements = [];
998
1519
  for (const token of unique(tokenize(resume.normalizedText))) {
999
1520
  const canonical = normalizeSkill(token, config.skillAliases);
1000
1521
  const jdSurface = job.keywordSurfaceForms[canonical];
1001
1522
  if (jdSurface && jobKeywordSet.has(canonical) && jdSurface.toLowerCase() !== token.toLowerCase()) {
1002
- replacements.push(`Replace "${token}" with "${jdSurface}" to match the job description's wording.`);
1523
+ replacements.push(
1524
+ `Replace "${token}" with "${jdSurface}" to match the job description's wording.`
1525
+ );
1003
1526
  }
1004
1527
  }
1005
1528
  return unique(replacements).slice(0, 5);
@@ -1007,7 +1530,10 @@ function buildAliasReplacementSuggestions(resume, job, config) {
1007
1530
  var SuggestionEngine = class {
1008
1531
  generate(input) {
1009
1532
  const suggestions = [];
1010
- const warnings = [...input.ruleWarnings, ...input.resume.warnings];
1533
+ const warnings = [
1534
+ ...input.ruleWarnings,
1535
+ ...input.resume.warnings
1536
+ ];
1011
1537
  if (input.score.missingSkills.length > 0) {
1012
1538
  suggestions.push(
1013
1539
  `Highlight these required skills: ${formatList(input.score.missingSkills)}`
@@ -1018,7 +1544,9 @@ var SuggestionEngine = class {
1018
1544
  `Incorporate job-specific keywords: ${formatList(input.score.missingKeywords)}`
1019
1545
  );
1020
1546
  }
1021
- suggestions.push(...buildAliasReplacementSuggestions(input.resume, input.job, input.config));
1547
+ suggestions.push(
1548
+ ...buildAliasReplacementSuggestions(input.resume, input.job, input.config)
1549
+ );
1022
1550
  if (input.score.overusedKeywords.length > 0) {
1023
1551
  suggestions.push(
1024
1552
  `Avoid keyword stuffing for: ${formatList(input.score.overusedKeywords)}`
@@ -1029,7 +1557,7 @@ var SuggestionEngine = class {
1029
1557
  `Clarify at least ${input.job.minExperienceYears ?? input.score.missingExperienceYears} years of relevant experience with quantified achievements.`
1030
1558
  );
1031
1559
  }
1032
- if (input.job.educationRequirements.length > 0 && input.score.educationScore === 0) {
1560
+ if (input.job.educationRequirements.length > 0 && input.score.educationScore < 60) {
1033
1561
  suggestions.push(
1034
1562
  `State your education credentials matching: ${formatList(input.job.educationRequirements)}`
1035
1563
  );
@@ -1048,7 +1576,9 @@ var SuggestionEngine = class {
1048
1576
  const formatted = input.score.missingLanguages.map((l) => l.level ? `${l.name} (${l.level})` : l.name).join(", ");
1049
1577
  suggestions.push(`Mention your proficiency in: ${formatted}`);
1050
1578
  }
1051
- const weakAchievement = input.resume.achievements.find((a) => a.strength === "weak");
1579
+ const weakAchievement = input.resume.achievements.find(
1580
+ (a) => a.strength === "weak"
1581
+ );
1052
1582
  if (weakAchievement) {
1053
1583
  suggestions.push(
1054
1584
  `Strengthen "${weakAchievement.text}" \u2014 add scope/metrics, e.g. "Built and maintained scalable services handling 500k+ requests/day."`
@@ -1069,6 +1599,24 @@ var SuggestionEngine = class {
1069
1599
  `The role asks for ${gap.requiredYears}+ years of ${gap.skill}; make that duration explicit in your experience section.`
1070
1600
  );
1071
1601
  }
1602
+ if (input.score.breakdown.parseability < 80) {
1603
+ for (const deduction of input.score.parseabilityReport.deductions) {
1604
+ suggestions.push(`Improve resume parseability: ${deduction.reason}.`);
1605
+ }
1606
+ }
1607
+ const GAP_THRESHOLD_MONTHS = 6;
1608
+ for (const gap of input.score.employmentGaps) {
1609
+ if (gap.months >= GAP_THRESHOLD_MONTHS) {
1610
+ suggestions.push(
1611
+ `Address the ${gap.months}-month employment gap after "${gap.afterRole}" \u2014 a brief explanation (upskilling, caregiving, freelance work, etc.) reassures recruiters.`
1612
+ );
1613
+ }
1614
+ }
1615
+ if (!input.score.seniorityMatch.met) {
1616
+ suggestions.push(
1617
+ `This role expects ${input.score.seniorityMatch.required} seniority, but your resume reads as ${input.score.seniorityMatch.resume}; emphasize scope/ownership that reflects the level the role expects.`
1618
+ );
1619
+ }
1072
1620
  return { suggestions, warnings };
1073
1621
  }
1074
1622
  };
@@ -1116,7 +1664,10 @@ var LLMBudgetManager = class {
1116
1664
  callsUsed: this.callCount,
1117
1665
  callsRemaining: Math.max(0, this.limits.maxCalls - this.callCount),
1118
1666
  tokensUsed: this.totalTokensUsed,
1119
- tokensRemaining: Math.max(0, this.limits.maxTotalTokens - this.totalTokensUsed),
1667
+ tokensRemaining: Math.max(
1668
+ 0,
1669
+ this.limits.maxTotalTokens - this.totalTokensUsed
1670
+ ),
1120
1671
  totalCalls: this.limits.maxCalls,
1121
1672
  totalTokens: this.limits.maxTotalTokens
1122
1673
  };
@@ -1138,7 +1689,8 @@ var LLMBudgetManager = class {
1138
1689
 
1139
1690
  // src/llm/validation.ts
1140
1691
  function validateJsonSchema(data, schema) {
1141
- if (schema.type !== "object" || typeof data !== "object" || data === null) return false;
1692
+ if (schema.type !== "object" || typeof data !== "object" || data === null)
1693
+ return false;
1142
1694
  const obj = data;
1143
1695
  if (schema.required) {
1144
1696
  for (const r of schema.required) {
@@ -1168,9 +1720,12 @@ function validateJsonSchema(data, schema) {
1168
1720
  if (items && items.type && Array.isArray(value)) {
1169
1721
  const itemType = items.type;
1170
1722
  for (const item of value) {
1171
- if (itemType === "string" && typeof item !== "string") return false;
1172
- if (itemType === "number" && typeof item !== "number") return false;
1173
- if (itemType === "object" && (typeof item !== "object" || item === null)) return false;
1723
+ if (itemType === "string" && typeof item !== "string")
1724
+ return false;
1725
+ if (itemType === "number" && typeof item !== "number")
1726
+ return false;
1727
+ if (itemType === "object" && (typeof item !== "object" || item === null))
1728
+ return false;
1174
1729
  }
1175
1730
  }
1176
1731
  break;
@@ -1197,24 +1752,36 @@ var LLMManager = class {
1197
1752
  */
1198
1753
  async callLLM(systemPrompt, userPrompt, schema, options = {}) {
1199
1754
  const onUnhandled = (reason) => {
1200
- this.warnings.push(`Unhandled rejection during LLM call: ${String(reason)}`);
1755
+ this.warnings.push(
1756
+ `Unhandled rejection during LLM call: ${String(reason)}`
1757
+ );
1201
1758
  };
1202
1759
  process.on("unhandledRejection", onUnhandled);
1203
1760
  let clientPromise = void 0;
1204
1761
  try {
1205
- const estimatedTokens = this.estimateTokens(systemPrompt, userPrompt, options.requestedTokens);
1762
+ const estimatedTokens = this.estimateTokens(
1763
+ systemPrompt,
1764
+ userPrompt,
1765
+ options.requestedTokens
1766
+ );
1206
1767
  try {
1207
1768
  this.budgetManager.assertCanCall(estimatedTokens);
1208
1769
  } catch (e) {
1209
1770
  const msg = `LLM budget exhausted: ${e.message}`;
1210
1771
  this.warnings.push(msg);
1211
- globalThis.setTimeout(() => process.removeListener("unhandledRejection", onUnhandled), 100);
1772
+ globalThis.setTimeout(
1773
+ () => process.removeListener("unhandledRejection", onUnhandled),
1774
+ 100
1775
+ );
1212
1776
  return { success: false, fallback: true, error: msg };
1213
1777
  }
1214
1778
  if (!this.isValidJsonSchema(schema)) {
1215
1779
  const msg = "Invalid JSON schema provided";
1216
1780
  this.warnings.push(msg);
1217
- globalThis.setTimeout(() => process.removeListener("unhandledRejection", onUnhandled), 100);
1781
+ globalThis.setTimeout(
1782
+ () => process.removeListener("unhandledRejection", onUnhandled),
1783
+ 100
1784
+ );
1218
1785
  return { success: false, fallback: true, error: msg };
1219
1786
  }
1220
1787
  const strictUserPrompt = `${userPrompt}
@@ -1233,23 +1800,36 @@ No explanations. No markdown. No additional text.`;
1233
1800
  void clientPromise.catch(() => {
1234
1801
  });
1235
1802
  const timeoutPromise = new Promise((_, reject) => {
1236
- const id = globalThis.setTimeout(() => reject(new Error(`LLM call timeout after ${this.timeoutMs}ms`)), this.timeoutMs);
1803
+ const id = globalThis.setTimeout(
1804
+ () => reject(new Error(`LLM call timeout after ${this.timeoutMs}ms`)),
1805
+ this.timeoutMs
1806
+ );
1237
1807
  void clientPromise.finally(() => globalThis.clearTimeout(id)).catch(() => {
1238
1808
  });
1239
1809
  });
1240
1810
  void timeoutPromise.catch(() => {
1241
1811
  });
1242
- const response = await Promise.race([clientPromise, timeoutPromise]);
1812
+ const response = await Promise.race([
1813
+ clientPromise,
1814
+ timeoutPromise
1815
+ ]);
1243
1816
  if (!response || !response.content) {
1244
1817
  const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
1245
1818
  try {
1246
- await Promise.race([clientPromise.catch(() => {
1247
- }), new Promise((r) => setTimeout(r, graceMs))]);
1248
- } catch (e) {
1819
+ await Promise.race([
1820
+ clientPromise.catch(() => {
1821
+ }),
1822
+ new Promise((r) => setTimeout(r, graceMs))
1823
+ ]);
1824
+ } catch {
1249
1825
  } finally {
1250
1826
  process.removeListener("unhandledRejection", onUnhandled);
1251
1827
  }
1252
- return { success: false, fallback: true, error: "Empty response from LLM" };
1828
+ return {
1829
+ success: false,
1830
+ fallback: true,
1831
+ error: "Empty response from LLM"
1832
+ };
1253
1833
  }
1254
1834
  let parsedContent;
1255
1835
  try {
@@ -1263,9 +1843,12 @@ No explanations. No markdown. No additional text.`;
1263
1843
  this.warnings.push(msg);
1264
1844
  const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
1265
1845
  try {
1266
- await Promise.race([clientPromise.catch(() => {
1267
- }), new Promise((r) => setTimeout(r, graceMs))]);
1268
- } catch (e2) {
1846
+ await Promise.race([
1847
+ clientPromise.catch(() => {
1848
+ }),
1849
+ new Promise((r) => setTimeout(r, graceMs))
1850
+ ]);
1851
+ } catch {
1269
1852
  } finally {
1270
1853
  process.removeListener("unhandledRejection", onUnhandled);
1271
1854
  }
@@ -1276,9 +1859,12 @@ No explanations. No markdown. No additional text.`;
1276
1859
  this.warnings.push(msg);
1277
1860
  const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
1278
1861
  try {
1279
- await Promise.race([clientPromise.catch(() => {
1280
- }), new Promise((r) => setTimeout(r, graceMs))]);
1281
- } catch (e) {
1862
+ await Promise.race([
1863
+ clientPromise.catch(() => {
1864
+ }),
1865
+ new Promise((r) => setTimeout(r, graceMs))
1866
+ ]);
1867
+ } catch {
1282
1868
  } finally {
1283
1869
  process.removeListener("unhandledRejection", onUnhandled);
1284
1870
  }
@@ -1298,9 +1884,12 @@ No explanations. No markdown. No additional text.`;
1298
1884
  this.warnings.push(msg);
1299
1885
  const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
1300
1886
  try {
1301
- await Promise.race([clientPromise.catch(() => {
1302
- }), new Promise((r) => setTimeout(r, graceMs))]);
1303
- } catch (e2) {
1887
+ await Promise.race([
1888
+ clientPromise.catch(() => {
1889
+ }),
1890
+ new Promise((r) => setTimeout(r, graceMs))
1891
+ ]);
1892
+ } catch {
1304
1893
  } finally {
1305
1894
  process.removeListener("unhandledRejection", onUnhandled);
1306
1895
  }
@@ -1350,7 +1939,7 @@ No explanations. No markdown. No additional text.`;
1350
1939
  validateAgainstSchema(data, schema) {
1351
1940
  try {
1352
1941
  return validateJsonSchema(data, schema);
1353
- } catch (e) {
1942
+ } catch {
1354
1943
  if (typeof data !== "object" || data === null) {
1355
1944
  return false;
1356
1945
  }
@@ -1410,7 +1999,15 @@ var sectionClassificationSchema = {
1410
1999
  },
1411
2000
  classification: {
1412
2001
  type: "string",
1413
- enum: ["summary", "experience", "skills", "education", "projects", "certifications", "other"],
2002
+ enum: [
2003
+ "summary",
2004
+ "experience",
2005
+ "skills",
2006
+ "education",
2007
+ "projects",
2008
+ "certifications",
2009
+ "other"
2010
+ ],
1414
2011
  description: "Classified section type"
1415
2012
  },
1416
2013
  confidence: {
@@ -1713,7 +2310,10 @@ function analyzeResume(input) {
1713
2310
  let suggestions = suggestionResult.suggestions;
1714
2311
  const llmWarnings = [];
1715
2312
  if (input.llm && suggestionResult.suggestions.length > 0) {
1716
- const llmResult = enhanceSuggestionsWithLLM(input.llm, suggestionResult.suggestions);
2313
+ const llmResult = enhanceSuggestionsWithLLM(
2314
+ input.llm,
2315
+ suggestionResult.suggestions
2316
+ );
1717
2317
  if (llmResult.success) {
1718
2318
  suggestions = llmResult.enhancedSuggestions || suggestions;
1719
2319
  }
@@ -1738,17 +2338,20 @@ function analyzeResume(input) {
1738
2338
  detectedSections: parsedResume.detectedSections,
1739
2339
  parsedExperienceYears: parsedResume.totalExperienceYears,
1740
2340
  experienceEntries: parsedResume.experience,
2341
+ parseabilityReport: scoring.parseabilityReport,
2342
+ employmentGaps: scoring.employmentGaps,
2343
+ seniorityMatch: scoring.seniorityMatch,
2344
+ perSkillExperience: scoring.perSkillExperience,
1741
2345
  suggestions,
1742
2346
  warnings: [...suggestionResult.warnings, ...llmWarnings]
1743
2347
  };
1744
2348
  }
1745
- function enhanceSuggestionsWithLLM(config, suggestions) {
2349
+ function enhanceSuggestionsWithLLM(config, _suggestions) {
1746
2350
  if (!config.enable?.suggestions) {
1747
2351
  return { success: false, warnings: [] };
1748
2352
  }
1749
2353
  const warnings = [];
1750
2354
  try {
1751
- const llmManager = new LLMManager(config);
1752
2355
  warnings.push(
1753
2356
  "LLM suggestion enhancement skipped - use async analyzeResumeAsync for LLM features"
1754
2357
  );
@@ -1810,6 +2413,10 @@ async function analyzeResumeAsync(input) {
1810
2413
  detectedSections: parsedResume.detectedSections,
1811
2414
  parsedExperienceYears: parsedResume.totalExperienceYears,
1812
2415
  experienceEntries: parsedResume.experience,
2416
+ parseabilityReport: scoring.parseabilityReport,
2417
+ employmentGaps: scoring.employmentGaps,
2418
+ seniorityMatch: scoring.seniorityMatch,
2419
+ perSkillExperience: scoring.perSkillExperience,
1813
2420
  suggestions,
1814
2421
  warnings: [...suggestionResult.warnings, ...llmWarnings]
1815
2422
  };
@@ -1831,13 +2438,19 @@ async function enhanceSuggestionsWithLLMAsync(config, suggestions) {
1831
2438
  if (result.error) {
1832
2439
  warnings.push(`LLM suggestion enhancement failed: ${result.error}`);
1833
2440
  }
1834
- return { success: false, warnings: [...warnings, ...llmManager.getWarnings()] };
2441
+ return {
2442
+ success: false,
2443
+ warnings: [...warnings, ...llmManager.getWarnings()]
2444
+ };
1835
2445
  }
1836
2446
  const enhanced = adaptSuggestionEnhancementResponse(result.data);
1837
2447
  const enhancedSuggestions = enhanced.filter((e) => e.actionable !== false).map((e) => e.enhanced);
1838
2448
  if (enhancedSuggestions.length === 0) {
1839
2449
  warnings.push("LLM returned no actionable enhanced suggestions");
1840
- return { success: false, warnings: [...warnings, ...llmManager.getWarnings()] };
2450
+ return {
2451
+ success: false,
2452
+ warnings: [...warnings, ...llmManager.getWarnings()]
2453
+ };
1841
2454
  }
1842
2455
  return {
1843
2456
  success: true,
@@ -1845,7 +2458,9 @@ async function enhanceSuggestionsWithLLMAsync(config, suggestions) {
1845
2458
  warnings: llmManager.getWarnings()
1846
2459
  };
1847
2460
  } catch (e) {
1848
- warnings.push(`Unexpected error in LLM enhancement: ${e.message}`);
2461
+ warnings.push(
2462
+ `Unexpected error in LLM enhancement: ${e.message}`
2463
+ );
1849
2464
  return { success: false, warnings };
1850
2465
  }
1851
2466
  }