@pranavraut033/ats-checker 1.0.5 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,12 +1,33 @@
1
- # ats-checker
1
+ # @pranavraut033/ats-checker
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@pranavraut033/ats-checker.svg)](https://www.npmjs.com/package/@pranavraut033/ats-checker)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/@pranavraut033/ats-checker.svg)](https://www.npmjs.com/package/@pranavraut033/ats-checker)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
- [![Build Status](https://github.com/Pranavraut033/ats-checker/actions/workflows/deploy.yml/badge.svg)](https://github.com/Pranavraut033/ats-checker/actions/workflows/deploy.yml)
7
6
  [![Tests](https://github.com/Pranavraut033/ats-checker/actions/workflows/ci.yml/badge.svg)](https://github.com/Pranavraut033/ats-checker/actions/workflows/ci.yml)
7
+ [![Build Status](https://github.com/Pranavraut033/ats-checker/actions/workflows/deploy.yml/badge.svg)](https://github.com/Pranavraut033/ats-checker/actions/workflows/deploy.yml)
8
+
9
+ Zero-dependency TypeScript library that scores a resume against a job description and explains why — skills coverage, keyword overlap, experience match, and education — with no randomness, no LLM, and no external calls.
10
+
11
+ **[Live Demo →](https://pranavraut033.github.io/ats-checker/)**
12
+ **[Docs →](https://pranavraut033.github.io/ats-checker/docs/)**
13
+
14
+ ---
15
+
16
+ ## Features
8
17
 
9
- A zero-dependency TypeScript library for evaluating resume compatibility with Applicant Tracking Systems (ATS). It parses resumes and job descriptions, calculates a deterministic score from 0 to 100, and provides actionable feedback to improve match rates.
18
+ - **Deterministic** same input always produces the same score; pin it with `referenceDate` to freeze "Present" date math
19
+ - **Explainable** — breakdown by category (skills / experience / keywords / education) plus matched and missing skill/keyword lists
20
+ - **Configurable** — adjust weights, add skill aliases, define custom penalty rules
21
+ - **Zero dependencies** — core library has no runtime deps; ships ESM + CJS
22
+ - **Built-in profiles** — software engineer, data scientist, product manager out of the box
23
+
24
+ ---
25
+
26
+ ## Requirements
27
+
28
+ - Node.js ≥ 18
29
+
30
+ ---
10
31
 
11
32
  ## Installation
12
33
 
@@ -14,173 +35,188 @@ A zero-dependency TypeScript library for evaluating resume compatibility with Ap
14
35
  npm install @pranavraut033/ats-checker
15
36
  ```
16
37
 
38
+ ---
39
+
17
40
  ## Usage
18
41
 
19
42
  ```typescript
20
43
  import { analyzeResume } from "@pranavraut033/ats-checker";
21
44
 
22
45
  const result = analyzeResume({
23
- resumeText: `John Doe
24
- Software Engineer with 5 years of experience in JavaScript and React.`,
25
- jobDescription: `We are looking for a software engineer with JavaScript experience.`
46
+ resumeText: `
47
+ Software Engineer with 5 years of experience.
48
+ Skills: JavaScript, TypeScript, React, Node.js, SQL
49
+ Experience: Senior Engineer at ExampleCorp (Jan 2020 - Present)
50
+ Education: B.S. Computer Science
51
+ `,
52
+ jobDescription: `
53
+ Frontend engineer role. Must have React, TypeScript, accessibility best practices.
54
+ Preferred: GraphQL. 3+ years required. Bachelor's degree required.
55
+ `,
56
+ config: { referenceDate: "2026-01-01" }, // freeze clock for reproducible scores
26
57
  });
27
58
 
28
- console.log(result.score); // 78
29
- console.log(result.breakdown.skills); // 85
30
- console.log(result.suggestions); // ["Add more specific JavaScript frameworks", ...]
59
+ console.log(result.score); // e.g. 72.45
60
+ console.log(result.matchedSkills); // ["javascript", "node", "react", "typescript"]
61
+ console.log(result.missingSkills); // ["accessibility best practices", "graphql"]
62
+ console.log(result.experienceGap); // 0 (requirement met)
63
+ console.log(result.suggestions); // ["Add GraphQL to your skills section", ...]
31
64
  ```
32
65
 
33
- ### LLM (Async) Usage
66
+ ---
34
67
 
35
- Note: `expandAliases()` is deprecated — prefer `normalizeSkills()` or `skillMatched()` for normalizing and matching skill names.
68
+ ## Output
36
69
 
70
+ `analyzeResume()` returns an `ATSAnalysisResult`:
37
71
 
72
+ | Field | Type | Description |
73
+ |---|---|---|
74
+ | `score` | `number` | Overall ATS score 0–100 after rule penalties |
75
+ | `breakdown` | `ATSBreakdown` | Sub-scores: `skills`, `experience`, `keywords`, `education` |
76
+ | `matchedSkills` | `string[]` | Required skills found in the resume |
77
+ | `missingSkills` | `string[]` | Required skills absent from the resume |
78
+ | `matchedKeywords` | `string[]` | JD keywords present in the resume (sorted) |
79
+ | `missingKeywords` | `string[]` | JD keywords absent from the resume (sorted) |
80
+ | `overusedKeywords` | `string[]` | Keywords exceeding density threshold (sorted) |
81
+ | `suggestions` | `string[]` | Deterministic improvement recommendations |
82
+ | `warnings` | `string[]` | Parse warnings and section alerts |
83
+ | `experienceGap` | `number` | Years below JD minimum; `0` when met |
84
+ | `detectedSections` | `string[]` | Resume sections the parser found |
85
+ | `parsedExperienceYears` | `number` | Total years from resume date ranges |
38
86
 
39
- For AI-enhanced suggestions while keeping scores deterministic, use the async API:
87
+ **Scoring formula:**
88
+ `score = skills×0.30 + experience×0.30 + keywords×0.25 + education×0.15` → clamped to 0–100 → rule penalties subtracted.
40
89
 
41
- ```typescript
42
- import { analyzeResumeAsync } from "@pranavraut033/ats-checker";
43
-
44
- const myLLMClient = /* implement LLMClient (OpenAI/Anthropic/local) */;
45
-
46
- const result = await analyzeResumeAsync({
47
- resumeText: "...",
48
- jobDescription: "...",
49
- llm: {
50
- client: myLLMClient,
51
- models: { default: "gpt-4o-mini" },
52
- limits: { maxCalls: 3, maxTokensPerCall: 1000, maxTotalTokens: 5000 },
53
- enable: { suggestions: true }
54
- }
55
- });
56
-
57
- console.log(result.score); // unchanged by LLM
58
- console.log(result.suggestions); // enhanced wording/context
59
- ```
60
-
61
- Note: Passing `llm` to `analyzeResume` (sync) will add a warning and skip enhancement. Prefer `analyzeResumeAsync` for LLM features.
90
+ ---
62
91
 
63
92
  ## Configuration
64
93
 
65
- Adjust scoring priorities, define skill synonyms, and add custom rules:
94
+ All options are optional. Pass any subset; `resolveConfig()` fills in defaults.
66
95
 
67
96
  ```typescript
68
97
  const result = analyzeResume({
69
98
  resumeText: "...",
70
99
  jobDescription: "...",
71
100
  config: {
101
+ // Override scoring weights (auto-normalized to sum to 1)
72
102
  weights: { skills: 0.4, experience: 0.3, keywords: 0.2, education: 0.1 },
73
- skillAliases: { "javascript": ["js", "ecmascript"] },
103
+
104
+ // Additional skill synonyms merged over built-in defaults
105
+ skillAliases: { javascript: ["js", "ecmascript"] },
106
+
107
+ // Industry profile: sets mandatory/optional skills and minExperience
108
+ profile: {
109
+ mandatorySkills: ["javascript", "react"],
110
+ optionalSkills: ["graphql", "docker"],
111
+ minExperience: 3,
112
+ },
113
+
114
+ // Freeze "Present" end dates for reproducible experience scoring
115
+ referenceDate: "2026-01-01",
116
+
117
+ // Keyword density thresholds
118
+ keywordDensity: { min: 0.0025, max: 0.04, overusePenalty: 5 },
119
+
120
+ // Custom penalty rules
74
121
  rules: [
75
122
  {
76
- id: "min-years",
77
- penalty: 5,
78
- warning: "Less than 3 years experience",
79
- condition: (ctx) => (ctx.resume.experienceYears ?? 0) < 3
123
+ id: "no-tables",
124
+ penalty: 10,
125
+ warning: "Remove tables ATS parsers often mangle them",
126
+ condition: (ctx) => ctx.resume.detectedSections.length < 2,
80
127
  },
81
128
  {
82
- id: "require-contact",
83
- penalty: 2,
84
- warning: "Add phone/email to contact info",
85
- condition: (ctx) => !ctx.resume.contactInfo?.phone || !ctx.resume.contactInfo?.email
86
- }
87
- ]
88
- }
129
+ id: "experience-gap",
130
+ penalty: 5,
131
+ warning: "Resume has less than 3 years experience",
132
+ condition: (ctx) => ctx.resume.totalExperienceYears < 3,
133
+ },
134
+ ],
135
+ },
89
136
  });
90
137
  ```
91
138
 
92
- See [Configuration](docs/configuration.md) for complete options.
139
+ ### Defaults
140
+
141
+ | Setting | Default |
142
+ |---|---|
143
+ | `weights.skills` | `0.30` |
144
+ | `weights.experience` | `0.30` |
145
+ | `weights.keywords` | `0.25` |
146
+ | `weights.education` | `0.15` |
147
+ | `keywordDensity.min` | `0.0025` |
148
+ | `keywordDensity.max` | `0.04` |
149
+ | `keywordDensity.overusePenalty` | `5` |
150
+ | `allowPartialMatches` | `true` |
151
+ | `referenceDate` | Current date (use explicit ISO string for determinism) |
152
+
153
+ See [Configuration docs](https://pranavraut033.github.io/ats-checker/docs/configuration/) for all options.
93
154
 
94
- ### Configuration Defaults
155
+ ---
95
156
 
96
- - Weights: skills 0.3, experience 0.3, keywords 0.25, education 0.15 (normalized)
97
- - Keyword density: min 0.0025, max 0.04, overusePenalty 5
98
- - Section penalties: summary 4, experience 10, skills 8, education 6
99
- - Partial matches: `allowPartialMatches: true`
157
+ ## Built-in Skill Aliases
100
158
 
101
- All user config is merged via `resolveConfig()` and weights are normalized to sum to 1.0.
159
+ Common tech synonyms are pre-loaded so `js` matches `javascript`, `k8s` matches `kubernetes`, etc. Extend or override via `config.skillAliases`.
102
160
 
103
- ### Custom Rules
161
+ ```typescript
162
+ import { defaultSkillAliases } from "@pranavraut033/ats-checker";
163
+ // { javascript: ["js"], node: ["node.js", "nodejs"], typescript: ["ts"], ... }
164
+ ```
165
+
166
+ ---
104
167
 
105
- Add penalties/warnings via rule conditions:
168
+ ## Built-in Profiles
106
169
 
107
170
  ```typescript
171
+ import {
172
+ softwareEngineerProfile,
173
+ dataScientistProfile,
174
+ productManagerProfile,
175
+ } from "@pranavraut033/ats-checker";
176
+
108
177
  const result = analyzeResume({
109
178
  resumeText: "...",
110
179
  jobDescription: "...",
111
- config: {
112
- rules: [
113
- {
114
- id: "min-years",
115
- penalty: 5,
116
- warning: "Less than 3 years experience",
117
- condition: (ctx) => (ctx.resume.experienceYears ?? 0) < 3
118
- },
119
- {
120
- id: "require-contact",
121
- penalty: 2,
122
- warning: "Add phone/email to contact info",
123
- condition: (ctx) => !ctx.resume.contactInfo?.phone || !ctx.resume.contactInfo?.email
124
- }
125
- ]
126
- }
180
+ config: { profile: softwareEngineerProfile },
127
181
  });
128
182
  ```
129
- See [Rules Engine](docs/rules.md) for default rules and context fields.
130
-
131
- ## Features
132
183
 
133
- - Deterministic scoring based on skills, experience, keywords, and education
134
- - Detects common ATS issues like missing sections or keyword overuse
135
- - Customizable scoring weights and validation rules
136
- - Optional LLM integration for enhanced suggestions
137
- - Includes a web interface for testing (`npm run dev`)
138
- - [Live Demo](https://pranavraut033.github.io/ats-checker/)
184
+ ---
139
185
 
140
- ## API
186
+ ## LLM Integration (deprecated)
141
187
 
142
- ### `analyzeResume(input: AnalyzeResumeInput): ATSAnalysisResult`
188
+ `analyzeResumeAsync` accepts an optional `llm` config that rewrites suggestion text via a caller-supplied LLM client. **This path is deprecated** — scores and breakdowns are never touched by LLM. Prefer calling `analyzeResume` and running your own LLM pass on `result.suggestions` if you want AI-enhanced wording.
143
189
 
144
- Analyzes a resume against a job description.
145
-
146
- **Input:**
147
- - `resumeText: string` - The full text of the resume
148
- - `jobDescription: string` - The job description text
149
- - `config?: ATSConfig` - Optional configuration overrides
150
-
151
- **Output:**
152
- - `score: number` - Overall ATS score (0-100)
153
- - `breakdown: ATSBreakdown` - Component scores
154
- - `matchedKeywords: string[]` - Keywords found in both
155
- - `missingKeywords: string[]` - Important keywords not in resume
156
- - `suggestions: string[]` - Improvement recommendations
157
- - `warnings: string[]` - Issues detected
190
+ ---
158
191
 
159
192
  ## Development
160
193
 
161
194
  ```bash
162
195
  npm install
163
- npm run build # Build to dist/
164
- npm test # Run tests
165
- npm run dev # Start web UI at http://localhost:3005
196
+ npm run build # tsup ESM + CJS in dist/
197
+ npm test # vitest (single pass)
198
+ npm run type-check # tsc --noEmit
199
+ npm run dev # static demo UI at http://localhost:3005
166
200
  ```
167
201
 
202
+ ---
203
+
168
204
  ## Documentation
169
205
 
170
- **Live Docs** (hosted on GitHub Pages):
171
- - https://Pranavraut033.github.io/ats-checker/docs/
206
+ Full docs at **[pranavraut033.github.io/ats-checker/docs/](https://pranavraut033.github.io/ats-checker/docs/)**
172
207
 
173
- **Local Docs** (in repository):
174
- - [Configuration Guide](docs/configuration.md)
175
- - [LLM Integration](docs/llm-integration.md)
176
- - [Web Interface](docs/ui.md)
177
208
  - [Architecture](docs/architecture.md)
209
+ - [Configuration](docs/configuration.md)
210
+ - [Rules Engine](docs/rules.md)
211
+
212
+ ---
178
213
 
179
214
  ## Contributing
180
215
 
181
- Contributions are welcome! Please see the [Contributing Guide](https://github.com/Pranavraut033/ats-checker/blob/main/CONTRIBUTING.md) for details.
216
+ See [CONTRIBUTING.md](CONTRIBUTING.md). PRs welcome.
182
217
 
183
- ## License
218
+ ---
184
219
 
185
- MIT
220
+ ## License
186
221
 
222
+ MIT © [Pranav Raut](https://github.com/Pranavraut033)
package/dist/index.d.mts CHANGED
@@ -79,6 +79,13 @@ interface ATSConfig {
79
79
  keywordDensity?: KeywordDensityConfig;
80
80
  sectionPenalties?: SectionPenaltyConfig;
81
81
  allowPartialMatches?: boolean;
82
+ /**
83
+ * ISO date string (e.g. "2024-06-01") used as the "today" reference when
84
+ * computing duration for open-ended date ranges ("Present"/"Current"/"Now").
85
+ * Omit to use the actual current date (live/production behaviour).
86
+ * Set to a fixed value in tests or batch processing to guarantee determinism.
87
+ */
88
+ referenceDate?: string;
82
89
  }
83
90
  interface NormalizedWeights extends ATSWeights {
84
91
  /** Weights normalized so they sum to 1. */
@@ -92,6 +99,8 @@ interface ResolvedATSConfig {
92
99
  keywordDensity: KeywordDensityConfig;
93
100
  sectionPenalties: Required<SectionPenaltyConfig>;
94
101
  allowPartialMatches: boolean;
102
+ /** Resolved reference date for "Present" duration calculations. */
103
+ referenceDate?: Date;
95
104
  }
96
105
  interface RuleContext {
97
106
  resume: ParsedResume;
@@ -224,11 +233,21 @@ interface AnalyzeResumeInput {
224
233
  interface ATSAnalysisResult {
225
234
  score: number;
226
235
  breakdown: ATSBreakdown;
236
+ /** Skills found in the resume that satisfy JD + profile requirements. */
237
+ matchedSkills: string[];
238
+ /** Required skills absent from the resume. */
239
+ missingSkills: string[];
227
240
  matchedKeywords: string[];
228
241
  missingKeywords: string[];
229
242
  overusedKeywords: string[];
230
243
  suggestions: string[];
231
244
  warnings: string[];
245
+ /** Years below the JD's minimum experience requirement; 0 when the requirement is met. */
246
+ experienceGap: number;
247
+ /** Resume sections the parser successfully detected (e.g. "summary", "skills"). */
248
+ detectedSections: string[];
249
+ /** Total years of experience parsed from the resume's date ranges. */
250
+ parsedExperienceYears: number;
232
251
  }
233
252
 
234
253
  declare const defaultSkillAliases: SkillAliases;
@@ -471,18 +490,12 @@ declare function safeExtractNumber(obj: unknown, key: string): number | undefine
471
490
  */
472
491
  declare function analyzeResume(input: AnalyzeResumeInput): ATSAnalysisResult;
473
492
  /**
474
- * Async version: Analyze a resume with full LLM support
475
- * This version properly handles async LLM calls
493
+ * @deprecated The LLM layer only rewrites suggestion text and adds non-determinism.
494
+ * Prefer `analyzeResume` (sync, deterministic) and call your own LLM on the result if needed.
495
+ * This function will be removed in a future major version.
476
496
  *
477
497
  * @param input Resume, job description, and optional LLM config
478
498
  * @returns Promise<ATSAnalysisResult>
479
- *
480
- * @example
481
- * const result = await analyzeResumeAsync({
482
- * resumeText,
483
- * jobDescription,
484
- * llm: { client, limits: {...}, enable: { suggestions: true } }
485
- * });
486
499
  */
487
500
  declare function analyzeResumeAsync(input: AnalyzeResumeInput): Promise<ATSAnalysisResult>;
488
501
 
package/dist/index.d.ts CHANGED
@@ -79,6 +79,13 @@ interface ATSConfig {
79
79
  keywordDensity?: KeywordDensityConfig;
80
80
  sectionPenalties?: SectionPenaltyConfig;
81
81
  allowPartialMatches?: boolean;
82
+ /**
83
+ * ISO date string (e.g. "2024-06-01") used as the "today" reference when
84
+ * computing duration for open-ended date ranges ("Present"/"Current"/"Now").
85
+ * Omit to use the actual current date (live/production behaviour).
86
+ * Set to a fixed value in tests or batch processing to guarantee determinism.
87
+ */
88
+ referenceDate?: string;
82
89
  }
83
90
  interface NormalizedWeights extends ATSWeights {
84
91
  /** Weights normalized so they sum to 1. */
@@ -92,6 +99,8 @@ interface ResolvedATSConfig {
92
99
  keywordDensity: KeywordDensityConfig;
93
100
  sectionPenalties: Required<SectionPenaltyConfig>;
94
101
  allowPartialMatches: boolean;
102
+ /** Resolved reference date for "Present" duration calculations. */
103
+ referenceDate?: Date;
95
104
  }
96
105
  interface RuleContext {
97
106
  resume: ParsedResume;
@@ -224,11 +233,21 @@ interface AnalyzeResumeInput {
224
233
  interface ATSAnalysisResult {
225
234
  score: number;
226
235
  breakdown: ATSBreakdown;
236
+ /** Skills found in the resume that satisfy JD + profile requirements. */
237
+ matchedSkills: string[];
238
+ /** Required skills absent from the resume. */
239
+ missingSkills: string[];
227
240
  matchedKeywords: string[];
228
241
  missingKeywords: string[];
229
242
  overusedKeywords: string[];
230
243
  suggestions: string[];
231
244
  warnings: string[];
245
+ /** Years below the JD's minimum experience requirement; 0 when the requirement is met. */
246
+ experienceGap: number;
247
+ /** Resume sections the parser successfully detected (e.g. "summary", "skills"). */
248
+ detectedSections: string[];
249
+ /** Total years of experience parsed from the resume's date ranges. */
250
+ parsedExperienceYears: number;
232
251
  }
233
252
 
234
253
  declare const defaultSkillAliases: SkillAliases;
@@ -471,18 +490,12 @@ declare function safeExtractNumber(obj: unknown, key: string): number | undefine
471
490
  */
472
491
  declare function analyzeResume(input: AnalyzeResumeInput): ATSAnalysisResult;
473
492
  /**
474
- * Async version: Analyze a resume with full LLM support
475
- * This version properly handles async LLM calls
493
+ * @deprecated The LLM layer only rewrites suggestion text and adds non-determinism.
494
+ * Prefer `analyzeResume` (sync, deterministic) and call your own LLM on the result if needed.
495
+ * This function will be removed in a future major version.
476
496
  *
477
497
  * @param input Resume, job description, and optional LLM config
478
498
  * @returns Promise<ATSAnalysisResult>
479
- *
480
- * @example
481
- * const result = await analyzeResumeAsync({
482
- * resumeText,
483
- * jobDescription,
484
- * llm: { client, limits: {...}, enable: { suggestions: true } }
485
- * });
486
499
  */
487
500
  declare function analyzeResumeAsync(input: AnalyzeResumeInput): Promise<ATSAnalysisResult>;
488
501
 
package/dist/index.js CHANGED
@@ -37,13 +37,15 @@ function normalizeWhitespace(text) {
37
37
  return text.replace(/\r\n?/g, "\n").replace(/\s+/g, " ").trim();
38
38
  }
39
39
  function normalizeForComparison(text) {
40
- return normalizeWhitespace(text).toLowerCase();
40
+ return normalizeWhitespace(text).normalize("NFKC").toLowerCase();
41
41
  }
42
42
  function splitLines(text) {
43
43
  return text.replace(/\r\n?/g, "\n").split("\n").map((line) => line.trim()).filter(Boolean);
44
44
  }
45
+ var TECH_TOKEN_RE = /[a-z0-9][a-z0-9.#+\-/]*[a-z0-9#+]/g;
45
46
  function tokenize(text) {
46
- return normalizeForComparison(text).split(/[^a-z0-9+]+/i).map((word) => word.trim()).filter((word) => word.length > 1 && !STOP_WORDS.has(word));
47
+ const normalized = normalizeForComparison(text);
48
+ return (normalized.match(TECH_TOKEN_RE) ?? []).filter((t) => !STOP_WORDS.has(t));
47
49
  }
48
50
  function unique(values) {
49
51
  const seen = /* @__PURE__ */ new Set();
@@ -217,7 +219,7 @@ function monthsBetween(start, end) {
217
219
  const endMonth = end.month ?? 12;
218
220
  return (end.year - start.year) * 12 + (endMonth - startMonth + 1);
219
221
  }
220
- function parseDateRange(text) {
222
+ function parseDateRange(text, referenceDate) {
221
223
  const normalized = text.trim();
222
224
  const rangeMatch = normalized.match(/([A-Za-z]{3,9}\s+\d{4}|\d{4})\s*(?:-|to|–|—)\s*(Present|Current|Now|[A-Za-z]{3,9}\s+\d{4}|\d{4})/i);
223
225
  if (!rangeMatch) {
@@ -230,9 +232,10 @@ function parseDateRange(text) {
230
232
  if (!startToken) {
231
233
  return null;
232
234
  }
235
+ const ref = referenceDate ?? /* @__PURE__ */ new Date();
233
236
  const endTokenResolved = endToken ?? {
234
- year: (/* @__PURE__ */ new Date()).getFullYear(),
235
- month: (/* @__PURE__ */ new Date()).getMonth() + 1
237
+ year: ref.getFullYear(),
238
+ month: ref.getMonth() + 1
236
239
  };
237
240
  const durationInMonths = monthsBetween(startToken, endTokenResolved);
238
241
  return {
@@ -327,7 +330,7 @@ function parseActionVerbs(text) {
327
330
  const words = tokenize(text);
328
331
  return ACTION_VERBS.filter((verb) => words.includes(verb));
329
332
  }
330
- function parseExperience(sectionContent) {
333
+ function parseExperience(sectionContent, referenceDate) {
331
334
  if (!sectionContent) {
332
335
  return { entries: [], rangesInMonths: [], jobTitles: [] };
333
336
  }
@@ -336,7 +339,7 @@ function parseExperience(sectionContent) {
336
339
  const rangesInMonths = [];
337
340
  const jobTitles = [];
338
341
  for (const line of lines) {
339
- const range = parseDateRange(line);
342
+ const range = parseDateRange(line, referenceDate);
340
343
  if (range) {
341
344
  const previous = entries[entries.length - 1];
342
345
  if (previous && !previous.dates) {
@@ -376,7 +379,7 @@ function parseResume(resumeText, config) {
376
379
  const { sections, detected } = extractSections(resumeText);
377
380
  const skills = parseSkills(sections.skills, config.skillAliases);
378
381
  const actionVerbs = parseActionVerbs(normalizedText);
379
- const experienceData = parseExperience(sections.experience);
382
+ const experienceData = parseExperience(sections.experience, config.referenceDate);
380
383
  const educationEntries = parseEducation(sections.education);
381
384
  const totalExperienceYears = sumExperienceYears(
382
385
  experienceData.entries.map((entry) => entry.dates).filter((range) => Boolean(range))
@@ -500,8 +503,9 @@ function scoreSkills(resume, job, config) {
500
503
  0,
501
504
  100
502
505
  );
503
- const missing = [...required].filter((skill) => !resumeSkills.has(skill));
504
- return { score, missing };
506
+ const matched = [...required].filter((skill) => resumeSkills.has(skill)).sort();
507
+ const missing = [...required].filter((skill) => !resumeSkills.has(skill)).sort();
508
+ return { score, matched, missing };
505
509
  }
506
510
  function scoreExperience(resume, job, config) {
507
511
  const requiredYears = job.minExperienceYears ?? config.profile?.minExperience ?? 0;
@@ -519,11 +523,15 @@ function scoreExperience(resume, job, config) {
519
523
  return { score, missingYears: Number(missingYears.toFixed(2)) };
520
524
  }
521
525
  function scoreKeywords(resume, job, config) {
522
- const jobKeywordSet = new Set(job.keywords.map((value) => value.toLowerCase()));
526
+ const jobKeywordSet = new Set(
527
+ job.keywords.map((k) => normalizeSkill(k, config.skillAliases))
528
+ );
523
529
  if (jobKeywordSet.size === 0) {
524
530
  return { score: 100, matchedKeywords: [], missingKeywords: [], overusedKeywords: [] };
525
531
  }
526
- const resumeTokens = tokenize(resume.normalizedText);
532
+ const resumeTokens = tokenize(resume.normalizedText).map(
533
+ (t) => normalizeSkill(t, config.skillAliases)
534
+ );
527
535
  const resumeTokenSet = new Set(resumeTokens);
528
536
  const matchedKeywords = [...jobKeywordSet].filter((keyword) => resumeTokenSet.has(keyword));
529
537
  const missingKeywords = [...jobKeywordSet].filter((keyword) => !resumeTokenSet.has(keyword));
@@ -537,9 +545,9 @@ function scoreKeywords(resume, job, config) {
537
545
  });
538
546
  return {
539
547
  score,
540
- matchedKeywords: unique(matchedKeywords),
541
- missingKeywords: unique(missingKeywords),
542
- overusedKeywords: unique(overusedKeywords)
548
+ matchedKeywords: unique(matchedKeywords).sort(),
549
+ missingKeywords: unique(missingKeywords).sort(),
550
+ overusedKeywords: unique(overusedKeywords).sort()
543
551
  };
544
552
  }
545
553
  function scoreEducation(resume, job) {
@@ -571,12 +579,17 @@ function calculateScore(resume, job, config) {
571
579
  return {
572
580
  score: clamp(Number(weightedScore.toFixed(2)), 0, 100),
573
581
  breakdown,
582
+ matchedSkills: skillsResult.matched,
583
+ missingSkills: skillsResult.missing,
574
584
  matchedKeywords: keywordResult.matchedKeywords,
575
585
  missingKeywords: keywordResult.missingKeywords,
576
586
  overusedKeywords: keywordResult.overusedKeywords,
577
587
  suggestions: [],
578
588
  warnings: [],
579
- missingSkills: skillsResult.missing,
589
+ // detectedSections / parsedExperienceYears / experienceGap: filled by index.ts
590
+ experienceGap: experienceResult.missingYears,
591
+ detectedSections: [],
592
+ parsedExperienceYears: 0,
580
593
  missingExperienceYears: experienceResult.missingYears,
581
594
  educationScore
582
595
  };
@@ -584,7 +597,9 @@ function calculateScore(resume, job, config) {
584
597
 
585
598
  // src/profiles/index.ts
586
599
  var defaultSkillAliases = {
587
- javascript: ["js", "node", "node.js", "nodejs"],
600
+ // ponytail: "node" split from javascript — Node.js runtime !== JS language
601
+ javascript: ["js"],
602
+ node: ["node.js", "nodejs"],
588
603
  typescript: ["ts"],
589
604
  react: ["reactjs", "react.js"],
590
605
  "c++": ["cpp"],
@@ -679,7 +694,8 @@ function resolveConfig(config = {}) {
679
694
  ...DEFAULT_SECTION_PENALTIES,
680
695
  ...config.sectionPenalties ?? {}
681
696
  },
682
- allowPartialMatches: config.allowPartialMatches ?? true
697
+ allowPartialMatches: config.allowPartialMatches ?? true,
698
+ referenceDate: config.referenceDate ? new Date(config.referenceDate) : void 0
683
699
  };
684
700
  return resolved;
685
701
  }
@@ -1376,9 +1392,14 @@ function analyzeResume(input) {
1376
1392
  return {
1377
1393
  score: finalScore,
1378
1394
  breakdown: scoring.breakdown,
1395
+ matchedSkills: scoring.matchedSkills,
1396
+ missingSkills: scoring.missingSkills,
1379
1397
  matchedKeywords: scoring.matchedKeywords,
1380
1398
  missingKeywords: scoring.missingKeywords,
1381
1399
  overusedKeywords: scoring.overusedKeywords,
1400
+ experienceGap: scoring.experienceGap,
1401
+ detectedSections: parsedResume.detectedSections,
1402
+ parsedExperienceYears: parsedResume.totalExperienceYears,
1382
1403
  suggestions,
1383
1404
  warnings: [...suggestionResult.warnings, ...llmWarnings]
1384
1405
  };
@@ -1435,9 +1456,14 @@ async function analyzeResumeAsync(input) {
1435
1456
  return {
1436
1457
  score: finalScore,
1437
1458
  breakdown: scoring.breakdown,
1459
+ matchedSkills: scoring.matchedSkills,
1460
+ missingSkills: scoring.missingSkills,
1438
1461
  matchedKeywords: scoring.matchedKeywords,
1439
1462
  missingKeywords: scoring.missingKeywords,
1440
1463
  overusedKeywords: scoring.overusedKeywords,
1464
+ experienceGap: scoring.experienceGap,
1465
+ detectedSections: parsedResume.detectedSections,
1466
+ parsedExperienceYears: parsedResume.totalExperienceYears,
1441
1467
  suggestions,
1442
1468
  warnings: [...suggestionResult.warnings, ...llmWarnings]
1443
1469
  };