@juicedresume/mcp 0.3.1 → 0.3.2

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 +247 -99
  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
  }
@@ -1368,7 +1430,20 @@ function resumeText(resume) {
1368
1430
  }
1369
1431
  function tailorToJob(resume, jdText) {
1370
1432
  const jdToks = tokens(jdText);
1371
- const resumeToks = new Set(tokens(resumeText(resume)));
1433
+ const resumeFullText = resumeText(resume).toLowerCase();
1434
+ const resumeToks = new Set(tokens(resumeFullText));
1435
+ const resumeNorm = new Set([...resumeToks].map((t) => normalizeSkill(t).toLowerCase()));
1436
+ function resumeHas(keyword) {
1437
+ const k = keyword.toLowerCase();
1438
+ if (k.includes(" ")) {
1439
+ return new RegExp(`\\b${escapeRe(k)}\\b`).test(resumeFullText);
1440
+ }
1441
+ if (resumeToks.has(k)) return true;
1442
+ const canon = normalizeSkill(keyword).toLowerCase();
1443
+ if (resumeNorm.has(canon)) return true;
1444
+ if (canon.includes(" ") && new RegExp(`\\b${escapeRe(canon)}\\b`).test(resumeFullText)) return true;
1445
+ return false;
1446
+ }
1372
1447
  const jdGrams = /* @__PURE__ */ new Set([...jdToks, ...bigrams(jdToks)]);
1373
1448
  const skillNames = new Map(SKILLS.map((s) => [s.name.toLowerCase(), s]));
1374
1449
  const freq = /* @__PURE__ */ new Map();
@@ -1384,17 +1459,22 @@ function tailorToJob(resume, jdText) {
1384
1459
  for (const [k, c] of sortedNonSkill) jdSkills.set(k, { importance: c });
1385
1460
  const matched = [];
1386
1461
  const missing = [];
1462
+ const kindMult = (kind) => kind === "hard" ? 2 : kind === "soft" ? 1.3 : 1;
1387
1463
  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 });
1464
+ const w = meta.importance * kindMult(meta.kind);
1465
+ if (resumeHas(k)) {
1466
+ matched.push({ keyword: k, kind: meta.kind, count: w });
1391
1467
  } else {
1392
- missing.push({ keyword: k, kind: meta.kind, importance: meta.importance });
1468
+ missing.push({ keyword: k, kind: meta.kind, importance: w });
1393
1469
  }
1394
1470
  }
1395
1471
  const matchedWeight = matched.reduce((a, m) => a + m.count, 0);
1396
1472
  const totalWeight = matchedWeight + missing.reduce((a, m) => a + m.importance, 0);
1397
- const score = totalWeight === 0 ? 0 : Math.round(matchedWeight / totalWeight * 100);
1473
+ const score = totalWeight === 0 ? 0 : Math.min(95, Math.round(matchedWeight / totalWeight * 100));
1474
+ const titleToks = tokens(resume.personal.title || "");
1475
+ const jdTokSet = new Set(jdToks);
1476
+ const titleHits = titleToks.filter((t) => jdTokSet.has(t)).length;
1477
+ const titleMatch = titleToks.length === 0 ? 0 : Math.round(titleHits / titleToks.length * 100);
1398
1478
  missing.sort((a, b) => b.importance - a.importance);
1399
1479
  matched.sort((a, b) => b.count - a.count);
1400
1480
  return {
@@ -1402,7 +1482,8 @@ function tailorToJob(resume, jdText) {
1402
1482
  matched,
1403
1483
  missing,
1404
1484
  jdSkills: jdSkills.size,
1405
- resumeSkills: resumeToks.size
1485
+ resumeSkills: resumeToks.size,
1486
+ titleMatch
1406
1487
  };
1407
1488
  }
1408
1489
 
