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