@juicedresume/mcp 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +414 -147
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -974,8 +974,67 @@ var SKILLS = [
974
974
  ];
975
975
  var SKILL_INDEX = /* @__PURE__ */ new Map();
976
976
  for (const s of SKILLS) SKILL_INDEX.set(s.name.toLowerCase(), s);
977
+ var SKILL_ALIASES = {
978
+ js: "JavaScript",
979
+ ecmascript: "JavaScript",
980
+ ts: "TypeScript",
981
+ "react.js": "React",
982
+ reactjs: "React",
983
+ "vue.js": "Vue",
984
+ vuejs: "Vue",
985
+ "next.js": "Next.js",
986
+ next: "Next.js",
987
+ nextjs: "Next.js",
988
+ "node.js": "Node.js",
989
+ node: "Node.js",
990
+ nodejs: "Node.js",
991
+ k8s: "Kubernetes",
992
+ postgres: "PostgreSQL",
993
+ psql: "PostgreSQL",
994
+ golang: "Go",
995
+ py: "Python",
996
+ "c sharp": "C#",
997
+ csharp: "C#",
998
+ "c plus plus": "C++",
999
+ cpp: "C++",
1000
+ tf: "Terraform",
1001
+ gha: "GitHub Actions",
1002
+ "rest api": "REST APIs",
1003
+ rest: "REST APIs",
1004
+ restful: "REST APIs",
1005
+ "objective c": "Objective-C",
1006
+ "spring-boot": "Spring Boot",
1007
+ "tailwind": "Tailwind CSS",
1008
+ tailwindcss: "Tailwind CSS",
1009
+ gcp: "GCP",
1010
+ "google cloud": "GCP",
1011
+ "google cloud platform": "GCP",
1012
+ aws: "AWS",
1013
+ "amazon web services": "AWS",
1014
+ sklearn: "scikit-learn",
1015
+ "scikit learn": "scikit-learn",
1016
+ hf: "Hugging Face",
1017
+ huggingface: "Hugging Face",
1018
+ pytorch: "PyTorch",
1019
+ tensorflow: "TensorFlow",
1020
+ "power-bi": "Power BI",
1021
+ powerbi: "Power BI"
1022
+ };
1023
+ function normalizeSkill(s) {
1024
+ const key = s.toLowerCase().trim();
1025
+ if (SKILL_INDEX.has(key)) return SKILL_INDEX.get(key).name;
1026
+ const stripped = key.replace(/\.js$/, "");
1027
+ if (SKILL_ALIASES[key]) return SKILL_ALIASES[key];
1028
+ if (SKILL_ALIASES[stripped]) return SKILL_ALIASES[stripped];
1029
+ if (SKILL_INDEX.has(stripped)) return SKILL_INDEX.get(stripped).name;
1030
+ return s.trim();
1031
+ }
977
1032
  function lookupSkill(s) {
978
- return SKILL_INDEX.get(s.toLowerCase().trim());
1033
+ const key = s.toLowerCase().trim();
1034
+ const direct = SKILL_INDEX.get(key);
1035
+ if (direct) return direct;
1036
+ const canonical = normalizeSkill(s).toLowerCase();
1037
+ return SKILL_INDEX.get(canonical);
979
1038
  }