@@ -1410,7 +1491,7 @@ function tailorToJob(resume, jdText) {
1410
1491
  function stripHtml(s) {
1411
1492
  return (s || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
1412
1493
  }
1413
- function passSoft(id, label, score, message, suggestion) {
1494
+ function passSoft(id, label, score, message, suggestion, weight) {
1414
1495
  const s = Math.max(0, Math.min(1, score));
1415
1496
  return {
1416
1497
  id,
@@ -1419,12 +1500,10 @@ function passSoft(id, label, score, message, suggestion) {
1419
1500
  status: s >= 0.85 ? "pass" : s >= 0.55 ? "warn" : "fail",
1420
1501
  message,
1421
1502
  suggestion: suggestion ?? null,
1422
- severity: "soft"
1503
+ severity: "soft",
1504
+ weight: weight ?? 1
1423
1505
  };
1424
1506
  }
1425
- function hardCheck(id, label, score, message, suggestion) {
1426
- return { ...passSoft(id, label, score, message, suggestion), severity: "hard" };
1427
- }
1428
1507
  function firstBulletStrongest(resume) {
1429
1508
  const exp = resume.sections.find((s) => s.type === "experience");
1430
1509
  if (!exp?.items?.length) return null;
@@ -1447,7 +1526,8 @@ function firstBulletStrongest(resume) {
1447
1526
  "Lead bullet of recent role is strongest",
1448
1527
  score,
1449
1528
  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."
1529
+ "Reorder bullets in your most-recent experience so the most quantified / highest-impact bullet is first.",
1530
+ 0.5
1451
1531
  )
1452
1532
  };
1453
1533
  }
@@ -1515,19 +1595,6 @@ function dobAgePenalty(resume) {
1515
1595
  )
1516
1596
  };
1517
1597
  }
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
1598
  function threeLocationsRule(resume) {
1532
1599
  const summary = resume.sections.find((s) => s.type === "summary");
1533
1600
  const skills = resume.sections.find((s) => s.type === "skills");
@@ -1556,42 +1623,13 @@ function threeLocationsRule(resume) {
1556
1623
  )
1557
1624
  };
1558
1625
  }
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
1626
  function additionalChecks(resume) {
1587
1627
  return [
1588
1628
  firstBulletStrongest(resume),
1589
1629
  dateFormatConsistency(resume),
1590
1630
  targetTitle(resume),
1591
1631
  dobAgePenalty(resume),
1592
- singleColumnHard(resume),
1593
- threeLocationsRule(resume),
1594
- bulletLengthSweetSpot(resume)
1632
+ threeLocationsRule(resume)
1595
1633
  ].filter((x) => x !== null);
1596
1634
  }
1597
1635
 
