resuml 3.1.0 → 3.2.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/DOCS.md CHANGED
@@ -8,6 +8,7 @@
8
8
  - [Installation](#installation)
9
9
  - [CLI commands and options](#cli-commands-and-options)
10
10
  - [ATS analysis](#ats-analysis)
11
+ - [Job search](#job-search)
11
12
  - [Themes](#themes)
12
13
  - [Example YAML structure](#example-yaml-structure)
13
14
  - [CI/CD integration](#cicd-auto-build-on-push)
@@ -47,21 +48,32 @@ Requires Node.js ≥ 20 and npm ≥ 10.
47
48
  | `pdf` | Render to PDF |
48
49
  | `dev` | Dev server with hot-reload |
49
50
  | `mcp` | Start the MCP server for AI agents |
51
+ | `jobs search` | Discover and rank job postings from free sources |
52
+ | `jobs score` | Score a single posting against your resume |
53
+ | `jobs tailor` | Generate a tailoring prompt for a posting |
50
54
 
51
55
  ### Options
52
56
 
53
57
  | Option | Alias | Description |
54
58
  | ----------------- | ----- | -------------------------------------------- |
55
- | `--resume` | `-r` | Input YAML file(s) or directory |
56
- | `--output` | `-o` | Output file path |
57
- | `--theme` | `-t` | Theme name |
58
- | `--port` | `-p` | Dev server port (default: 3000) |
59
- | `--language` | | Locale (default: `en`) |
60
- | `--debug` | | Detailed errors |
61
- | `--ats` | | Run ATS analysis (with `validate`) |
62
- | `--jd` | | Path to job description file (with `--ats`) |
63
- | `--ats-threshold` | | Minimum score (0-100); exits 1 if below |
64
- | `--format` | | Output format for validate: `text` or `json` |
59
+ | `--resume` | `-r` | Input YAML file(s) or directory |
60
+ | `--output` | `-o` | Output file path |
61
+ | `--theme` | `-t` | Theme name |
62
+ | `--port` | `-p` | Dev server port (default: 3000) |
63
+ | `--language` | | Locale (default: `en`) |
64
+ | `--debug` | | Detailed errors |
65
+ | `--ats` | | Run ATS analysis (with `validate`) |
66
+ | `--jd` | | Path to job description file (with `--ats`) |
67
+ | `--ats-threshold` | | Minimum score (0-100); exits 1 if below |
68
+ | `--format` | | Output format for validate: `text` or `json` |
69
+ | `--location` | | Override candidate location as `"City, CC"` (with `jobs search`) |
70
+ | `--min-score` | | Minimum ATS score for results (default 85, with `jobs search`) |
71
+ | `--limit` | | Max postings returned after ranking (default 20, with `jobs search`) |
72
+ | `--providers` | | Comma-separated provider list (with `jobs search`) |
73
+ | `--remote` | | Restrict to remote-friendly postings (with `jobs search`) |
74
+ | `--posting` | | Posting YAML/JSON file for `jobs score` / `jobs tailor` |
75
+ | `--body` | | Posting body text; use `-` for stdin (with `jobs tailor`) |
76
+ | `--json` | | Machine-readable JSON output (with `jobs search`, `score`, `tailor`) |
65
77
 
66
78
  ### Quick start
67
79
 
@@ -141,6 +153,49 @@ Supports English and German (language-specific action verbs and pronouns). Use `
141
153
  resuml validate --resume lebenslauf.yaml --ats --language de
142
154
  ```
143
155
 
156
+ ## Job search
157
+
158
+ Discover, score, and tailor job postings against your resume — all offline, no API keys.
159
+
160
+ ```bash
161
+ # Search free job sources and rank results against your resume (default minScore: 85)
162
+ resuml jobs search -r resume.yaml
163
+
164
+ # Filter to remote-friendly postings in your country
165
+ resuml jobs search -r resume.yaml --remote --location "Zürich, CH"
166
+
167
+ # Score a single posting (full per-tier ATS breakdown)
168
+ resuml jobs score -r resume.yaml --posting posting.yaml
169
+
170
+ # Generate a tailoring prompt for a posting (pipe output to Claude)
171
+ resuml jobs tailor --posting posting.yaml
172
+ resuml jobs tailor --body - < jd.txt # read body from stdin
173
+
174
+ # Machine-readable output
175
+ resuml jobs search -r resume.yaml --json
176
+ ```
177
+
178
+ ### Posting file format (`--posting posting.yaml`)
179
+
180
+ ```yaml
181
+ company: Acme Corp
182
+ title: Senior Backend Engineer
183
+ url: https://acme.com/jobs/123
184
+ body: |
185
+ We are looking for a backend engineer with 5+ years of experience...
186
+ location: Berlin, DE # optional
187
+ remote: false # optional
188
+ ```
189
+
190
+ ### How search works
191
+
192
+ 1. Derives a query from your resume (seniority, top skills, location).
193
+ 2. Fetches postings from free sources in parallel (Greenhouse, Lever, Ashby, Workable, RemoteOK, WWR, Remotive, HN Who's Hiring).
194
+ 3. Drops on-site postings in the wrong country when `--location` is set.
195
+ 4. Scores every posting with the full ATS engine; dedupes by `(company, title, location)`.
196
+ 5. Removes postings below `--min-score` (default 85) and caps to `--limit` (default 20).
197
+ 6. Off-specialty roles (e.g. a backend JD for a frontend CV) are capped at 45 and filtered out automatically.
198
+
144
199
  ## Themes
145
200
 
146
201
  resuml supports any `jsonresume-theme-*` package from npm. Install a theme, then pass its name to `--theme`:
@@ -277,14 +332,18 @@ Claude Code will:
277
332
 
278
333
  ### Tools
279
334
 
280
- | Tool | Purpose |
281
- | -------------------- | --------------------------------------------------- |
282
- | `resuml_init_resume` | Generate a starter YAML template |
283
- | `resuml_validate` | Validate resume YAML against the JSON Resume schema |
284
- | `resuml_ats_check` | ATS analysis + JD keyword matching |
285
- | `resuml_render` | Render to HTML using a theme (supports `locale`) |
286
- | `resuml_list_themes` | List available themes and install status |
287
- | `resuml_export_pdf` | Export as PDF (supports `margin`, `locale`) |
335
+ | Tool | Purpose |
336
+ | ---------------------- | ------------------------------------------------------------------------------- |
337
+ | `resuml_init_resume` | Generate a starter YAML template |
338
+ | `resuml_validate` | Validate resume YAML against the JSON Resume schema |
339
+ | `resuml_ats_check` | Tiered ATS analysis (Parsing / Match / Recruiter) with knockout signals |
340
+ | `resuml_ats_explain` | Return the rubric entry for a specific check id |
341
+ | `resuml_render` | Render to HTML using a theme (supports `locale`) |
342
+ | `resuml_list_themes` | List available themes and install status |
343
+ | `resuml_export_pdf` | Export as PDF (supports `margin`, `locale`) |
344
+ | `resuml_jobs_search` | Discover and rank job postings from free sources against the resume |
345
+ | `resuml_jobs_score` | Score a single job posting against the resume; returns full ATS breakdown |
346
+ | `resuml_jobs_tailor` | Build a tailoring prompt for a specific posting (returns prompt text) |
288
347
 
289
348
  ### Resources
290
349
 
package/README.md CHANGED
@@ -72,6 +72,11 @@ npm install -g jsonresume-theme-stackoverflow
72
72
  resuml validate --resume resume.yaml --ats --jd job.txt
73
73
  resuml render --resume resume.yaml --theme stackoverflow --output resume.html
74
74
  resuml pdf --resume resume.yaml --theme stackoverflow --output resume.pdf
75
+
76
+ # discover and rank job postings from free sources
77
+ resuml jobs search --resume resume.yaml --location "Zürich, CH"
78
+ resuml jobs score --resume resume.yaml --posting posting.yaml
79
+ resuml jobs tailor --posting posting.yaml
75
80
  ```
76
81
 
77
82
  `resuml pdf` and snapshot rendering need Playwright. Install it once with `npm install -g playwright` (it's an optional peer dep so the base install stays slim).
@@ -0,0 +1,76 @@
1
+ {
2
+ "_comment": "Seed allowlists for ATS-direct providers. Slugs are the board-id segment of each company's public ATS URL. Curated from public job boards as of 2026-05. Add to this list via the --extra-companies CLI flag or extraCompanies SearchOption.",
3
+ "greenhouse": [
4
+ "airbnb",
5
+ "stripe",
6
+ "anthropic",
7
+ "openai",
8
+ "datadog",
9
+ "discord",
10
+ "duolingo",
11
+ "elastic",
12
+ "figma",
13
+ "gitlab",
14
+ "instacart",
15
+ "linear",
16
+ "lyft",
17
+ "mongodb",
18
+ "notion",
19
+ "nubank",
20
+ "pinterest",
21
+ "plaid",
22
+ "ramp",
23
+ "reddit",
24
+ "robinhood",
25
+ "samsara",
26
+ "scaleai",
27
+ "snowflake",
28
+ "squareup",
29
+ "twilio",
30
+ "vercel",
31
+ "wise",
32
+ "zapier"
33
+ ],
34
+ "lever": [
35
+ "netflix",
36
+ "spotify",
37
+ "shopify",
38
+ "github",
39
+ "palantir",
40
+ "asana",
41
+ "intercom",
42
+ "kraken",
43
+ "leetcode",
44
+ "miro",
45
+ "patreon",
46
+ "showpad",
47
+ "thumbtack"
48
+ ],
49
+ "ashby": [
50
+ "ashbyhq",
51
+ "openai",
52
+ "anthropic",
53
+ "ramp",
54
+ "linear",
55
+ "vercel",
56
+ "posthog",
57
+ "modal",
58
+ "replicate",
59
+ "perplexity",
60
+ "supabase",
61
+ "huggingface",
62
+ "ironclad",
63
+ "warp",
64
+ "writer"
65
+ ],
66
+ "workable": [
67
+ "deliveroo",
68
+ "getyourguide",
69
+ "n26",
70
+ "personio",
71
+ "celonis",
72
+ "tier",
73
+ "wefox",
74
+ "blinkist"
75
+ ]
76
+ }
@@ -1,83 +1,7 @@
1
1
  import { Resume as ResumeSchema } from '../types/index.js';
2
-
3
- type Tier = 'parsing' | 'match' | 'recruiter';
4
- type AtsCheckWeight = 'high' | 'medium' | 'low';
5
- type AtsRating = 'excellent' | 'good' | 'needs-work' | 'poor';
6
- type CheckStatus = 'pass' | 'warn' | 'fail' | 'skipped';
7
- type Grade = 'A' | 'B' | 'C' | 'D' | 'F';
8
- interface CheckResult {
9
- id: string;
10
- tier: Tier;
11
- status: CheckStatus;
12
- score: number;
13
- weight: AtsCheckWeight;
14
- message: string;
15
- hints: string[];
16
- }
17
- interface TierResult {
18
- score: number;
19
- grade: Grade;
20
- checks: CheckResult[];
21
- }
22
- interface KnockoutSignal {
23
- signal: string;
24
- evidence: string;
25
- recommendation: string;
26
- }
27
- interface TieredAtsResult {
28
- score: number;
29
- rating: AtsRating;
30
- tiers: {
31
- parsing: TierResult;
32
- match?: TierResult;
33
- recruiter: TierResult;
34
- };
35
- knockouts: KnockoutSignal[];
36
- summary: string;
37
- }
38
- interface AtsOptions {
39
- language?: string;
40
- jobDescription?: string;
41
- threshold?: number;
42
- config?: AtsConfig;
43
- }
44
- interface AtsConfig {
45
- weights: {
46
- tiers: {
47
- parsing: number;
48
- match: number;
49
- recruiter: number;
50
- };
51
- checks: Record<string, AtsCheckWeight>;
52
- };
53
- thresholds: {
54
- rating: {
55
- excellent: number;
56
- good: number;
57
- needsWork: number;
58
- };
59
- grade: {
60
- A: number;
61
- B: number;
62
- C: number;
63
- D: number;
64
- };
65
- seniorYoeCutoff: number;
66
- wordCount: {
67
- min: number;
68
- max: number;
69
- seniorMax: number;
70
- };
71
- bulletsPerRole: {
72
- min: number;
73
- max: number;
74
- seniorMax: number;
75
- };
76
- };
77
- disable: string[];
78
- locale: string;
79
- }
2
+ import { A as AtsOptions, T as TieredAtsResult } from '../types-B_jASYU4.js';
3
+ export { C as CheckResult, K as KnockoutSignal, a as TierResult } from '../types-B_jASYU4.js';
80
4
 
81
5
  declare function analyzeAts(resume: ResumeSchema, options?: AtsOptions): TieredAtsResult;
82
6
 
83
- export { type AtsOptions, type CheckResult, type KnockoutSignal, type TierResult, type TieredAtsResult, analyzeAts };
7
+ export { AtsOptions, TieredAtsResult, analyzeAts };
package/dist/ats/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  analyzeAts
3
- } from "../chunk-N55EPZ2N.js";
3
+ } from "../chunk-C2JG5KF4.js";
4
4
  import "../chunk-QR77BRMN.js";
5
5
  export {
6
6
  analyzeAts
@@ -1430,6 +1430,176 @@ function matchJobDescription(resume, jobDescription, _language = "en") {
1430
1430
  return { matched, missing, extra, matchPercentage };
1431
1431
  }
1432
1432
 
1433
+ // src/ats/roleFamily.ts
1434
+ var SIGNATURES = {
1435
+ engineering: [
1436
+ "software engineer",
1437
+ "engineer",
1438
+ "developer",
1439
+ "programmer",
1440
+ "swe",
1441
+ "backend",
1442
+ "back-end",
1443
+ "frontend",
1444
+ "front-end",
1445
+ "full stack",
1446
+ "full-stack",
1447
+ "devops",
1448
+ "sre",
1449
+ "site reliability",
1450
+ "software development",
1451
+ "microservices",
1452
+ "codebase"
1453
+ ],
1454
+ data: [
1455
+ "data scientist",
1456
+ "data engineer",
1457
+ "machine learning",
1458
+ "ml engineer",
1459
+ "data analyst",
1460
+ "analytics engineer",
1461
+ "mlops"
1462
+ ],
1463
+ design: [
1464
+ "designer",
1465
+ "ux ",
1466
+ "ui designer",
1467
+ "product design",
1468
+ "graphic design",
1469
+ "visual design",
1470
+ "user research"
1471
+ ],
1472
+ product: ["product manager", "product owner", "program manager", "product management"],
1473
+ recruiting: [
1474
+ "recruiter",
1475
+ "recruiting",
1476
+ "recruitment",
1477
+ "talent acquisition",
1478
+ "sourcer",
1479
+ "sourcing",
1480
+ "full-cycle recruiting",
1481
+ "candidate experience",
1482
+ "hiring manager",
1483
+ "people ops",
1484
+ "human resources"
1485
+ ],
1486
+ sales: [
1487
+ "account executive",
1488
+ "business development",
1489
+ "sales development",
1490
+ "sales representative",
1491
+ "quota",
1492
+ "sdr",
1493
+ "bdr"
1494
+ ],
1495
+ marketing: [
1496
+ "marketing",
1497
+ "growth marketing",
1498
+ "demand generation",
1499
+ "content marketing",
1500
+ "brand manager",
1501
+ "seo"
1502
+ ],
1503
+ legal: ["legal counsel", "attorney", "paralegal", "lawyer", "litigation", "general counsel"],
1504
+ finance: ["accountant", "accounting", "controller", "fp&a", "financial analyst", "bookkeeping"],
1505
+ support: [
1506
+ "customer support",
1507
+ "customer success",
1508
+ "support specialist",
1509
+ "technical support",
1510
+ "help desk"
1511
+ ],
1512
+ operations: ["operations manager", "business operations", "supply chain", "logistics"]
1513
+ };
1514
+ var FAMILIES = Object.keys(SIGNATURES);
1515
+ function countOccurrences(haystack, needle) {
1516
+ let count = 0;
1517
+ let idx = haystack.indexOf(needle);
1518
+ while (idx !== -1) {
1519
+ count++;
1520
+ idx = haystack.indexOf(needle, idx + needle.length);
1521
+ }
1522
+ return count;
1523
+ }
1524
+ function classifyRoleFamily(body, title = "") {
1525
+ const b = body.toLowerCase();
1526
+ const t = title.toLowerCase();
1527
+ const scores = FAMILIES.map((family) => {
1528
+ let score = 0;
1529
+ for (const sig of SIGNATURES[family]) {
1530
+ score += countOccurrences(b, sig);
1531
+ score += countOccurrences(t, sig) * 3;
1532
+ }
1533
+ return { family, score };
1534
+ }).sort((a, b2) => b2.score - a.score);
1535
+ const top = scores[0];
1536
+ const second = scores[1];
1537
+ if (!top || top.score < 2) return null;
1538
+ const margin = top.score - (second?.score ?? 0);
1539
+ if (margin < 1) return null;
1540
+ return { family: top.family, score: top.score, margin };
1541
+ }
1542
+ var SPECIALTY_SIGNATURES = {
1543
+ frontend: ["frontend", "front-end", "front end", "react", "vue", "angular", "css", "ui engineer", "web developer", "design system"],
1544
+ backend: ["backend", "back-end", "back end", "server-side", "microservices", "api development", "distributed systems"],
1545
+ security: ["security", "appsec", "application security", "infosec", "penetration", "vulnerability", "secure coding", "cryptography", "threat"],
1546
+ ml: ["machine learning", "ml engineer", "deep learning", "nlp", "computer vision", "ai engineer", "pytorch", "tensorflow"],
1547
+ mobile: ["ios engineer", "android engineer", "mobile engineer", "swift", "kotlin", "react native", "flutter"],
1548
+ devops: ["devops", "sre", "site reliability", "platform engineer", "kubernetes", "terraform", "infrastructure engineer"],
1549
+ data: ["data engineer", "etl", "data pipeline", "spark", "hadoop", "data warehouse"],
1550
+ embedded: ["embedded", "firmware", "rtos", "microcontroller", "bare metal"],
1551
+ qa: ["qa engineer", "test engineer", "sdet", "automation testing", "quality assurance"]
1552
+ };
1553
+ var SPECIALTIES = Object.keys(SPECIALTY_SIGNATURES);
1554
+ function classifySpecialty(body, title = "") {
1555
+ const b = body.toLowerCase();
1556
+ const t = title.toLowerCase();
1557
+ const scores = SPECIALTIES.map((sp) => {
1558
+ let score = 0;
1559
+ for (const sig of SPECIALTY_SIGNATURES[sp]) {
1560
+ score += countOccurrences(b, sig);
1561
+ score += countOccurrences(t, sig) * 3;
1562
+ }
1563
+ return { sp, score };
1564
+ }).sort((a, b2) => b2.score - a.score);
1565
+ const top = scores[0];
1566
+ const second = scores[1];
1567
+ if (!top || top.score < 2) return null;
1568
+ if (top.score - (second?.score ?? 0) < 1) return null;
1569
+ return top.sp;
1570
+ }
1571
+ var SPECIALTY_MIN_SCORE = 4;
1572
+ var TOP_MARGIN_FACTOR = 0.5;
1573
+ var SPECIALTY_ABS_MARGIN = 3;
1574
+ function resumeSpecialties(resume) {
1575
+ const text = resumeRoleText(resume).toLowerCase();
1576
+ const scored = SPECIALTIES.map((sp) => {
1577
+ let score = 0;
1578
+ for (const sig of SPECIALTY_SIGNATURES[sp]) score += countOccurrences(text, sig);
1579
+ return { sp, score };
1580
+ }).sort((a, b) => b.score - a.score);
1581
+ const top = scored[0];
1582
+ if (!top || top.score < SPECIALTY_MIN_SCORE) return [];
1583
+ const margin = Math.min(SPECIALTY_ABS_MARGIN, Math.max(TOP_MARGIN_FACTOR * top.score, 1));
1584
+ const threshold = top.score - margin;
1585
+ return scored.filter((s) => s.score >= threshold).map((s) => s.sp);
1586
+ }
1587
+ function resumeRoleText(resume) {
1588
+ const parts = [];
1589
+ if (resume.basics?.label) parts.push(resume.basics.label);
1590
+ if (resume.basics?.summary) parts.push(resume.basics.summary);
1591
+ for (const w of resume.work ?? []) {
1592
+ if (w.position) parts.push(w.position);
1593
+ if (w.summary) parts.push(w.summary);
1594
+ parts.push(...w.highlights ?? []);
1595
+ }
1596
+ for (const s of resume.skills ?? []) {
1597
+ if (s.name) parts.push(s.name);
1598
+ parts.push(...s.keywords ?? []);
1599
+ }
1600
+ return parts.join(" ");
1601
+ }
1602
+
1433
1603
  // src/ats/checks/match.ts
1434
1604
  var SENIORITY = /\b(junior|senior|lead|staff|principal|head of|vp|chief)\b/gi;
1435
1605
  var STOPWORDS = /* @__PURE__ */ new Set([
@@ -1470,7 +1640,7 @@ function extractJdTitle(jd) {
1470
1640
  );
1471
1641
  return fallback ? trimTitle(fallback) : void 0;
1472
1642
  }
1473
- var titleAlignment = (resume, _l, { jobDescription }) => {
1643
+ var titleAlignment = (resume, _l, { jobDescription, jobTitle }) => {
1474
1644
  if (!jobDescription) {
1475
1645
  return {
1476
1646
  id: "title-alignment",
@@ -1483,16 +1653,16 @@ var titleAlignment = (resume, _l, { jobDescription }) => {
1483
1653
  };
1484
1654
  }
1485
1655
  const resumeTitle = resume.work?.[0]?.position || resume.basics?.label;
1486
- const jdTitle = extractJdTitle(jobDescription);
1656
+ const jdTitle = jobTitle?.trim() ? trimTitle(jobTitle) : extractJdTitle(jobDescription);
1487
1657
  if (!resumeTitle || !jdTitle) {
1488
1658
  return {
1489
1659
  id: "title-alignment",
1490
1660
  tier: "match",
1491
1661
  weight: "high",
1492
- status: "warn",
1493
- score: 50,
1494
- message: "Could not extract title from JD or resume.",
1495
- hints: ["Set basics.label to your target title."]
1662
+ status: "skipped",
1663
+ score: 0,
1664
+ message: "Could not extract a title from the JD or resume.",
1665
+ hints: ["Set basics.label to your target title, or supply the posting title."]
1496
1666
  };
1497
1667
  }
1498
1668
  const j = jaccard(tokenize(resumeTitle), tokenize(jdTitle));
@@ -1618,7 +1788,81 @@ var hardSkillOverlap = (resume, language, { jobDescription }) => {
1618
1788
  hints: status === "pass" ? [] : km.missing.slice(0, 5).map((s) => `Add evidence of "${s}" to skills/highlights.`)
1619
1789
  };
1620
1790
  };
1621
- var allMatchChecks = [hardSkillOverlap, titleAlignment, educationLevel, yoeMatch];
1791
+ var roleFamilyMatch = (resume, _l, { jobDescription, jobTitle }) => {
1792
+ if (!jobDescription) {
1793
+ return {
1794
+ id: "role-family-match",
1795
+ tier: "match",
1796
+ weight: "high",
1797
+ status: "skipped",
1798
+ score: 0,
1799
+ message: "No JD.",
1800
+ hints: []
1801
+ };
1802
+ }
1803
+ const jd = classifyRoleFamily(jobDescription, jobTitle ?? "");
1804
+ const cv = classifyRoleFamily(resumeRoleText(resume));
1805
+ if (!jd || !cv) {
1806
+ return {
1807
+ id: "role-family-match",
1808
+ tier: "match",
1809
+ weight: "high",
1810
+ status: "skipped",
1811
+ score: 0,
1812
+ message: "Could not confidently classify the role family of the JD or resume.",
1813
+ hints: []
1814
+ };
1815
+ }
1816
+ if (jd.family !== cv.family) {
1817
+ return {
1818
+ id: "role-family-match",
1819
+ tier: "match",
1820
+ weight: "high",
1821
+ status: "fail",
1822
+ score: 0,
1823
+ message: `Role mismatch: ${cv.family} resume vs ${jd.family} role.`,
1824
+ hints: [
1825
+ `This posting reads as a ${jd.family} role; your resume reads as ${cv.family}. Likely not a fit.`
1826
+ ]
1827
+ };
1828
+ }
1829
+ if (cv.family === "engineering") {
1830
+ const jdSpec = classifySpecialty(jobDescription, jobTitle ?? "");
1831
+ if (jdSpec) {
1832
+ const cvTopSpecs = resumeSpecialties(resume);
1833
+ const satisfied = cvTopSpecs.length === 0 || cvTopSpecs.includes(jdSpec);
1834
+ if (!satisfied) {
1835
+ return {
1836
+ id: "role-family-match",
1837
+ tier: "match",
1838
+ weight: "high",
1839
+ status: "fail",
1840
+ score: 0,
1841
+ message: `Specialty mismatch: ${jdSpec} role, resume specializes in ${cvTopSpecs[0] ?? "general engineering"}.`,
1842
+ hints: [
1843
+ `This is a ${jdSpec} engineering role; your resume doesn't show ${jdSpec} depth. Likely not a fit.`
1844
+ ]
1845
+ };
1846
+ }
1847
+ }
1848
+ }
1849
+ return {
1850
+ id: "role-family-match",
1851
+ tier: "match",
1852
+ weight: "high",
1853
+ status: "pass",
1854
+ score: 100,
1855
+ message: `Role family aligned (${cv.family}).`,
1856
+ hints: []
1857
+ };
1858
+ };
1859
+ var allMatchChecks = [
1860
+ hardSkillOverlap,
1861
+ titleAlignment,
1862
+ roleFamilyMatch,
1863
+ educationLevel,
1864
+ yoeMatch
1865
+ ];
1622
1866
  var KNOCKOUTS = [
1623
1867
  {
1624
1868
  signal: "work-auth",
@@ -1807,19 +2051,26 @@ function analyzeAts(resume, options = {}) {
1807
2051
  const recruiter = buildTier("recruiter", recruiterChecks, cfg);
1808
2052
  let match;
1809
2053
  let knockouts = [];
2054
+ let roleMismatch = false;
1810
2055
  if (options.jobDescription) {
1811
2056
  const matchChecks = allMatchChecks.map(
1812
- (fn) => fn(resume, language, { jobDescription: options.jobDescription })
2057
+ (fn) => fn(resume, language, { jobDescription: options.jobDescription, jobTitle: options.jobTitle })
1813
2058
  );
1814
2059
  match = buildTier("match", matchChecks, cfg);
2060
+ roleMismatch = matchChecks.some(
2061
+ (c) => c.id === "role-family-match" && c.status === "fail"
2062
+ );
1815
2063
  knockouts = extractKnockouts(resume, options.jobDescription);
1816
2064
  }
1817
- const totalScore = computeTotalScore(
2065
+ let totalScore = computeTotalScore(
1818
2066
  { parsing: parsing.score, match: match?.score, recruiter: recruiter.score },
1819
2067
  cfg.weights.tiers
1820
2068
  );
2069
+ const capped = roleMismatch && totalScore > ROLE_MISMATCH_CAP;
2070
+ if (capped) totalScore = ROLE_MISMATCH_CAP;
1821
2071
  const rating = scoreToRating(totalScore, cfg.thresholds.rating);
1822
- const summary = generateSummary(totalScore, rating, !!options.jobDescription, knockouts.length);
2072
+ let summary = generateSummary(totalScore, rating, !!options.jobDescription, knockouts.length);
2073
+ if (capped) summary += " Score capped: resume role family does not match this posting.";
1823
2074
  return {
1824
2075
  score: totalScore,
1825
2076
  rating,
@@ -1828,9 +2079,10 @@ function analyzeAts(resume, options = {}) {
1828
2079
  summary
1829
2080
  };
1830
2081
  }
2082
+ var ROLE_MISMATCH_CAP = 45;
1831
2083
 
1832
2084
  export {
1833
2085
  loadConfig,
1834
2086
  analyzeAts
1835
2087
  };
1836
- //# sourceMappingURL=chunk-N55EPZ2N.js.map
2088
+ //# sourceMappingURL=chunk-C2JG5KF4.js.map