980
1039
  function searchSkills(query, limit = 12) {
981
1040
  const q = query.toLowerCase().trim();
@@ -1344,6 +1403,9 @@ var STOPWORDS = /* @__PURE__ */ new Set([
1344
1403
  "day",
1345
1404
  "days"
1346
1405
  ]);
1406
+ function escapeRe(s) {
1407
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1408
+ }
1347
1409
  function tokens(s) {
1348
1410
  return s.toLowerCase().replace(/[^a-z0-9+./# -]/g, " ").split(/\s+/).filter((w) => w && w.length > 1 && !STOPWORDS.has(w));
1349
1411
  }
@@ -1353,22 +1415,49 @@ function bigrams(toks) {
1353
1415
  return out;
1354
1416
  }
1355
1417
  function resumeText(resume) {
1356
- const parts = [];
1357
- parts.push(resume.personal.title);
1418
+ const parts = [resume.personal.title];
1358
1419
  for (const sec of resume.sections) {
1420
+ if (sec.visible === false) continue;
1359
1421
  for (const it of sec.items) {
1360
- parts.push(it.body || "");
1361
- parts.push(it.description || "");
1362
- parts.push(it.skills || "");
1363
- parts.push(it.jobTitle || it.degree || it.name || it.title || "");
1364
- parts.push(it.employer || it.school || it.publisher || it.institution || "");
1422
+ if (it.visible === false) continue;
1423
+ parts.push(
1424
+ it.body || "",
1425
+ it.description || "",
1426
+ it.skills || "",
1427
+ it.category || "",
1428
+ it.jobTitle || "",
1429
+ it.degree || "",
1430
+ it.field || "",
1431
+ it.name || "",
1432
+ it.title || "",
1433
+ it.role || "",
1434
+ it.employer || "",
1435
+ it.school || "",
1436
+ it.publisher || "",
1437
+ it.institution || "",
1438
+ it.issuer || "",
1439
+ it.location || ""
1440
+ );
1365
1441
  }
1366
1442
  }
1367
1443
  return parts.join(" ").replace(/<[^>]+>/g, " ");
1368
1444
  }
1369
1445
  function tailorToJob(resume, jdText) {
1370
1446
  const jdToks = tokens(jdText);
1371
- const resumeToks = new Set(tokens(resumeText(resume)));
1447
+ const resumeFullText = resumeText(resume).toLowerCase();
1448
+ const resumeToks = new Set(tokens(resumeFullText));
1449
+ const resumeNorm = new Set([...resumeToks].map((t) => normalizeSkill(t).toLowerCase()));
1450
+ function matchKind(keyword) {
1451
+ const k = keyword.toLowerCase();
1452
+ if (k.includes(" ")) {
1453
+ return new RegExp(`\\b${escapeRe(k)}\\b`).test(resumeFullText) ? "exact" : "none";
1454
+ }
1455
+ if (resumeToks.has(k)) return "exact";
1456
+ const canon = normalizeSkill(keyword).toLowerCase();
1457
+ if (resumeNorm.has(canon)) return "variant";
1458
+ if (canon.includes(" ") && new RegExp(`\\b${escapeRe(canon)}\\b`).test(resumeFullText)) return "variant";
1459
+ return "none";
1460
+ }
1372
1461
  const jdGrams = /* @__PURE__ */ new Set([...jdToks, ...bigrams(jdToks)]);
1373
1462
  const skillNames = new Map(SKILLS.map((s) => [s.name.toLowerCase(), s]));
1374
1463
  const freq = /* @__PURE__ */ new Map();
@@ -1384,17 +1473,32 @@ function tailorToJob(resume, jdText) {
1384
1473
  for (const [k, c] of sortedNonSkill) jdSkills.set(k, { importance: c });
1385
1474
  const matched = [];
1386
1475
  const missing = [];
1476
+ const kindMult = (kind) => kind === "hard" ? 2 : kind === "soft" ? 1.3 : 1;
1477
+ const VARIANT_CREDIT = 0.6;
1387
1478
  for (const [k, meta] of jdSkills) {
1388
- const lower = k.toLowerCase();
1389
- if (resumeToks.has(lower) || [...resumeToks].some((t) => t.includes(lower))) {
1390
- matched.push({ keyword: k, kind: meta.kind, count: meta.importance });
1479
+ const w = meta.importance * kindMult(meta.kind);
1480
+ const kind = matchKind(k);
1481
+ if (kind === "exact") {
1482
+ matched.push({ keyword: k, kind: meta.kind, count: w });
1483
+ } else if (kind === "variant") {
1484
+ matched.push({
1485
+ keyword: k,
1486
+ kind: meta.kind,
1487
+ count: w * VARIANT_CREDIT,
1488
+ partial: true,
1489
+ note: `You use a variant of "${k}". ATS keyword search is usually exact-match, so add the exact term "${k}" too.`
1490
+ });
1391
1491
  } else {
1392
- missing.push({ keyword: k, kind: meta.kind, importance: meta.importance });
1492
+ missing.push({ keyword: k, kind: meta.kind, importance: w });
1393
1493
  }
1394
1494
  }
1395
1495
  const matchedWeight = matched.reduce((a, m) => a + m.count, 0);
1396
1496
  const totalWeight = matchedWeight + missing.reduce((a, m) => a + m.importance, 0);
1397
- const score = totalWeight === 0 ? 0 : Math.round(matchedWeight / totalWeight * 100);
1497
+ const score = totalWeight === 0 ? 0 : Math.min(95, Math.round(matchedWeight / totalWeight * 100));
1498
+ const titleToks = tokens(resume.personal.title || "");
1499
+ const jdTokSet = new Set(jdToks);
1500
+ const titleHits = titleToks.filter((t) => jdTokSet.has(t)).length;
1501
+ const titleMatch = titleToks.length === 0 ? 0 : Math.round(titleHits / titleToks.length * 100);
1398
1502
  missing.sort((a, b) => b.importance - a.importance);
1399
1503
  matched.sort((a, b) => b.count - a.count);
1400
1504
  return {
@@ -1402,15 +1506,78 @@ function tailorToJob(resume, jdText) {
1402
1506
  matched,
1403
1507
  missing,
1404
1508
  jdSkills: jdSkills.size,
1405
- resumeSkills: resumeToks.size
1509
+ resumeSkills: resumeToks.size,
1510
+ titleMatch,
1511
+ flags: jdKnockoutFlags(resume, jdText)
1406
1512
  };
1407
1513
  }
1514
+ function tailorMMYYYY(d) {
1515
+ if (!d) return null;
1516
+ const m = d.match(/^(\d{1,2})\/(\d{4})$/);
1517
+ if (m) return parseInt(m[2], 10) * 12 + parseInt(m[1], 10);
1518
+ const mn = d.match(/^([A-Za-z]+)\s+(\d{4})$/);
1519
+ if (mn) {
1520
+ const months = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
1521
+ const i = months.findIndex((x) => mn[1].toLowerCase().startsWith(x));
1522
+ if (i >= 0) return parseInt(mn[2], 10) * 12 + (i + 1);
1523
+ }
1524
+ const y = d.match(/^(\d{4})$/);
1525
+ if (y) return parseInt(y[1], 10) * 12;
1526
+ return null;
1527
+ }
1528
+ function resumeYears(resume) {
1529
+ const exp = resume.sections.find((s) => s.type === "experience");
1530
+ if (!exp) return 0;
1531
+ const now = (/* @__PURE__ */ new Date()).getFullYear() * 12 + ((/* @__PURE__ */ new Date()).getMonth() + 1);
1532
+ let months = 0;
1533
+ for (const it of exp.items || []) {
1534
+ if (it.visible === false) continue;
1535
+ const s = tailorMMYYYY(it.startDate || "");
1536
+ const e = it.current ? now : tailorMMYYYY(it.endDate || "");
1537
+ if (s && e && e >= s) months += e - s;
1538
+ }
1539
+ return Math.round(months / 12 * 10) / 10;
1540
+ }
1541
+ function jdKnockoutFlags(resume, jdText) {
1542
+ const flags = [];
1543
+ const degreeRequired = /\b(bachelor|master|b\.?s\.?|m\.?s\.?|b\.?a\.?|ph\.?d|mba|degree)\b[^.\n]{0,40}\b(required|require|must|minimum|mandatory)\b/i.test(jdText) || /\b(required|must have|minimum|mandatory)\b[^.\n]{0,40}\b(bachelor|master|degree|b\.?s\.?|m\.?s\.?|ph\.?d|diploma)\b/i.test(jdText);
1544
+ if (degreeRequired) {
1545
+ const eduSec = resume.sections.find((s) => s.type === "education");
1546
+ const hasDegree = (eduSec?.items || []).some((it) => it.visible !== false && String(it.degree || "").trim());
1547
+ if (!hasDegree) {
1548
+ flags.push({
1549
+ id: "K4.missing-required-degree",
1550
+ severity: "risk",
1551
+ title: "This job states a degree requirement",
1552
+ detail: "The description asks for a specific degree, and no completed degree is listed on your resume. A stated degree requirement is often a hard knockout filter.",
1553
+ suggestion: "Add your degree in the Education section, or if you have equivalent experience, be ready to address the gap in a cover note.",
1554
+ requirement: "degree"
1555
+ });
1556
+ }
1557
+ }
1558
+ const yoe = [...jdText.matchAll(/(\d{1,2})\s*\+?\s*(?:to|-|–|—)?\s*\d{0,2}\s*years?(?:['’]?s?)?\s*(?:of\s+)?(?:experience|exp\b)/gi)].map((m) => parseInt(m[1], 10)).filter((n) => n >= 1 && n <= 20);
1559
+ if (yoe.length) {
1560
+ const required = Math.min(...yoe);
1561
+ const have = resumeYears(resume);
1562
+ if (have + 0.5 < required) {
1563
+ flags.push({
1564
+ id: "K5.yoe-below-requirement",
1565
+ severity: "risk",
1566
+ title: `Asks for ${required}+ years; your resume shows about ${have}`,
1567
+ detail: "Years of experience is one of the most common automated and recruiter filters. A year or more below the stated minimum is a frequent screen-out.",
1568
+ suggestion: "Make sure every relevant role has dates so your full tenure is counted, and include earlier or concurrent experience if it applies.",
1569
+ requirement: `${required}+ years`
1570
+ });
1571
+ }
1572
+ }
1573
+ return flags;
1574
+ }
1408
1575
 
1409
1576
  // ../../packages/scoring/src/v2-additions.ts
1410
1577
  function stripHtml(s) {
1411
1578
  return (s || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
1412
1579
  }
1413
- function passSoft(id, label, score, message, suggestion) {
1580
+ function passSoft(id, label, score, message, suggestion, weight) {
1414
1581
  const s = Math.max(0, Math.min(1, score));
1415
1582
  return {
1416
1583
  id,
@@ -1419,12 +1586,10 @@ function passSoft(id, label, score, message, suggestion) {
1419
1586
  status: s >= 0.85 ? "pass" : s >= 0.55 ? "warn" : "fail",
1420
1587
  message,
1421
1588
  suggestion: suggestion ?? null,
1422
- severity: "soft"
1589
+ severity: "soft",
1590
+ weight: weight ?? 1
1423
1591
  };
1424
1592
  }
1425
- function hardCheck(id, label, score, message, suggestion) {
1426
- return { ...passSoft(id, label, score, message, suggestion), severity: "hard" };
1427
- }
1428
1593
  function firstBulletStrongest(resume) {
1429
1594
  const exp = resume.sections.find((s) => s.type === "experience");
1430
1595
  if (!exp?.items?.length) return null;
@@ -1447,42 +1612,8 @@ function firstBulletStrongest(resume) {
1447
1612
  "Lead bullet of recent role is strongest",
1448
1613
  score,
1449
1614
  topQuantified && topLength >= avgOtherLength ? "Top bullet of your most-recent role is quantified and substantive \u2014 recruiter eye lands here first." : "Your strongest bullet should be FIRST in your most-recent role (recruiters skim top-down).",
1450
- "Reorder bullets in your most-recent experience so the most quantified / highest-impact bullet is first."
1451
- )
1452
- };
1453
- }
1454
- function dateFormatConsistency(resume) {
1455
- const exp = resume.sections.find((s) => s.type === "experience");
1456
- const edu = resume.sections.find((s) => s.type === "education");
1457
- const dates = [];
1458
- [...exp?.items || [], ...edu?.items || []].forEach((it) => {
1459
- if (it.startDate) dates.push(it.startDate);
1460
- if (it.endDate && !it.current) dates.push(it.endDate);
1461
- });
1462
- if (dates.length < 2) return null;
1463
- const MM_YYYY = /^\d{1,2}\/\d{4}$/;
1464
- const MMM_YYYY = /^[A-Za-z]+\s+\d{4}$/;
1465
- const YYYY = /^\d{4}$/;
1466
- const ABBREV = /['']\d{2}|\d{1,2}\/\d{2}|\b[A-Z][a-z]{2}'\d{2}\b/;
1467
- const formats = /* @__PURE__ */ new Set();
1468
- for (const d of dates) {
1469
- if (ABBREV.test(d)) formats.add("abbrev");
1470
- else if (MM_YYYY.test(d)) formats.add("mm/yyyy");
1471
- else if (MMM_YYYY.test(d)) formats.add("month-yyyy");
1472
- else if (YYYY.test(d)) formats.add("yyyy");
1473
- else formats.add("other");
1474
- }
1475
- const hasAbbrev = formats.has("abbrev");
1476
- const inconsistent = formats.size > 1;
1477
- const score = hasAbbrev ? 0.2 : inconsistent ? 0.55 : 1;
1478
- return {
1479
- dim: "style",
1480
- check: passSoft(
1481
- "C9.date-format",
1482
- "Date format is consistent",
1483
- score,
1484
- hasAbbrev ? "Date abbreviations like '21 or Jan'24 break ATS years-of-experience calculation." : inconsistent ? `You're mixing date formats (${[...formats].join(", ")}). Pick one and apply consistently.` : "Dates are formatted consistently \u2014 ATS parses them cleanly.",
1485
- "Use MM/YYYY (06/2024) or Month YYYY (June 2024) \u2014 consistently across every entry."
1615
+ "Reorder bullets in your most-recent experience so the most quantified / highest-impact bullet is first.",
1616
+ 0.5
1486
1617
  )
1487
1618
  };
1488
1619
  }
@@ -1515,19 +1646,6 @@ function dobAgePenalty(resume) {
1515
1646
  )
1516
1647
  };
1517
1648
  }
1518
- function singleColumnHard(resume) {
1519
- const twoCol = isMultiColumnTemplate(resume.styling?.template);
1520
- return {
1521
- dim: "ats",
1522
- check: hardCheck(
1523
- "AT12.single-column-hard",
1524
- "Single-column layout (ATS-critical)",
1525
- twoCol ? 0 : 1,
1526
- twoCol ? "Two-column / sidebar template \u2014 a top cause of ATS parse failure. Workday can concatenate columns into gibberish. (The ATS-PDF export is always single-column, but the visual PDF is not.)" : "Single-column layout \u2014 every ATS parses this cleanly.",
1527
- "Switch to a single-column template (Jake, Harvard, Classic) in the Templates tab. Multi-column is a top score-destroyer in ATS systems."
1528
- )
1529
- };
1530
- }
1531
1649
  function threeLocationsRule(resume) {
1532
1650
  const summary = resume.sections.find((s) => s.type === "summary");
1533
1651
  const skills = resume.sections.find((s) => s.type === "skills");
@@ -1556,42 +1674,12 @@ function threeLocationsRule(resume) {
1556
1674
  )
1557
1675
  };
1558
1676
  }
1559
- function bulletLengthSweetSpot(resume) {
1560
- const exp = resume.sections.find((s) => s.type === "experience");
1561
- if (!exp?.items?.length) return null;
1562
- const bullets = [];
1563
- for (const it of exp.items) {
1564
- const html = it.description || "";
1565
- const lis = html.match(/<li[^>]*>([\s\S]*?)<\/li>/gi) || [];
1566
- for (const li of lis) bullets.push(stripHtml(li));
1567
- }
1568
- if (bullets.length === 0) return null;
1569
- let inRange = 0;
1570
- for (const b of bullets) {
1571
- const w = b.split(/\s+/).filter(Boolean).length;
1572
- if (w >= 12 && w <= 25) inRange++;
1573
- }
1574
- const ratio = inRange / bullets.length;
1575
- return {
1576
- dim: "brevity",
1577
- check: passSoft(
1578
- "B5.sweet-spot",
1579
- "Bullets in 12\u201325 word range",
1580
- ratio,
1581
- `${Math.round(ratio * 100)}% of your bullets fall in the 12\u201325 word sweet spot.`,
1582
- "Aim for bullets between 12 and 25 words \u2014 short enough for the 6-second recruiter scan, long enough to convey impact + metric."
1583
- )
1584
- };
1585
- }
1586
1677
  function additionalChecks(resume) {
1587
1678
  return [
1588
1679
  firstBulletStrongest(resume),
1589
- dateFormatConsistency(resume),
1590
1680
  targetTitle(resume),
1591
1681
  dobAgePenalty(resume),
1592
- singleColumnHard(resume),
1593
- threeLocationsRule(resume),
1594
- bulletLengthSweetSpot(resume)
1682
+ threeLocationsRule(resume)
1595
1683
  ].filter((x) => x !== null);
1596
1684
  }
1597
1685
 
@@ -2119,6 +2207,11 @@ function impactChecks(resume, b) {
2119
2207
  const scaleSignals = E.filter((x) => SCALE_RE.test(x.text));
2120
2208
  const timeBoundRe = /\b(within|in|over)\s+\d+\s*(weeks?|months?|quarters?|years?|days?)\b|\bq[1-4]\s?'?\d{2,4}\b/i;
2121
2209
  const timeBound = E.filter((x) => timeBoundRe.test(x.text));
2210
+ const quantifiedEarly = quantified.filter((x) => {
2211
+ const head = x.text.split(/\s+/).slice(0, 8).join(" ");
2212
+ return QUANT_RE.test(head);
2213
+ });
2214
+ const earlyRatio = quantified.length ? quantifiedEarly.length / quantified.length : 0;
2122
2215
  return [
2123
2216
  mk(
2124
2217
  "A1.quantification",
@@ -2126,15 +2219,20 @@ function impactChecks(resume, b) {
2126
2219
  qRatio >= 0.6 ? 1 : qRatio >= 0.4 ? 0.7 : qRatio / 0.4,
2127
2220
  `${quantified.length} of ${total} experience bullets include numbers, %, $, or counts (${Math.round(qRatio * 100)}%).`,
2128
2221
  nonQ.slice(0, 6).map((x) => x.id),
2129
- qRatio < 0.6 ? "Add concrete metrics: % change, $ amount, users, time saved." : void 0
2222
+ qRatio < 0.6 ? "Add concrete metrics: % change, $ amount, users, time saved." : void 0,
2223
+ void 0,
2224
+ 3
2130
2225
  ),
2131
2226
  mk(
2132
2227
  "A2.metric-diversity",
2133
2228
  "Variety of metric types",
2134
- Math.min(1, metricTypes.size / 3),
2229
+ // Bonus signal: absence is neutral (0.8), presence lifts toward 1.
2230
+ metricTypes.size === 0 ? 0.8 : Math.min(1, 0.8 + metricTypes.size * 0.1),
2135
2231
  metricTypes.size === 0 ? "No metric types detected (%, $, time, counts, ratios)." : `Uses ${metricTypes.size} metric type(s): ${[...metricTypes].join(", ")}.`,
2136
2232
  [],
2137
- metricTypes.size < 3 ? "Mix metric flavors: a % gain, a $ saving, and a scale number tells a richer story than three %s." : void 0
2233
+ metricTypes.size < 3 ? "Mix metric flavors: a % gain, a $ saving, and a scale number tells a richer story than three %s." : void 0,
2234
+ void 0,
2235
+ 0.5
2138
2236
  ),
2139
2237
  mk(
2140
2238
  "A3.xyz-bullets",
@@ -2142,7 +2240,9 @@ function impactChecks(resume, b) {
2142
2240
  Math.min(1, xyz.length / Math.max(1, total * 0.4)),
2143
2241
  `${xyz.length} of ${total} bullets read as "Accomplished X (measured by Y) by doing Z."`,
2144
2242
  [],
2145
- xyz.length / total < 0.4 ? "Reframe bullets as Action + Measurable Outcome + Method ('Cut LLM cost 35% by redesigning the prompt+cache layer')." : void 0
2243
+ xyz.length / total < 0.4 ? "Reframe bullets as Action + Measurable Outcome + Method ('Cut LLM cost 35% by redesigning the prompt+cache layer')." : void 0,
2244
+ void 0,
2245
+ 2
2146
2246
  ),
2147
2247
  mk(
2148
2248
  "A4.responsibility",
@@ -2158,7 +2258,9 @@ function impactChecks(resume, b) {
2158
2258
  strongStart.length / total,
2159
2259
  `${strongStart.length} of ${total} bullets open with a strong verb (Led, Built, Drove, \u2026).`,
2160
2260
  E.filter((x) => !STRONG_VERBS.has(norm(firstWord(x.text)))).slice(0, 5).map((x) => x.id),
2161
- strongStart.length / total < 0.85 ? "Replace soft openers with strong past-tense verbs." : void 0
2261
+ strongStart.length / total < 0.85 ? "Replace soft openers with strong past-tense verbs." : void 0,
2262
+ void 0,
2263
+ 2
2162
2264
  ),
2163
2265
  mk(
2164
2266
  "A6.weak-verbs",
@@ -2179,18 +2281,34 @@ function impactChecks(resume, b) {
2179
2281
  mk(
2180
2282
  "A8.scale-signals",
2181
2283
  "Mentions team size, budget, or org scale",
2182
- scaleSignals.length === 0 ? 0 : Math.min(1, scaleSignals.length / 2),
2183
- scaleSignals.length === 0 ? "No team/budget/scale signals detected." : `${scaleSignals.length} bullet(s) reference team size, budget, or organisation scale.`,
2284
+ // Bonus signal: absence is neutral, presence lifts the score.
2285
+ scaleSignals.length === 0 ? 0.8 : Math.min(1, 0.8 + scaleSignals.length * 0.1),
2286
+ scaleSignals.length === 0 ? "No team/budget/scale signals detected. A nice-to-have, not a deduction." : `${scaleSignals.length} bullet(s) reference team size, budget, or organisation scale.`,
2184
2287
  [],
2185
- scaleSignals.length === 0 ? "Add scale: 'Led a team of 6', 'Owned a $2M budget', '60+ engineers depend on this service.'" : void 0
2288
+ scaleSignals.length === 0 ? "Optional boost. Add scale: 'Led a team of 6', 'Owned a $2M budget', '60+ engineers depend on this service.'" : void 0,
2289
+ void 0,
2290
+ 0.5
2186
2291
  ),
2187
2292
  mk(
2188
2293
  "A9.time-bound",
2189
2294
  "Time-bound impact statements",
2190
- timeBound.length === 0 ? 0 : Math.min(1, timeBound.length / 2),
2191
- timeBound.length === 0 ? "No time-bound results ('within 6 months', 'in 2 quarters')." : `${timeBound.length} bullet(s) include a timeframe.`,
2295
+ // Bonus signal: absence is neutral, presence lifts the score.
2296
+ timeBound.length === 0 ? 0.8 : Math.min(1, 0.8 + timeBound.length * 0.1),
2297
+ timeBound.length === 0 ? "No time-bound results yet. A nice-to-have, not a deduction." : `${timeBound.length} bullet(s) include a timeframe.`,
2298
+ [],
2299
+ timeBound.length === 0 ? "Optional boost. Anchor results in time: 'within 6 months', 'in 12 weeks', 'over Q3 2025'." : void 0,
2300
+ void 0,
2301
+ 0.5
2302
+ ),
2303
+ mk(
2304
+ "A10.metric-position",
2305
+ "Metrics appear early in the bullet",
2306
+ quantified.length === 0 ? 0.8 : earlyRatio,
2307
+ quantified.length === 0 ? "No quantified bullets yet to position." : `${quantifiedEarly.length} of ${quantified.length} quantified bullets lead with the number.`,
2192
2308
  [],
2193
- timeBound.length === 0 ? "Anchor results in time: 'within 6 months', 'in 12 weeks', 'over Q3 2025'." : void 0
2309
+ quantified.length && earlyRatio < 0.6 ? "Move the number toward the front: 'Cut costs 35% by\u2026' scans faster than a metric buried at the end." : void 0,
2310
+ void 0,
2311
+ 1
2194
2312
  )
2195
2313
  ];
2196
2314
  }
@@ -2222,7 +2340,7 @@ function brevityChecks(resume, b) {
2222
2340
  return r.bullets > 5;
2223
2341
  });
2224
2342
  const pages = Math.max(1, Math.ceil(totalWords / 450));
2225
- const targetPages = experienceYears(resume) >= 10 ? 2 : 1;
2343
+ const targetPages = experienceYears(resume) < 5 ? 1 : 2;
2226
2344
  const lengthOK = pages <= targetPages;
2227
2345
  return [
2228
2346
  mk(
@@ -2255,7 +2373,7 @@ function brevityChecks(resume, b) {
2255
2373
  mk(
2256
2374
  "B4.page-length",
2257
2375
  `Resume length fits ${targetPages} page${targetPages > 1 ? "s" : ""}`,
2258
- lengthOK ? 1 : Math.max(0, 1 - (pages - targetPages) * 0.5),
2376
+ lengthOK ? 1 : Math.max(0, 1 - (pages - targetPages) * 0.35),
2259
2377
  `Estimated ${pages} page${pages > 1 ? "s" : ""} (${totalWords} words total).`,
2260
2378
  [],
2261
2379
  !lengthOK ? `Cut ~${(pages - targetPages) * 450} words to fit ${targetPages} page${targetPages > 1 ? "s" : ""}.` : void 0
@@ -2353,7 +2471,9 @@ function styleChecks(resume, b) {
2353
2471
  passive.length / total <= 0.15 ? 1 : 1 - (passive.length / total - 0.15) * 2,
2354
2472
  `${passive.length} of ${total} bullets read as passive voice (${Math.round(passive.length / total * 100)}%).`,
2355
2473
  passive.slice(0, 5).map((x) => x.id),
2356
- passive.length / total > 0.15 ? "Flip subject/object: 'The project was led by me' \u2192 'Led the project.'" : void 0
2474
+ passive.length / total > 0.15 ? "Flip subject/object: 'The project was led by me' \u2192 'Led the project.'" : void 0,
2475
+ void 0,
2476
+ 1.5
2357
2477
  ),
2358
2478
  mk(
2359
2479
  "C3.tense-consistency",
@@ -2393,7 +2513,9 @@ function styleChecks(resume, b) {
2393
2513
  periodConsistent ? 1 : 0.5,
2394
2514
  periodConsistent ? "All bullets agree on whether they end with a period." : `Mixed: ${endsWithPeriod} end with a period, ${endsWithout} don't.`,
2395
2515
  [],
2396
- !periodConsistent ? "Pick one rule for the whole resume." : void 0
2516
+ !periodConsistent ? "Pick one rule for the whole resume." : void 0,
2517
+ void 0,
2518
+ 0.4
2397
2519
  ),
2398
2520
  mk(
2399
2521
  "C8.punctuation-chars",
@@ -2401,7 +2523,9 @@ function styleChecks(resume, b) {
2401
2523
  punctConsistent ? 1 : 0.5,
2402
2524
  punctConsistent ? "Punctuation glyphs are consistent." : "Mixes smart quotes/em-dashes with ASCII equivalents.",
2403
2525
  [],
2404
- !punctConsistent ? 'Pick either smart quotes (\u201C\u201D) or straight quotes (") consistently.' : void 0
2526
+ !punctConsistent ? 'Pick either smart quotes (\u201C\u201D) or straight quotes (") consistently.' : void 0,
2527
+ void 0,
2528
+ 0.4
2405
2529
  ),
2406
2530
  mk(
2407
2531
  "C9.capitalization",
@@ -2409,7 +2533,9 @@ function styleChecks(resume, b) {
2409
2533
  capConsistent ? 1 : 0.5,
2410
2534
  capConsistent ? "Every bullet starts with the same case." : `${startsCap} of ${total} bullets start with a capital letter.`,
2411
2535
  [],
2412
- !capConsistent ? "Capitalize the first letter of every bullet." : void 0
2536
+ !capConsistent ? "Capitalize the first letter of every bullet." : void 0,
2537
+ void 0,
2538
+ 0.4
2413
2539
  ),
2414
2540
  mk(
2415
2541
  "C10.acronym-density",
@@ -2464,6 +2590,20 @@ function structureChecks(resume, b) {
2464
2590
  }
2465
2591
  const expRev = expSec ? rev(expSec.items || []) : true;
2466
2592
  const eduRev = eduSec ? rev(eduSec.items || []) : true;
2593
+ const nowMonths = (/* @__PURE__ */ new Date()).getFullYear() * 12 + ((/* @__PURE__ */ new Date()).getMonth() + 1);
2594
+ let maxGapMonths = 0;
2595
+ if (expSec) {
2596
+ const ranges = (expSec.items || []).map((it) => ({
2597
+ start: parseMMYYYY(it.startDate || ""),
2598
+ end: it.current ? nowMonths : parseMMYYYY(it.endDate || it.startDate || "")
2599
+ })).filter((r) => r.start && r.end);
2600
+ ranges.sort((a, b2) => b2.start - a.start);
2601
+ for (let i = 0; i < ranges.length - 1; i++) {
2602
+ const gap = ranges[i].start - ranges[i + 1].end;
2603
+ if (gap > maxGapMonths) maxGapMonths = gap;
2604
+ }
2605
+ }
2606
+ const gapOK = maxGapMonths <= 6;
2467
2607
  function isCanonical(type, title) {
2468
2608
  const t = title.toLowerCase().trim();
2469
2609
  const list = ATS_SECTION_LABELS[type] || [];
@@ -2533,6 +2673,16 @@ function structureChecks(resume, b) {
2533
2673
  expRev && eduRev ? "Both sections are reverse-chronological." : `${!expRev ? "Experience" : ""}${!expRev && !eduRev ? " & " : ""}${!eduRev ? "Education" : ""} out of order.`,
2534
2674
  [],
2535
2675
  !expRev || !eduRev ? "List most recent first." : void 0
2676
+ ),
2677
+ mk(
2678
+ "D9.employment-gaps",
2679
+ "No large unexplained employment gaps",
2680
+ gapOK ? 1 : 0.7,
2681
+ gapOK ? "No employment gap over 6 months between roles." : `A gap of about ${maxGapMonths} months sits between two roles. Some employers screen for gaps over 6 months.`,
2682
+ [],
2683
+ gapOK ? void 0 : "A gap over 6 months is fine, but consider a one-line note (contract work, study, caregiving) or a brief bridging entry so it does not read as unexplained.",
2684
+ void 0,
2685
+ 0.5
2536
2686
  )
2537
2687
  ];
2538
2688
  }
@@ -2542,12 +2692,16 @@ function atsChecks(resume, b) {
2542
2692
  const allText = b.bullets.map((x) => x.text).join(" ");
2543
2693
  const isSingleCol = !isMultiColumnTemplate(s.template);
2544
2694
  const isSafeFont = SAFE_FONTS.has(s.fontHeading) && SAFE_FONTS.has(s.fontBody);
2545
- const fontSizeOK = s.fontSize >= 9.5 && s.fontSize <= 12;
2695
+ const fontSizeIdeal = s.fontSize >= 10 && s.fontSize <= 12;
2696
+ const fontSizeAcceptable = s.fontSize >= 9.5 && s.fontSize <= 12.5;
2546
2697
  const safeBullet = SAFE_BULLET_GLYPHS.has(s.bulletGlyph === "disc" ? "\u2022" : s.bulletGlyph === "dash" ? "-" : "");
2547
2698
  const emailOK = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(p.email);
2548
2699
  const phoneOK = !p.phone || /(\+?\d[\d\s().-]{6,})/.test(p.phone);
2549
2700
  const hasLinkedIn = !!(p.linkedin && /linkedin\.com\/in\//i.test(p.linkedin));
2550
2701
  const locationOK = !!p.location && /,/.test(p.location);
2702
+ const nameOK = !!(p.fullName && p.fullName.trim().length >= 2);
2703
+ const hasPhone = !!(p.phone && /(\+?\d[\d\s().-]{6,})/.test(p.phone));
2704
+ const contactParseable = nameOK && (emailOK || hasPhone);
2551
2705
  const allDates = [];
2552
2706
  for (const sec of resume.sections) for (const it of sec.items || []) {
2553
2707
  if (it.startDate) allDates.push(it.startDate);
@@ -2575,19 +2729,33 @@ function atsChecks(resume, b) {
2575
2729
  return [
2576
2730
  mk(
2577
2731
  "E1.single-column",
2578
- "Single-column layout (ATS-critical)",
2579
- isSingleCol ? 1 : 0,
2580
- isSingleCol ? "Layout is single-column." : "Multi-column layout \u2014 many ATS parsers scramble reading order.",
2732
+ "Single-column layout (ATS-friendly)",
2733
+ // Proportional soft penalty, not an auto-fail. Modern parsers handle
2734
+ // columns (Textkernel reports ~90% correct column extraction vs ~97%
2735
+ // single-column), but ~1 in 10 still scrambles reading order and columns
2736
+ // cost human skimmability. A single, evidence-scaled ding — the overall
2737
+ // score also takes a small proportional multi-column haircut (see below).
2738
+ // ~0.85 reflects the real ~90% correct column-extraction rate reported by
2739
+ // Textkernel, so the within-dimension ding stays light; the small overall
2740
+ // multi-column haircut in scoreResume carries the actual score effect
2741
+ // (we deliberately don't double-penalize with a 0.55 here).
2742
+ isSingleCol ? 1 : 0.85,
2743
+ isSingleCol ? "Single-column layout. Every ATS parses this cleanly." : "Multi-column template. Modern parsers handle columns about 90% of the time, but a minority scramble reading order, and columns are harder for a recruiter to skim.",
2581
2744
  [],
2582
- !isSingleCol ? "Pick a single-column template (e.g. Jake, Harvard, Classic) in the Templates tab." : void 0,
2583
- !isSingleCol ? "hard" : "soft"
2745
+ !isSingleCol ? "Exporting via the ATS PDF button always outputs a single-column, parse-clean file, so you can keep this template on screen and still submit a safe PDF. Or switch to a single-column template (Jake, Harvard, Classic) for a single source." : void 0,
2746
+ "soft"
2584
2747
  ),
2585
2748
  mk(
2586
- "E2.contact-in-body",
2587
- "Contact info rendered in body, not header/footer",
2588
- 1,
2589
- "Contact info is rendered in the document body in this app.",
2590
- []
2749
+ "E2.contact-parseable",
2750
+ "Name + contact details are parseable",
2751
+ // Hard dependency: parsers build the candidate record from a name plus at
2752
+ // least one of email/phone (Daxtra: without one of these the parse fails).
2753
+ contactParseable ? 1 : 0,
2754
+ contactParseable ? "Full name and at least one contact channel (email or phone) are present." : !nameOK ? "No full name detected. Parsers need a name plus an email or phone to create a candidate record." : "No email or phone detected. Most parsers reject a record that has neither.",
2755
+ [],
2756
+ !contactParseable ? "Add your full name and at least one of email or phone. Without these, many ATS drop the whole resume." : void 0,
2757
+ contactParseable ? "soft" : "hard",
2758
+ 2
2591
2759
  ),
2592
2760
  mk(
2593
2761
  "E3.email-valid",
@@ -2620,7 +2788,9 @@ function atsChecks(resume, b) {
2620
2788
  hasLinkedIn ? 1 : 0,
2621
2789
  hasLinkedIn ? "LinkedIn URL detected." : "LinkedIn URL missing or non-canonical.",
2622
2790
  [],
2623
- !hasLinkedIn ? "Add a linkedin.com/in/your-handle URL." : void 0
2791
+ !hasLinkedIn ? "Add a linkedin.com/in/your-handle URL. Resumes with one see materially higher recruiter response." : void 0,
2792
+ void 0,
2793
+ 1.5
2624
2794
  ),
2625
2795
  mk(
2626
2796
  "E7.date-format",
@@ -2644,7 +2814,9 @@ function atsChecks(resume, b) {
2644
2814
  safeBullet ? 1 : 0.6,
2645
2815
  safeBullet ? "Bullet glyph is ATS-safe." : `Bullet glyph '${s.bulletGlyph}' may not survive every ATS parser.`,
2646
2816
  [],
2647
- !safeBullet ? "Switch bullet glyph to disc, dash, or square (Customize \u2192 Layout \u2192 bullet glyph)." : void 0
2817
+ !safeBullet ? "Switch bullet glyph to disc, dash, or square (Customize \u2192 Layout \u2192 bullet glyph)." : void 0,
2818
+ void 0,
2819
+ 0.5
2648
2820
  ),
2649
2821
  mk(
2650
2822
  "E10.font-safe",
@@ -2652,15 +2824,19 @@ function atsChecks(resume, b) {
2652
2824
  isSafeFont ? 1 : 0.5,
2653
2825
  isSafeFont ? "Fonts are ATS-safe." : `Heading '${s.fontHeading}' or body '${s.fontBody}' may not be embedded by some PDF renderers.`,
2654
2826
  [],
2655
- !isSafeFont ? "Use Arial, Calibri, Helvetica, Garamond, or one of the safe defaults." : void 0
2827
+ !isSafeFont ? "Use Arial, Calibri, Helvetica, Garamond, or one of the safe defaults." : void 0,
2828
+ void 0,
2829
+ 0.5
2656
2830
  ),
2657
2831
  mk(
2658
2832
  "E11.font-size",
2659
2833
  "Body font size 10\u201312pt",
2660
- fontSizeOK ? 1 : 0.5,
2661
- fontSizeOK ? `Font size ${s.fontSize}pt is in range.` : `Font size ${s.fontSize}pt is outside the 10\u201312pt range.`,
2834
+ fontSizeIdeal ? 1 : fontSizeAcceptable ? 0.75 : 0.5,
2835
+ fontSizeIdeal ? `Font size ${s.fontSize}pt is in the ideal 10\u201312pt band.` : fontSizeAcceptable ? `Font size ${s.fontSize}pt is readable but outside the ideal 10\u201312pt band.` : `Font size ${s.fontSize}pt is outside the readable 9.5\u201312.5pt range.`,
2662
2836
  [],
2663
- !fontSizeOK ? "Set body size to 10\u201312pt." : void 0
2837
+ !fontSizeIdeal ? "Set body size to 10\u201312pt for the best balance of density and legibility." : void 0,
2838
+ void 0,
2839
+ 0.5
2664
2840
  ),
2665
2841
  mk(
2666
2842
  "E12.no-killer-chars",
@@ -2685,7 +2861,7 @@ function skillsChecks(resume, b) {
2685
2861
  if (t.length < 3) continue;
2686
2862
  counts.set(t, (counts.get(t) || 0) + 1);
2687
2863
  }
2688
- const stuffed = [...counts.entries()].filter(([k, v]) => v > 6 && /^[a-z]+$/.test(k) && !["with", "from", "into", "over", "under", "across", "by", "to", "for", "the", "and", "of", "on", "in"].includes(k));
2864
+ const stuffed = [...counts.entries()].filter(([k, v]) => v > 8 && /^[a-z]+$/.test(k) && !["with", "from", "into", "over", "under", "across", "by", "to", "for", "the", "and", "of", "on", "in", "a", "an", "that", "this", "was", "were", "are", "have", "has"].includes(k));
2689
2865
  const allText = b.bullets.map((x) => x.text).join(" ");
2690
2866
  const acronyms = [...new Set(Array.from(allText.matchAll(/\b([A-Z]{2,5})\b/g)).map((m) => m[1]))].slice(0, 5);
2691
2867
  const expanded = acronyms.filter((a) => new RegExp(`\\b${a}\\s*\\(`).test(allText));
@@ -2720,7 +2896,7 @@ function skillsChecks(resume, b) {
2720
2896
  stuffed.length === 0 ? 1 : 1 - Math.min(1, stuffed.length / 3),
2721
2897
  stuffed.length === 0 ? "No token over-repeated." : `Over-repeated: ${stuffed.slice(0, 4).map(([k, v]) => `${k}\xD7${v}`).join(", ")}.`,
2722
2898
  [],
2723
- stuffed.length ? "Diversify language \u2014 repeating a keyword more than 5\xD7 looks like stuffing." : void 0
2899
+ stuffed.length ? "Diversify your language. Repeating the same keyword more than 8 times reads as stuffing, and frequency past the first mention does not help ATS ranking anyway." : void 0
2724
2900
  ),
2725
2901
  mk(
2726
2902
  "F5.acronym-expansion",
@@ -2728,7 +2904,9 @@ function skillsChecks(resume, b) {
2728
2904
  acronyms.length === 0 ? 1 : expanded.length / acronyms.length,
2729
2905
  acronyms.length === 0 ? "No acronyms detected." : `${expanded.length} of ${acronyms.length} top acronyms have an inline expansion.`,
2730
2906
  [],
2731
- acronyms.length > 0 && expanded.length < acronyms.length ? `Expand at least once: e.g. '${acronyms[0]} (\u2026)'.` : void 0
2907
+ acronyms.length > 0 && expanded.length < acronyms.length ? `Expand at least once: e.g. '${acronyms[0]} (\u2026)'. Recruiter search is exact-match, so 'ML (Machine Learning)' catches both queries.` : void 0,
2908
+ void 0,
2909
+ 0.5
2732
2910
  )
2733
2911
  ];
2734
2912
  }
@@ -2743,6 +2921,10 @@ function polishChecks(resume, b) {
2743
2921
  const linkedinGeneric = p.linkedin && /\/in\/[a-z0-9]{8,}-[a-z0-9]{4,}/i.test(p.linkedin) && !/[a-zA-Z]/.test(p.linkedin.split("/in/")[1]?.split(/[-?#]/)[0] || "");
2744
2922
  const hasCustomWebsite = !!p.website && !/(linkedin\.com|github\.com|facebook\.com|twitter\.com|x\.com)/i.test(p.website);
2745
2923
  const hasHeadline = !!p.title;
2924
+ const rawHtml = resume.sections.flatMap((sec) => (sec.items || []).flatMap((it) => [it.description, it.body].filter(Boolean))).join(" ");
2925
+ const HIDDEN_STYLE_RE = /color\s*:\s*(#fff(fff)?|#ffffff|white|rgba?\(\s*255\s*,\s*255\s*,\s*255)|font-size\s*:\s*0|opacity\s*:\s*0|visibility\s*:\s*hidden|display\s*:\s*none/i;
2926
+ const PROMPT_INJECT_RE = /\b(ignore (all )?(the )?previous|as an ai|you are an ai|rate (this|the|me|my) (resume|candidate|application) (highly|as|a perfect|10)|you must (rate|score|recommend)|system prompt|disregard (the )?instructions|hiring manager must)\b/i;
2927
+ const hasHiddenText = HIDDEN_STYLE_RE.test(rawHtml) || PROMPT_INJECT_RE.test(stripHtml2(rawHtml).toLowerCase());
2746
2928
  return [
2747
2929
  mk(
2748
2930
  "G1.no-placeholders",
@@ -2787,14 +2969,28 @@ function polishChecks(resume, b) {
2787
2969
  mk(
2788
2970
  "G6.custom-portfolio",
2789
2971
  "Custom portfolio or personal site (nice-to-have)",
2790
- hasCustomWebsite ? 1 : 0.6,
2791
- hasCustomWebsite ? "Custom site present." : "No personal site listed.",
2972
+ hasCustomWebsite ? 1 : 0.9,
2973
+ hasCustomWebsite ? "Custom site present." : "No personal site listed. Optional, not a deduction.",
2974
+ [],
2975
+ hasCustomWebsite ? void 0 : "A custom domain (yourname.dev) lifts a senior resume \u2014 optional.",
2976
+ void 0,
2977
+ 0.5
2978
+ ),
2979
+ mk(
2980
+ "G8.hidden-text",
2981
+ "No hidden text or keyword tricks",
2982
+ hasHiddenText ? 0 : 1,
2983
+ hasHiddenText ? "Hidden or invisible text (white-on-white, zero-size) or an AI-instruction string was detected. ATS convert your file to plain text, so these become visible and read as gaming the system." : "No hidden text or prompt-injection tricks detected.",
2792
2984
  [],
2793
- hasCustomWebsite ? void 0 : "A custom domain (yourname.dev) lifts a senior resume \u2014 optional."
2985
+ hasHiddenText ? "Remove any white/invisible text, zero-size fonts, or 'rate me highly'-style instructions. They backfire once the ATS strips formatting." : void 0,
2986
+ // Hard severity: hidden text / prompt-injection is the one trick that
2987
+ // genuinely backfires, so a positive detection caps the overall score.
2988
+ "hard",
2989
+ 2
2794
2990
  )
2795
2991
  ];
2796
2992
  }
2797
- function mk(id, label, score, message, evidence, suggestion, severity) {
2993
+ function mk(id, label, score, message, evidence, suggestion, severity, weight) {
2798
2994
  const s = Math.max(0, Math.min(1, score));
2799
2995
  return {
2800
2996
  id,
@@ -2804,7 +3000,8 @@ function mk(id, label, score, message, evidence, suggestion, severity) {
2804
3000
  message,
2805
3001
  evidence: evidence ?? [],
2806
3002
  suggestion: suggestion ?? null,
2807
- severity: severity || "soft"
3003
+ severity: severity || "soft",
3004
+ weight: weight ?? 1
2808
3005
  };
2809
3006
  }
2810
3007
  function parseMMYYYY(d) {
@@ -2832,11 +3029,62 @@ function experienceYears(resume) {
2832
3029
  }
2833
3030
  return Math.floor(months / 12);
2834
3031
  }
3032
+ function computeFlags(resume) {
3033
+ const flags = [];
3034
+ const p = resume.personal;
3035
+ const emailOK = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(p.email);
3036
+ const hasPhone = !!(p.phone && /(\+?\d[\d\s().-]{6,})/.test(p.phone));
3037
+ const nameOK = !!(p.fullName && p.fullName.trim().length >= 2);
3038
+ if (!(nameOK && (emailOK || hasPhone))) {
3039
+ flags.push({
3040
+ id: "K1.contact-unparseable",
3041
+ severity: "block",
3042
+ title: "Contact details can't be parsed",
3043
+ detail: !nameOK ? "No full name is set. ATS build the candidate record from a name plus an email or phone, so without a name the whole resume can be dropped." : "No valid email or phone is set. Most parsers reject a record that has neither.",
3044
+ suggestion: "Add your full name and at least one of a valid email or phone."
3045
+ });
3046
+ }
3047
+ const expSec = resume.sections.find((s) => s.type === "experience");
3048
+ const expItems = (expSec?.items || []).filter((it) => it.visible !== false);
3049
+ const datelessRoles = expItems.filter((it) => !parseMMYYYY(it.startDate || ""));
3050
+ if (expItems.length > 0 && datelessRoles.length > 0) {
3051
+ flags.push({
3052
+ id: "K2.role-without-dates",
3053
+ severity: "risk",
3054
+ title: `${datelessRoles.length} role${datelessRoles.length === 1 ? "" : "s"} missing dates`,
3055
+ detail: "A role with no start date can't be ordered or counted toward years of experience by a parser, and years-of-experience filters are a top recruiter screen.",
3056
+ suggestion: "Add a start and end date (MM/YYYY or Month YYYY) to every role."
3057
+ });
3058
+ }
3059
+ const nowMonths = (/* @__PURE__ */ new Date()).getFullYear() * 12 + ((/* @__PURE__ */ new Date()).getMonth() + 1);
3060
+ const ranges = expItems.map((it) => ({ start: parseMMYYYY(it.startDate || ""), end: it.current ? nowMonths : parseMMYYYY(it.endDate || it.startDate || "") })).filter((r) => r.start && r.end);
3061
+ ranges.sort((a, b) => b.start - a.start);
3062
+ let maxGap = 0;
3063
+ for (let i = 0; i < ranges.length - 1; i++) {
3064
+ const gap = ranges[i].start - ranges[i + 1].end;
3065
+ if (gap > maxGap) maxGap = gap;
3066
+ }
3067
+ if (maxGap > 6) {
3068
+ flags.push({
3069
+ id: "K3.employment-gap",
3070
+ severity: "risk",
3071
+ title: `About a ${maxGap}-month employment gap`,
3072
+ detail: "Some employers screen for gaps over 6 months. A gap is legitimate, but an unexplained one lowers callback odds.",
3073
+ suggestion: "Add a one-line note (contract, study, caregiving) or a short bridging entry so the gap does not read as unexplained."
3074
+ });
3075
+ }
3076
+ return flags;
3077
+ }
2835
3078
  function aggregate(checks, weight) {
2836
3079
  if (!checks.length) return { score: 0, weighted: 0 };
2837
- const sum = checks.reduce((a, c) => a + c.score, 0);
2838
- const score = Math.round(sum / checks.length * 100);
2839
- return { score, weighted: sum / checks.length * weight };
3080
+ let wsum = 0, wtot = 0;
3081
+ for (const c of checks) {
3082
+ const w = c.weight ?? 1;
3083
+ wsum += c.score * w;
3084
+ wtot += w;
3085
+ }
3086
+ const mean = wtot ? wsum / wtot : 0;
3087
+ return { score: Math.round(mean * 100), weighted: mean * weight };
2840
3088
  }
2841
3089
  var WEIGHTS = {
2842
3090
  impact: 22,
@@ -2856,7 +3104,7 @@ var LABELS = {
2856
3104
  skills: "Skills & Keywords",
2857
3105
  polish: "Completeness & Polish"
2858
3106
  };
2859
- function scoreResume(resume) {
3107
+ function scoreResume(resume, opts) {
2860
3108
  const b = gather(resume);
2861
3109
  const dims = [
2862
3110
  { dim: "impact", checks: impactChecks(resume, b) },
@@ -2886,12 +3134,30 @@ function scoreResume(resume) {
2886
3134
  };
2887
3135
  });
2888
3136
  let overall = Math.round(weightedSum / totalWeight * 100);
3137
+ if (isMultiColumnTemplate(resume.styling.template)) {
3138
+ overall = Math.round(overall * 0.93);
3139
+ }
2889
3140
  const hardFail = dimensions.some((d) => d.checks.some((c) => c.severity === "hard" && c.status === "fail"));
2890
3141
  if (hardFail) overall = Math.min(overall, 60);
3142
+ const parseFindings = opts?.parseFindings ?? [];
3143
+ const parseHard = parseFindings.some((f) => f.severity === "hard");
3144
+ const parseSoftCount = parseFindings.filter((f) => f.severity === "soft").length;
3145
+ if (parseHard) overall = Math.min(overall, 55);
3146
+ if (parseSoftCount) overall = Math.max(0, overall - Math.min(12, parseSoftCount * 4));
3147
+ const flags = [
3148
+ ...computeFlags(resume),
3149
+ ...parseFindings.map((f) => ({
3150
+ id: f.id,
3151
+ severity: f.severity === "hard" ? "block" : "risk",
3152
+ title: f.title,
3153
+ detail: f.detail,
3154
+ suggestion: f.suggestion ?? null
3155
+ }))
3156
+ ];
2891
3157
  const sortedDims = [...dimensions].sort((a, b2) => a.score - b2.score);
2892
3158
  const worst = sortedDims[0];
2893
3159
  const best = sortedDims[sortedDims.length - 1];
2894
- const allFails = dimensions.flatMap((d) => d.checks.map((c) => ({ ...c, dim: d.dimension, dimLabel: d.label, weight: d.weight }))).filter((c) => c.suggestion && c.status !== "pass").sort((a, b2) => a.score - b2.score);
3160
+ const allFails = dimensions.flatMap((d) => d.checks.map((c) => ({ ...c, dim: d.dimension, dimLabel: d.label, weight: d.weight }))).filter((c) => c.suggestion && c.status !== "pass").sort((a, b2) => (1 - b2.score) * b2.weight - (1 - a.score) * a.weight);
2895
3161
  const priorities = allFails.slice(0, 5);
2896
3162
  return {
2897
3163
  overall,
@@ -2900,6 +3166,7 @@ function scoreResume(resume) {
2900
3166
  summary: `Strongest: ${best.label} (${best.score}). Focus area: ${worst.label} (${worst.score}).`,
2901
3167
  priorities,
2902
3168
  hardFails: dimensions.flatMap((d) => d.checks.filter((c) => c.severity === "hard" && c.status === "fail").map((c) => ({ ...c, dim: d.dimension }))),
3169
+ flags,
2903
3170
  stats: {
2904
3171
  totalBullets: b.bullets.length,
2905
3172
  experienceBullets: b.expBullets.length,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juicedresume/mcp",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Model Context Protocol server for JuicedResume — score, tailor, and edit your resume from any MCP client (Claude Code, Claude Desktop, Cursor).",
5
5
  "keywords": [
6
6
  "mcp",