@@ -2119,6 +2157,11 @@ function impactChecks(resume, b) {
2119
2157
  const scaleSignals = E.filter((x) => SCALE_RE.test(x.text));
2120
2158
  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
2159
  const timeBound = E.filter((x) => timeBoundRe.test(x.text));
2160
+ const quantifiedEarly = quantified.filter((x) => {
2161
+ const head = x.text.split(/\s+/).slice(0, 8).join(" ");
2162
+ return QUANT_RE.test(head);
2163
+ });
2164
+ const earlyRatio = quantified.length ? quantifiedEarly.length / quantified.length : 0;
2122
2165
  return [
2123
2166
  mk(
2124
2167
  "A1.quantification",
@@ -2126,15 +2169,20 @@ function impactChecks(resume, b) {
2126
2169
  qRatio >= 0.6 ? 1 : qRatio >= 0.4 ? 0.7 : qRatio / 0.4,
2127
2170
  `${quantified.length} of ${total} experience bullets include numbers, %, $, or counts (${Math.round(qRatio * 100)}%).`,
2128
2171
  nonQ.slice(0, 6).map((x) => x.id),
2129
- qRatio < 0.6 ? "Add concrete metrics: % change, $ amount, users, time saved." : void 0
2172
+ qRatio < 0.6 ? "Add concrete metrics: % change, $ amount, users, time saved." : void 0,
2173
+ void 0,
2174
+ 3
2130
2175
  ),
2131
2176
  mk(
2132
2177
  "A2.metric-diversity",
2133
2178
  "Variety of metric types",
2134
- Math.min(1, metricTypes.size / 3),
2179
+ // Bonus signal: absence is neutral (0.8), presence lifts toward 1.
2180
+ metricTypes.size === 0 ? 0.8 : Math.min(1, 0.8 + metricTypes.size * 0.1),
2135
2181
  metricTypes.size === 0 ? "No metric types detected (%, $, time, counts, ratios)." : `Uses ${metricTypes.size} metric type(s): ${[...metricTypes].join(", ")}.`,
2136
2182
  [],
2137
- metricTypes.size < 3 ? "Mix metric flavors: a % gain, a $ saving, and a scale number tells a richer story than three %s." : void 0
2183
+ metricTypes.size < 3 ? "Mix metric flavors: a % gain, a $ saving, and a scale number tells a richer story than three %s." : void 0,
2184
+ void 0,
2185
+ 0.5
2138
2186
  ),
2139
2187
  mk(
2140
2188
  "A3.xyz-bullets",
@@ -2142,7 +2190,9 @@ function impactChecks(resume, b) {
2142
2190
  Math.min(1, xyz.length / Math.max(1, total * 0.4)),
2143
2191
  `${xyz.length} of ${total} bullets read as "Accomplished X (measured by Y) by doing Z."`,
2144
2192
  [],
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
2193
+ xyz.length / total < 0.4 ? "Reframe bullets as Action + Measurable Outcome + Method ('Cut LLM cost 35% by redesigning the prompt+cache layer')." : void 0,
2194
+ void 0,
2195
+ 2
2146
2196
  ),
2147
2197
  mk(
2148
2198
  "A4.responsibility",
@@ -2158,7 +2208,9 @@ function impactChecks(resume, b) {
2158
2208
  strongStart.length / total,
2159
2209
  `${strongStart.length} of ${total} bullets open with a strong verb (Led, Built, Drove, \u2026).`,
2160
2210
  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
2211
+ strongStart.length / total < 0.85 ? "Replace soft openers with strong past-tense verbs." : void 0,
2212
+ void 0,
2213
+ 2
2162
2214
  ),
2163
2215
  mk(
2164
2216
  "A6.weak-verbs",
@@ -2179,18 +2231,34 @@ function impactChecks(resume, b) {
2179
2231
  mk(
2180
2232
  "A8.scale-signals",
2181
2233
  "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.`,
2234
+ // Bonus signal: absence is neutral, presence lifts the score.
2235
+ scaleSignals.length === 0 ? 0.8 : Math.min(1, 0.8 + scaleSignals.length * 0.1),
2236
+ 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
2237
  [],
2185
- scaleSignals.length === 0 ? "Add scale: 'Led a team of 6', 'Owned a $2M budget', '60+ engineers depend on this service.'" : void 0
2238
+ scaleSignals.length === 0 ? "Optional boost. Add scale: 'Led a team of 6', 'Owned a $2M budget', '60+ engineers depend on this service.'" : void 0,
2239
+ void 0,
2240
+ 0.5
2186
2241
  ),
2187
2242
  mk(
2188
2243
  "A9.time-bound",
2189
2244
  "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.`,
2245
+ // Bonus signal: absence is neutral, presence lifts the score.
2246
+ timeBound.length === 0 ? 0.8 : Math.min(1, 0.8 + timeBound.length * 0.1),
2247
+ timeBound.length === 0 ? "No time-bound results yet. A nice-to-have, not a deduction." : `${timeBound.length} bullet(s) include a timeframe.`,
2192
2248
  [],
2193
- timeBound.length === 0 ? "Anchor results in time: 'within 6 months', 'in 12 weeks', 'over Q3 2025'." : void 0
2249
+ timeBound.length === 0 ? "Optional boost. Anchor results in time: 'within 6 months', 'in 12 weeks', 'over Q3 2025'." : void 0,
2250
+ void 0,
2251
+ 0.5
2252
+ ),
2253
+ mk(
2254
+ "A10.metric-position",
2255
+ "Metrics appear early in the bullet",
2256
+ quantified.length === 0 ? 0.8 : earlyRatio,
2257
+ quantified.length === 0 ? "No quantified bullets yet to position." : `${quantifiedEarly.length} of ${quantified.length} quantified bullets lead with the number.`,
2258
+ [],
2259
+ 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,
2260
+ void 0,
2261
+ 1
2194
2262
  )
2195
2263
  ];
2196
2264
  }
@@ -2222,7 +2290,7 @@ function brevityChecks(resume, b) {
2222
2290
  return r.bullets > 5;
2223
2291
  });
2224
2292
  const pages = Math.max(1, Math.ceil(totalWords / 450));
2225
- const targetPages = experienceYears(resume) >= 10 ? 2 : 1;
2293
+ const targetPages = experienceYears(resume) < 5 ? 1 : 2;
2226
2294
  const lengthOK = pages <= targetPages;
2227
2295
  return [
2228
2296
  mk(
@@ -2255,7 +2323,7 @@ function brevityChecks(resume, b) {
2255
2323
  mk(
2256
2324
  "B4.page-length",
2257
2325
  `Resume length fits ${targetPages} page${targetPages > 1 ? "s" : ""}`,
2258
- lengthOK ? 1 : Math.max(0, 1 - (pages - targetPages) * 0.5),
2326
+ lengthOK ? 1 : Math.max(0, 1 - (pages - targetPages) * 0.35),
2259
2327
  `Estimated ${pages} page${pages > 1 ? "s" : ""} (${totalWords} words total).`,
2260
2328
  [],
2261
2329
  !lengthOK ? `Cut ~${(pages - targetPages) * 450} words to fit ${targetPages} page${targetPages > 1 ? "s" : ""}.` : void 0
@@ -2353,7 +2421,9 @@ function styleChecks(resume, b) {
2353
2421
  passive.length / total <= 0.15 ? 1 : 1 - (passive.length / total - 0.15) * 2,
2354
2422
  `${passive.length} of ${total} bullets read as passive voice (${Math.round(passive.length / total * 100)}%).`,
2355
2423
  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
2424
+ passive.length / total > 0.15 ? "Flip subject/object: 'The project was led by me' \u2192 'Led the project.'" : void 0,
2425
+ void 0,
2426
+ 1.5
2357
2427
  ),
2358
2428
  mk(
2359
2429
  "C3.tense-consistency",
@@ -2393,7 +2463,9 @@ function styleChecks(resume, b) {
2393
2463
  periodConsistent ? 1 : 0.5,
2394
2464
  periodConsistent ? "All bullets agree on whether they end with a period." : `Mixed: ${endsWithPeriod} end with a period, ${endsWithout} don't.`,
2395
2465
  [],
2396
- !periodConsistent ? "Pick one rule for the whole resume." : void 0
2466
+ !periodConsistent ? "Pick one rule for the whole resume." : void 0,
2467
+ void 0,
2468
+ 0.4
2397
2469
  ),
2398
2470
  mk(
2399
2471
  "C8.punctuation-chars",
@@ -2401,7 +2473,9 @@ function styleChecks(resume, b) {
2401
2473
  punctConsistent ? 1 : 0.5,
2402
2474
  punctConsistent ? "Punctuation glyphs are consistent." : "Mixes smart quotes/em-dashes with ASCII equivalents.",
2403
2475
  [],
2404
- !punctConsistent ? 'Pick either smart quotes (\u201C\u201D) or straight quotes (") consistently.' : void 0
2476
+ !punctConsistent ? 'Pick either smart quotes (\u201C\u201D) or straight quotes (") consistently.' : void 0,
2477
+ void 0,
2478
+ 0.4
2405
2479
  ),
2406
2480
  mk(
2407
2481
  "C9.capitalization",
@@ -2409,7 +2483,9 @@ function styleChecks(resume, b) {
2409
2483
  capConsistent ? 1 : 0.5,
2410
2484
  capConsistent ? "Every bullet starts with the same case." : `${startsCap} of ${total} bullets start with a capital letter.`,
2411
2485
  [],
2412
- !capConsistent ? "Capitalize the first letter of every bullet." : void 0
2486
+ !capConsistent ? "Capitalize the first letter of every bullet." : void 0,
2487
+ void 0,
2488
+ 0.4
2413
2489
  ),
2414
2490
  mk(
2415
2491
  "C10.acronym-density",
@@ -2464,6 +2540,20 @@ function structureChecks(resume, b) {
2464
2540
  }
2465
2541
  const expRev = expSec ? rev(expSec.items || []) : true;
2466
2542
  const eduRev = eduSec ? rev(eduSec.items || []) : true;
2543
+ const nowMonths = (/* @__PURE__ */ new Date()).getFullYear() * 12 + ((/* @__PURE__ */ new Date()).getMonth() + 1);
2544
+ let maxGapMonths = 0;
2545
+ if (expSec) {
2546
+ const ranges = (expSec.items || []).map((it) => ({
2547
+ start: parseMMYYYY(it.startDate || ""),
2548
+ end: it.current ? nowMonths : parseMMYYYY(it.endDate || it.startDate || "")
2549
+ })).filter((r) => r.start && r.end);
2550
+ ranges.sort((a, b2) => b2.start - a.start);
2551
+ for (let i = 0; i < ranges.length - 1; i++) {
2552
+ const gap = ranges[i].start - ranges[i + 1].end;
2553
+ if (gap > maxGapMonths) maxGapMonths = gap;
2554
+ }
2555
+ }
2556
+ const gapOK = maxGapMonths <= 6;
2467
2557
  function isCanonical(type, title) {
2468
2558
  const t = title.toLowerCase().trim();
2469
2559
  const list = ATS_SECTION_LABELS[type] || [];
@@ -2533,6 +2623,16 @@ function structureChecks(resume, b) {
2533
2623
  expRev && eduRev ? "Both sections are reverse-chronological." : `${!expRev ? "Experience" : ""}${!expRev && !eduRev ? " & " : ""}${!eduRev ? "Education" : ""} out of order.`,
2534
2624
  [],
2535
2625
  !expRev || !eduRev ? "List most recent first." : void 0
2626
+ ),
2627
+ mk(
2628
+ "D9.employment-gaps",
2629
+ "No large unexplained employment gaps",
2630
+ gapOK ? 1 : 0.7,
2631
+ 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.`,
2632
+ [],
2633
+ 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.",
2634
+ void 0,
2635
+ 0.5
2536
2636
  )
2537
2637
  ];
2538
2638
  }
@@ -2548,6 +2648,9 @@ function atsChecks(resume, b) {
2548
2648
  const phoneOK = !p.phone || /(\+?\d[\d\s().-]{6,})/.test(p.phone);
2549
2649
  const hasLinkedIn = !!(p.linkedin && /linkedin\.com\/in\//i.test(p.linkedin));
2550
2650
  const locationOK = !!p.location && /,/.test(p.location);
2651
+ const nameOK = !!(p.fullName && p.fullName.trim().length >= 2);
2652
+ const hasPhone = !!(p.phone && /(\+?\d[\d\s().-]{6,})/.test(p.phone));
2653
+ const contactParseable = nameOK && (emailOK || hasPhone);
2551
2654
  const allDates = [];
2552
2655
  for (const sec of resume.sections) for (const it of sec.items || []) {
2553
2656
  if (it.startDate) allDates.push(it.startDate);
@@ -2575,19 +2678,29 @@ function atsChecks(resume, b) {
2575
2678
  return [
2576
2679
  mk(
2577
2680
  "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.",
2681
+ "Single-column layout (ATS-friendly)",
2682
+ // Proportional soft penalty, not an auto-fail. Modern parsers handle
2683
+ // columns (Textkernel reports ~90% correct column extraction vs ~97%
2684
+ // single-column), but ~1 in 10 still scrambles reading order and columns
2685
+ // cost human skimmability. A single, evidence-scaled ding — the overall
2686
+ // score also takes a small proportional multi-column haircut (see below).
2687
+ isSingleCol ? 1 : 0.55,
2688
+ isSingleCol ? "Single-column layout \u2014 every ATS parses this cleanly." : "Multi-column template. Modern parsers usually handle columns, but a minority still scramble reading order, and columns are harder for a recruiter to skim. Single-column is the zero-risk choice.",
2581
2689
  [],
2582
- !isSingleCol ? "Pick a single-column template (e.g. Jake, Harvard, Classic) in the Templates tab." : void 0,
2583
- !isSingleCol ? "hard" : "soft"
2690
+ !isSingleCol ? "For maximum ATS safety, switch to a single-column template (Jake, Harvard, Classic) in the Templates tab. Not required, but it removes all parse risk." : void 0,
2691
+ "soft"
2584
2692
  ),
2585
2693
  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
- []
2694
+ "E2.contact-parseable",
2695
+ "Name + contact details are parseable",
2696
+ // Hard dependency: parsers build the candidate record from a name plus at
2697
+ // least one of email/phone (Daxtra: without one of these the parse fails).
2698
+ contactParseable ? 1 : 0,
2699
+ 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.",
2700
+ [],
2701
+ !contactParseable ? "Add your full name and at least one of email or phone. Without these, many ATS drop the whole resume." : void 0,
2702
+ contactParseable ? "soft" : "hard",
2703
+ 2
2591
2704
  ),
2592
2705
  mk(
2593
2706
  "E3.email-valid",
@@ -2620,7 +2733,9 @@ function atsChecks(resume, b) {
2620
2733
  hasLinkedIn ? 1 : 0,
2621
2734
  hasLinkedIn ? "LinkedIn URL detected." : "LinkedIn URL missing or non-canonical.",
2622
2735
  [],
2623
- !hasLinkedIn ? "Add a linkedin.com/in/your-handle URL." : void 0
2736
+ !hasLinkedIn ? "Add a linkedin.com/in/your-handle URL. Resumes with one see materially higher recruiter response." : void 0,
2737
+ void 0,
2738
+ 1.5
2624
2739
  ),
2625
2740
  mk(
2626
2741
  "E7.date-format",
@@ -2644,7 +2759,9 @@ function atsChecks(resume, b) {
2644
2759
  safeBullet ? 1 : 0.6,
2645
2760
  safeBullet ? "Bullet glyph is ATS-safe." : `Bullet glyph '${s.bulletGlyph}' may not survive every ATS parser.`,
2646
2761
  [],
2647
- !safeBullet ? "Switch bullet glyph to disc, dash, or square (Customize \u2192 Layout \u2192 bullet glyph)." : void 0
2762
+ !safeBullet ? "Switch bullet glyph to disc, dash, or square (Customize \u2192 Layout \u2192 bullet glyph)." : void 0,
2763
+ void 0,
2764
+ 0.5
2648
2765
  ),
2649
2766
  mk(
2650
2767
  "E10.font-safe",
@@ -2652,7 +2769,9 @@ function atsChecks(resume, b) {
2652
2769
  isSafeFont ? 1 : 0.5,
2653
2770
  isSafeFont ? "Fonts are ATS-safe." : `Heading '${s.fontHeading}' or body '${s.fontBody}' may not be embedded by some PDF renderers.`,
2654
2771
  [],
2655
- !isSafeFont ? "Use Arial, Calibri, Helvetica, Garamond, or one of the safe defaults." : void 0
2772
+ !isSafeFont ? "Use Arial, Calibri, Helvetica, Garamond, or one of the safe defaults." : void 0,
2773
+ void 0,
2774
+ 0.5
2656
2775
  ),
2657
2776
  mk(
2658
2777
  "E11.font-size",
@@ -2660,7 +2779,9 @@ function atsChecks(resume, b) {
2660
2779
  fontSizeOK ? 1 : 0.5,
2661
2780
  fontSizeOK ? `Font size ${s.fontSize}pt is in range.` : `Font size ${s.fontSize}pt is outside the 10\u201312pt range.`,
2662
2781
  [],
2663
- !fontSizeOK ? "Set body size to 10\u201312pt." : void 0
2782
+ !fontSizeOK ? "Set body size to 10\u201312pt." : void 0,
2783
+ void 0,
2784
+ 0.5
2664
2785
  ),
2665
2786
  mk(
2666
2787
  "E12.no-killer-chars",
@@ -2685,7 +2806,7 @@ function skillsChecks(resume, b) {
2685
2806
  if (t.length < 3) continue;
2686
2807
  counts.set(t, (counts.get(t) || 0) + 1);
2687
2808
  }
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));
2809
+ 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
2810
  const allText = b.bullets.map((x) => x.text).join(" ");
2690
2811
  const acronyms = [...new Set(Array.from(allText.matchAll(/\b([A-Z]{2,5})\b/g)).map((m) => m[1]))].slice(0, 5);
2691
2812
  const expanded = acronyms.filter((a) => new RegExp(`\\b${a}\\s*\\(`).test(allText));
@@ -2728,7 +2849,9 @@ function skillsChecks(resume, b) {
2728
2849
  acronyms.length === 0 ? 1 : expanded.length / acronyms.length,
2729
2850
  acronyms.length === 0 ? "No acronyms detected." : `${expanded.length} of ${acronyms.length} top acronyms have an inline expansion.`,
2730
2851
  [],
2731
- acronyms.length > 0 && expanded.length < acronyms.length ? `Expand at least once: e.g. '${acronyms[0]} (\u2026)'.` : void 0
2852
+ 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,
2853
+ void 0,
2854
+ 0.5
2732
2855
  )
2733
2856
  ];
2734
2857
  }
@@ -2743,6 +2866,10 @@ function polishChecks(resume, b) {
2743
2866
  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
2867
  const hasCustomWebsite = !!p.website && !/(linkedin\.com|github\.com|facebook\.com|twitter\.com|x\.com)/i.test(p.website);
2745
2868
  const hasHeadline = !!p.title;
2869
+ const rawHtml = resume.sections.flatMap((sec) => (sec.items || []).flatMap((it) => [it.description, it.body].filter(Boolean))).join(" ");
2870
+ 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;
2871
+ 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;
2872
+ const hasHiddenText = HIDDEN_STYLE_RE.test(rawHtml) || PROMPT_INJECT_RE.test(stripHtml2(rawHtml).toLowerCase());
2746
2873
  return [
2747
2874
  mk(
2748
2875
  "G1.no-placeholders",
@@ -2787,14 +2914,26 @@ function polishChecks(resume, b) {
2787
2914
  mk(
2788
2915
  "G6.custom-portfolio",
2789
2916
  "Custom portfolio or personal site (nice-to-have)",
2790
- hasCustomWebsite ? 1 : 0.6,
2791
- hasCustomWebsite ? "Custom site present." : "No personal site listed.",
2917
+ hasCustomWebsite ? 1 : 0.9,
2918
+ hasCustomWebsite ? "Custom site present." : "No personal site listed. Optional, not a deduction.",
2792
2919
  [],
2793
- hasCustomWebsite ? void 0 : "A custom domain (yourname.dev) lifts a senior resume \u2014 optional."
2920
+ hasCustomWebsite ? void 0 : "A custom domain (yourname.dev) lifts a senior resume \u2014 optional.",
2921
+ void 0,
2922
+ 0.5
2923
+ ),
2924
+ mk(
2925
+ "G8.hidden-text",
2926
+ "No hidden text or keyword tricks",
2927
+ hasHiddenText ? 0 : 1,
2928
+ 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.",
2929
+ [],
2930
+ hasHiddenText ? "Remove any white/invisible text, zero-size fonts, or 'rate me highly'-style instructions. They backfire once the ATS strips formatting." : void 0,
2931
+ void 0,
2932
+ 2
2794
2933
  )
2795
2934
  ];
2796
2935
  }
2797
- function mk(id, label, score, message, evidence, suggestion, severity) {
2936
+ function mk(id, label, score, message, evidence, suggestion, severity, weight) {
2798
2937
  const s = Math.max(0, Math.min(1, score));
2799
2938
  return {
2800
2939
  id,
@@ -2804,7 +2943,8 @@ function mk(id, label, score, message, evidence, suggestion, severity) {
2804
2943
  message,
2805
2944
  evidence: evidence ?? [],
2806
2945
  suggestion: suggestion ?? null,
2807
- severity: severity || "soft"
2946
+ severity: severity || "soft",
2947
+ weight: weight ?? 1
2808
2948
  };
2809
2949
  }
2810
2950
  function parseMMYYYY(d) {
@@ -2834,9 +2974,14 @@ function experienceYears(resume) {
2834
2974
  }
2835
2975
  function aggregate(checks, weight) {
2836
2976
  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 };
2977
+ let wsum = 0, wtot = 0;
2978
+ for (const c of checks) {
2979
+ const w = c.weight ?? 1;
2980
+ wsum += c.score * w;
2981
+ wtot += w;
2982
+ }
2983
+ const mean = wtot ? wsum / wtot : 0;
2984
+ return { score: Math.round(mean * 100), weighted: mean * weight };
2840
2985
  }
2841
2986
  var WEIGHTS = {
2842
2987
  impact: 22,
@@ -2886,12 +3031,15 @@ function scoreResume(resume) {
2886
3031
  };
2887
3032
  });
2888
3033
  let overall = Math.round(weightedSum / totalWeight * 100);
3034
+ if (isMultiColumnTemplate(resume.styling.template)) {
3035
+ overall = Math.round(overall * 0.93);
3036
+ }
2889
3037
  const hardFail = dimensions.some((d) => d.checks.some((c) => c.severity === "hard" && c.status === "fail"));
2890
3038
  if (hardFail) overall = Math.min(overall, 60);
2891
3039
  const sortedDims = [...dimensions].sort((a, b2) => a.score - b2.score);
2892
3040
  const worst = sortedDims[0];
2893
3041
  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);
3042
+ 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
3043
  const priorities = allFails.slice(0, 5);
2896
3044
  return {
2897
3045
  overall,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juicedresume/mcp",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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",