@pranavraut033/ats-checker 1.0.4 → 1.1.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
@@ -35,13 +35,15 @@ function normalizeWhitespace(text) {
35
35
  return text.replace(/\r\n?/g, "\n").replace(/\s+/g, " ").trim();
36
36
  }
37
37
  function normalizeForComparison(text) {
38
- return normalizeWhitespace(text).toLowerCase();
38
+ return normalizeWhitespace(text).normalize("NFKC").toLowerCase();
39
39
  }
40
40
  function splitLines(text) {
41
41
  return text.replace(/\r\n?/g, "\n").split("\n").map((line) => line.trim()).filter(Boolean);
42
42
  }
43
+ var TECH_TOKEN_RE = /[a-z0-9][a-z0-9.#+\-/]*[a-z0-9#+]/g;
43
44
  function tokenize(text) {
44
- return normalizeForComparison(text).split(/[^a-z0-9+]+/i).map((word) => word.trim()).filter((word) => word.length > 1 && !STOP_WORDS.has(word));
45
+ const normalized = normalizeForComparison(text);
46
+ return (normalized.match(TECH_TOKEN_RE) ?? []).filter((t) => !STOP_WORDS.has(t));
45
47
  }
46
48
  function unique(values) {
47
49
  const seen = /* @__PURE__ */ new Set();
@@ -215,7 +217,7 @@ function monthsBetween(start, end) {
215
217
  const endMonth = end.month ?? 12;
216
218
  return (end.year - start.year) * 12 + (endMonth - startMonth + 1);
217
219
  }
218
- function parseDateRange(text) {
220
+ function parseDateRange(text, referenceDate) {
219
221
  const normalized = text.trim();
220
222
  const rangeMatch = normalized.match(/([A-Za-z]{3,9}\s+\d{4}|\d{4})\s*(?:-|to|–|—)\s*(Present|Current|Now|[A-Za-z]{3,9}\s+\d{4}|\d{4})/i);
221
223
  if (!rangeMatch) {
@@ -228,9 +230,10 @@ function parseDateRange(text) {
228
230
  if (!startToken) {
229
231
  return null;
230
232
  }
233
+ const ref = referenceDate ?? /* @__PURE__ */ new Date();
231
234
  const endTokenResolved = endToken ?? {
232
- year: (/* @__PURE__ */ new Date()).getFullYear(),
233
- month: (/* @__PURE__ */ new Date()).getMonth() + 1
235
+ year: ref.getFullYear(),
236
+ month: ref.getMonth() + 1
234
237
  };
235
238
  const durationInMonths = monthsBetween(startToken, endTokenResolved);
236
239
  return {
@@ -275,11 +278,15 @@ var ACTION_VERBS = [
275
278
  "reduced",
276
279
  "increased"
277
280
  ];
281
+ function escapeRegExp(input) {
282
+ return input.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&");
283
+ }
278
284
  function detectSection(line) {
279
- const normalized = line.toLowerCase();
285
+ const normalized = line.trim().toLowerCase();
280
286
  for (const [section, aliases] of Object.entries(SECTION_ALIASES)) {
281
287
  for (const alias of aliases) {
282
- const headerPattern = new RegExp(`^${alias}(s*:)?$`, "i");
288
+ const safeAlias = escapeRegExp(alias);
289
+ const headerPattern = new RegExp(`^${safeAlias}(\\s*:)?$`, "i");
283
290
  if (headerPattern.test(normalized)) {
284
291
  return section;
285
292
  }
@@ -321,7 +328,7 @@ function parseActionVerbs(text) {
321
328
  const words = tokenize(text);
322
329
  return ACTION_VERBS.filter((verb) => words.includes(verb));
323
330
  }
324
- function parseExperience(sectionContent) {
331
+ function parseExperience(sectionContent, referenceDate) {
325
332
  if (!sectionContent) {
326
333
  return { entries: [], rangesInMonths: [], jobTitles: [] };
327
334
  }
@@ -330,7 +337,7 @@ function parseExperience(sectionContent) {
330
337
  const rangesInMonths = [];
331
338
  const jobTitles = [];
332
339
  for (const line of lines) {
333
- const range = parseDateRange(line);
340
+ const range = parseDateRange(line, referenceDate);
334
341
  if (range) {
335
342
  const previous = entries[entries.length - 1];
336
343
  if (previous && !previous.dates) {
@@ -370,7 +377,7 @@ function parseResume(resumeText, config) {
370
377
  const { sections, detected } = extractSections(resumeText);
371
378
  const skills = parseSkills(sections.skills, config.skillAliases);
372
379
  const actionVerbs = parseActionVerbs(normalizedText);
373
- const experienceData = parseExperience(sections.experience);
380
+ const experienceData = parseExperience(sections.experience, config.referenceDate);
374
381
  const educationEntries = parseEducation(sections.education);
375
382
  const totalExperienceYears = sumExperienceYears(
376
383
  experienceData.entries.map((entry) => entry.dates).filter((range) => Boolean(range))
@@ -494,8 +501,9 @@ function scoreSkills(resume, job, config) {
494
501
  0,
495
502
  100
496
503
  );
497
- const missing = [...required].filter((skill) => !resumeSkills.has(skill));
498
- return { score, missing };
504
+ const matched = [...required].filter((skill) => resumeSkills.has(skill)).sort();
505
+ const missing = [...required].filter((skill) => !resumeSkills.has(skill)).sort();
506
+ return { score, matched, missing };
499
507
  }
500
508
  function scoreExperience(resume, job, config) {
501
509
  const requiredYears = job.minExperienceYears ?? config.profile?.minExperience ?? 0;
@@ -513,11 +521,15 @@ function scoreExperience(resume, job, config) {
513
521
  return { score, missingYears: Number(missingYears.toFixed(2)) };
514
522
  }
515
523
  function scoreKeywords(resume, job, config) {
516
- const jobKeywordSet = new Set(job.keywords.map((value) => value.toLowerCase()));
524
+ const jobKeywordSet = new Set(
525
+ job.keywords.map((k) => normalizeSkill(k, config.skillAliases))
526
+ );
517
527
  if (jobKeywordSet.size === 0) {
518
528
  return { score: 100, matchedKeywords: [], missingKeywords: [], overusedKeywords: [] };
519
529
  }
520
- const resumeTokens = tokenize(resume.normalizedText);
530
+ const resumeTokens = tokenize(resume.normalizedText).map(
531
+ (t) => normalizeSkill(t, config.skillAliases)
532
+ );
521
533
  const resumeTokenSet = new Set(resumeTokens);
522
534
  const matchedKeywords = [...jobKeywordSet].filter((keyword) => resumeTokenSet.has(keyword));
523
535
  const missingKeywords = [...jobKeywordSet].filter((keyword) => !resumeTokenSet.has(keyword));
@@ -531,9 +543,9 @@ function scoreKeywords(resume, job, config) {
531
543
  });
532
544
  return {
533
545
  score,
534
- matchedKeywords: unique(matchedKeywords),
535
- missingKeywords: unique(missingKeywords),
536
- overusedKeywords: unique(overusedKeywords)
546
+ matchedKeywords: unique(matchedKeywords).sort(),
547
+ missingKeywords: unique(missingKeywords).sort(),
548
+ overusedKeywords: unique(overusedKeywords).sort()
537
549
  };
538
550
  }
539
551
  function scoreEducation(resume, job) {
@@ -565,12 +577,17 @@ function calculateScore(resume, job, config) {
565
577
  return {
566
578
  score: clamp(Number(weightedScore.toFixed(2)), 0, 100),
567
579
  breakdown,
580
+ matchedSkills: skillsResult.matched,
581
+ missingSkills: skillsResult.missing,
568
582
  matchedKeywords: keywordResult.matchedKeywords,
569
583
  missingKeywords: keywordResult.missingKeywords,
570
584
  overusedKeywords: keywordResult.overusedKeywords,
571
585
  suggestions: [],
572
586
  warnings: [],
573
- missingSkills: skillsResult.missing,
587
+ // detectedSections / parsedExperienceYears / experienceGap: filled by index.ts
588
+ experienceGap: experienceResult.missingYears,
589
+ detectedSections: [],
590
+ parsedExperienceYears: 0,
574
591
  missingExperienceYears: experienceResult.missingYears,
575
592
  educationScore
576
593
  };
@@ -578,7 +595,9 @@ function calculateScore(resume, job, config) {
578
595
 
579
596
  // src/profiles/index.ts
580
597
  var defaultSkillAliases = {
581
- javascript: ["js", "node", "node.js", "nodejs"],
598
+ // ponytail: "node" split from javascript — Node.js runtime !== JS language
599
+ javascript: ["js"],
600
+ node: ["node.js", "nodejs"],
582
601
  typescript: ["ts"],
583
602
  react: ["reactjs", "react.js"],
584
603
  "c++": ["cpp"],
@@ -639,7 +658,14 @@ var DEFAULT_SECTION_PENALTIES = {
639
658
  function normalizeWeights(weights) {
640
659
  const total = weights.skills + weights.experience + weights.keywords + weights.education;
641
660
  if (total === 0) {
642
- return { ...weights, normalizedTotal: 1 };
661
+ const equal = 1 / 4;
662
+ return {
663
+ skills: equal,
664
+ experience: equal,
665
+ keywords: equal,
666
+ education: equal,
667
+ normalizedTotal: 1
668
+ };
643
669
  }
644
670
  return {
645
671
  skills: weights.skills / total,
@@ -666,7 +692,8 @@ function resolveConfig(config = {}) {
666
692
  ...DEFAULT_SECTION_PENALTIES,
667
693
  ...config.sectionPenalties ?? {}
668
694
  },
669
- allowPartialMatches: config.allowPartialMatches ?? true
695
+ allowPartialMatches: config.allowPartialMatches ?? true,
696
+ referenceDate: config.referenceDate ? new Date(config.referenceDate) : void 0
670
697
  };
671
698
  return resolved;
672
699
  }
@@ -778,6 +805,53 @@ var LLMBudgetManager = class {
778
805
  }
779
806
  };
780
807
 
808
+ // src/llm/validation.ts
809
+ function validateJsonSchema(data, schema) {
810
+ if (schema.type !== "object" || typeof data !== "object" || data === null) return false;
811
+ const obj = data;
812
+ if (schema.required) {
813
+ for (const r of schema.required) {
814
+ if (!(r in obj)) return false;
815
+ }
816
+ }
817
+ if (schema.properties && typeof schema.properties === "object") {
818
+ for (const [key, propSchema] of Object.entries(schema.properties)) {
819
+ const value = obj[key];
820
+ if (value === void 0) continue;
821
+ if (propSchema == null) continue;
822
+ const expectedType = propSchema.type;
823
+ if (!expectedType) continue;
824
+ switch (expectedType) {
825
+ case "string":
826
+ if (typeof value !== "string") return false;
827
+ break;
828
+ case "number":
829
+ if (typeof value !== "number") return false;
830
+ break;
831
+ case "boolean":
832
+ if (typeof value !== "boolean") return false;
833
+ break;
834
+ case "array":
835
+ if (!Array.isArray(value)) return false;
836
+ const items = propSchema.items;
837
+ if (items && items.type && Array.isArray(value)) {
838
+ const itemType = items.type;
839
+ for (const item of value) {
840
+ if (itemType === "string" && typeof item !== "string") return false;
841
+ if (itemType === "number" && typeof item !== "number") return false;
842
+ if (itemType === "object" && (typeof item !== "object" || item === null)) return false;
843
+ }
844
+ }
845
+ break;
846
+ case "object":
847
+ if (typeof value !== "object" || value === null) return false;
848
+ break;
849
+ }
850
+ }
851
+ }
852
+ return true;
853
+ }
854
+
781
855
  // src/llm/llm.manager.ts
782
856
  var LLMManager = class {
783
857
  constructor(config) {
@@ -791,6 +865,11 @@ var LLMManager = class {
791
865
  * Structured call to LLM with timeout and budget protection
792
866
  */
793
867
  async callLLM(systemPrompt, userPrompt, schema, options = {}) {
868
+ const onUnhandled = (reason) => {
869
+ this.warnings.push(`Unhandled rejection during LLM call: ${String(reason)}`);
870
+ };
871
+ process.on("unhandledRejection", onUnhandled);
872
+ let clientPromise = void 0;
794
873
  try {
795
874
  const estimatedTokens = this.estimateTokens(systemPrompt, userPrompt, options.requestedTokens);
796
875
  try {
@@ -798,30 +877,46 @@ var LLMManager = class {
798
877
  } catch (e) {
799
878
  const msg = `LLM budget exhausted: ${e.message}`;
800
879
  this.warnings.push(msg);
880
+ globalThis.setTimeout(() => process.removeListener("unhandledRejection", onUnhandled), 100);
801
881
  return { success: false, fallback: true, error: msg };
802
882
  }
803
883
  if (!this.isValidJsonSchema(schema)) {
804
884
  const msg = "Invalid JSON schema provided";
805
885
  this.warnings.push(msg);
886
+ globalThis.setTimeout(() => process.removeListener("unhandledRejection", onUnhandled), 100);
806
887
  return { success: false, fallback: true, error: msg };
807
888
  }
808
889
  const strictUserPrompt = `${userPrompt}
809
890
 
810
891
  Return ONLY valid JSON matching the schema below.
811
892
  No explanations. No markdown. No additional text.`;
812
- const response = await Promise.race([
813
- this.client.createCompletion({
814
- model: options.useThinking ? this.config.models?.thinking || this.config.models?.default || "gpt-4o" : this.config.models?.default || "gpt-4o",
815
- messages: [
816
- { role: "system", content: systemPrompt },
817
- { role: "user", content: strictUserPrompt }
818
- ],
819
- max_tokens: options.requestedTokens || 2e3,
820
- response_format: schema
821
- }),
822
- this.createTimeout(this.timeoutMs)
823
- ]);
893
+ clientPromise = this.client.createCompletion({
894
+ model: options.useThinking ? this.config.models?.thinking || this.config.models?.default || "gpt-4o" : this.config.models?.default || "gpt-4o",
895
+ messages: [
896
+ { role: "system", content: systemPrompt },
897
+ { role: "user", content: strictUserPrompt }
898
+ ],
899
+ max_tokens: options.requestedTokens || 2e3,
900
+ response_format: schema
901
+ });
902
+ void clientPromise.catch(() => {
903
+ });
904
+ const timeoutPromise = new Promise((_, reject) => {
905
+ const id = globalThis.setTimeout(() => reject(new Error(`LLM call timeout after ${this.timeoutMs}ms`)), this.timeoutMs);
906
+ void clientPromise.finally(() => globalThis.clearTimeout(id));
907
+ });
908
+ void timeoutPromise.catch(() => {
909
+ });
910
+ const response = await Promise.race([clientPromise, timeoutPromise]);
824
911
  if (!response || !response.content) {
912
+ const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
913
+ try {
914
+ await Promise.race([clientPromise.catch(() => {
915
+ }), new Promise((r) => setTimeout(r, graceMs))]);
916
+ } catch (e) {
917
+ } finally {
918
+ process.removeListener("unhandledRejection", onUnhandled);
919
+ }
825
920
  return { success: false, fallback: true, error: "Empty response from LLM" };
826
921
  }
827
922
  let parsedContent;
@@ -834,15 +929,32 @@ No explanations. No markdown. No additional text.`;
834
929
  } catch (e) {
835
930
  const msg = `Invalid JSON in LLM response: ${e.message}`;
836
931
  this.warnings.push(msg);
932
+ const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
933
+ try {
934
+ await Promise.race([clientPromise.catch(() => {
935
+ }), new Promise((r) => setTimeout(r, graceMs))]);
936
+ } catch (e2) {
937
+ } finally {
938
+ process.removeListener("unhandledRejection", onUnhandled);
939
+ }
837
940
  return { success: false, fallback: true, error: msg };
838
941
  }
839
942
  if (!this.validateAgainstSchema(parsedContent, schema)) {
840
943
  const msg = "LLM response does not match schema";
841
944
  this.warnings.push(msg);
945
+ const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
946
+ try {
947
+ await Promise.race([clientPromise.catch(() => {
948
+ }), new Promise((r) => setTimeout(r, graceMs))]);
949
+ } catch (e) {
950
+ } finally {
951
+ process.removeListener("unhandledRejection", onUnhandled);
952
+ }
842
953
  return { success: false, fallback: true, error: msg };
843
954
  }
844
955
  const tokensUsed = response.usage?.total_tokens || estimatedTokens;
845
956
  this.budgetManager.recordUsage(tokensUsed);
957
+ process.removeListener("unhandledRejection", onUnhandled);
846
958
  return {
847
959
  success: true,
848
960
  fallback: false,
@@ -852,6 +964,14 @@ No explanations. No markdown. No additional text.`;
852
964
  } catch (e) {
853
965
  const msg = `LLM call failed: ${e.message}`;
854
966
  this.warnings.push(msg);
967
+ const graceMs = Math.min(Math.max(this.timeoutMs * 2, 100), 500);
968
+ try {
969
+ await Promise.race([clientPromise.catch(() => {
970
+ }), new Promise((r) => setTimeout(r, graceMs))]);
971
+ } catch (e2) {
972
+ } finally {
973
+ process.removeListener("unhandledRejection", onUnhandled);
974
+ }
855
975
  return { success: false, fallback: true, error: msg };
856
976
  }
857
977
  }
@@ -874,14 +994,6 @@ No explanations. No markdown. No additional text.`;
874
994
  return this.config.enable?.[feature] === true;
875
995
  }
876
996
  // ============ Private helpers ============
877
- /**
878
- * Create a timeout promise
879
- */
880
- createTimeout(ms) {
881
- return new Promise((_, reject) => {
882
- globalThis.setTimeout(() => reject(new Error(`LLM call timeout after ${ms}ms`)), ms);
883
- });
884
- }
885
997
  /**
886
998
  * Estimate tokens for a call (rough approximation)
887
999
  * 1 token ≈ 4 characters average
@@ -904,18 +1016,22 @@ No explanations. No markdown. No additional text.`;
904
1016
  * Simple schema validation - check required fields exist
905
1017
  */
906
1018
  validateAgainstSchema(data, schema) {
907
- if (typeof data !== "object" || data === null) {
908
- return false;
909
- }
910
- const obj = data;
911
- if (schema.required) {
912
- for (const field of schema.required) {
913
- if (!(field in obj)) {
914
- return false;
1019
+ try {
1020
+ return validateJsonSchema(data, schema);
1021
+ } catch (e) {
1022
+ if (typeof data !== "object" || data === null) {
1023
+ return false;
1024
+ }
1025
+ const obj = data;
1026
+ if (schema.required) {
1027
+ for (const field of schema.required) {
1028
+ if (!(field in obj)) {
1029
+ return false;
1030
+ }
915
1031
  }
916
1032
  }
1033
+ return true;
917
1034
  }
918
- return true;
919
1035
  }
920
1036
  };
921
1037
 
@@ -1274,9 +1390,14 @@ function analyzeResume(input) {
1274
1390
  return {
1275
1391
  score: finalScore,
1276
1392
  breakdown: scoring.breakdown,
1393
+ matchedSkills: scoring.matchedSkills,
1394
+ missingSkills: scoring.missingSkills,
1277
1395
  matchedKeywords: scoring.matchedKeywords,
1278
1396
  missingKeywords: scoring.missingKeywords,
1279
1397
  overusedKeywords: scoring.overusedKeywords,
1398
+ experienceGap: scoring.experienceGap,
1399
+ detectedSections: parsedResume.detectedSections,
1400
+ parsedExperienceYears: parsedResume.totalExperienceYears,
1280
1401
  suggestions,
1281
1402
  warnings: [...suggestionResult.warnings, ...llmWarnings]
1282
1403
  };
@@ -1333,9 +1454,14 @@ async function analyzeResumeAsync(input) {
1333
1454
  return {
1334
1455
  score: finalScore,
1335
1456
  breakdown: scoring.breakdown,
1457
+ matchedSkills: scoring.matchedSkills,
1458
+ missingSkills: scoring.missingSkills,
1336
1459
  matchedKeywords: scoring.matchedKeywords,
1337
1460
  missingKeywords: scoring.missingKeywords,
1338
1461
  overusedKeywords: scoring.overusedKeywords,
1462
+ experienceGap: scoring.experienceGap,
1463
+ detectedSections: parsedResume.detectedSections,
1464
+ parsedExperienceYears: parsedResume.totalExperienceYears,
1339
1465
  suggestions,
1340
1466
  warnings: [...suggestionResult.warnings, ...llmWarnings]
1341
1467
  };