resuml 3.0.0 → 3.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.
@@ -4,1442 +4,169 @@ var __export = (target, all) => {
4
4
  __defProp(target, name, { get: all[name], enumerable: true });
5
5
  };
6
6
 
7
- // src/core.ts
8
- import { parse } from "yaml";
9
- import merge from "lodash.merge";
10
- import { validate } from "@jsonresume/schema";
11
- async function processResumeData(yamlContents) {
12
- if (yamlContents.length === 0) {
13
- throw new Error("No YAML content provided for processing.");
14
- }
15
- const dataObjects = yamlContents.map((content) => {
16
- try {
17
- return parse(content);
18
- } catch (error) {
19
- console.warn("Failed to parse YAML content:", error);
20
- return null;
21
- }
22
- }).filter((data) => typeof data === "object" && data !== null);
23
- if (dataObjects.length === 0) {
24
- throw new Error("No valid YAML content found after parsing.");
25
- }
26
- const customizer = (objValue, srcValue) => {
27
- if (Array.isArray(objValue)) {
28
- return objValue.concat(srcValue);
29
- }
30
- return void 0;
31
- };
32
- const mergedData = dataObjects.reduce((acc, data) => merge(acc, data, customizer), {});
33
- return new Promise((resolve, reject) => {
34
- validate(mergedData, (errors, isValid) => {
35
- if (!isValid) {
36
- reject(
37
- new Error(`Resume data failed schema validation: ${JSON.stringify(errors, null, 2)}`)
38
- );
39
- } else {
40
- resolve(mergedData);
41
- }
42
- });
43
- });
7
+ // src/ats/skills/matcher.ts
8
+ function isTokenChar(ch) {
9
+ if (ch >= "a" && ch <= "z") return true;
10
+ if (ch >= "A" && ch <= "Z") return true;
11
+ if (ch >= "0" && ch <= "9") return true;
12
+ return ch === "." || ch === "/" || ch === "-" || ch === "+" || ch === "#" || ch === "_";
44
13
  }
45
-
46
- // src/utils/config.ts
47
- import fs from "fs";
48
- import path from "path";
49
- import yaml from "yaml";
50
- import { z } from "zod";
51
- import merge2 from "lodash.merge";
52
- var weightEnum = z.enum(["high", "medium", "low"]);
53
- var atsConfigSchema = z.object({
54
- weights: z.object({
55
- tiers: z.object({
56
- parsing: z.number().int().min(0).max(100),
57
- match: z.number().int().min(0).max(100),
58
- recruiter: z.number().int().min(0).max(100)
59
- }).partial().optional(),
60
- checks: z.record(z.string(), weightEnum).optional()
61
- }).partial().optional(),
62
- thresholds: z.object({
63
- rating: z.object({
64
- excellent: z.number(),
65
- good: z.number(),
66
- needsWork: z.number()
67
- }).partial().optional(),
68
- grade: z.object({ A: z.number(), B: z.number(), C: z.number(), D: z.number() }).partial().optional(),
69
- seniorYoeCutoff: z.number().int().min(0).optional(),
70
- wordCount: z.object({ min: z.number(), max: z.number(), seniorMax: z.number() }).partial().optional(),
71
- bulletsPerRole: z.object({ min: z.number(), max: z.number(), seniorMax: z.number() }).partial().optional()
72
- }).partial().optional(),
73
- disable: z.array(z.string()).optional(),
74
- locale: z.string().optional()
75
- });
76
- var fileSchema = z.object({ ats: atsConfigSchema.optional() });
77
- var defaultConfig = {
78
- weights: {
79
- tiers: { parsing: 30, match: 50, recruiter: 20 },
80
- checks: {}
81
- },
82
- thresholds: {
83
- rating: { excellent: 90, good: 75, needsWork: 60 },
84
- grade: { A: 90, B: 80, C: 70, D: 60 },
85
- seniorYoeCutoff: 10,
86
- wordCount: { min: 400, max: 800, seniorMax: 1600 },
87
- bulletsPerRole: { min: 3, max: 6, seniorMax: 10 }
88
- },
89
- disable: [],
90
- locale: "en"
91
- };
92
- function loadConfig(opts = {}) {
93
- const cwd = opts.cwd ?? process.cwd();
94
- const file = opts.configPath ?? path.join(cwd, "resuml.config.yaml");
95
- if (!fs.existsSync(file)) return defaultConfig;
96
- const raw = fs.readFileSync(file, "utf8");
97
- const parsed = yaml.parse(raw) ?? {};
98
- const result = fileSchema.safeParse(parsed);
99
- if (!result.success) {
100
- const issue = result.error.issues[0];
101
- const where = issue?.path.join(".") ?? "<root>";
102
- throw new Error(`Invalid resuml.config.yaml at "${where}": ${issue?.message}`);
103
- }
104
- return merge2({}, defaultConfig, result.data.ats ?? {});
14
+ function isLetter(ch) {
15
+ return ch >= "a" && ch <= "z" || ch >= "A" && ch <= "Z";
105
16
  }
106
- function effectiveWeight(checkId, defaultWeight, config) {
107
- return config.weights.checks[checkId] ?? defaultWeight;
17
+ function isDigit(ch) {
18
+ return ch >= "0" && ch <= "9";
108
19
  }
109
-
110
- // src/ats/checks/parsing.ts
111
- var ISO_DATE = /^\d{4}(-\d{2})?(-\d{2})?$/;
112
- var conventionalSections = (resume) => {
113
- const required = ["basics", "work", "education"];
114
- const missing = required.filter((s) => {
115
- const v = resume[s];
116
- if (Array.isArray(v)) return v.length === 0;
117
- return v === void 0;
118
- });
119
- const passed = missing.length === 0;
120
- return {
121
- id: "conventional-sections",
122
- tier: "parsing",
123
- weight: "high",
124
- status: passed ? "pass" : "fail",
125
- score: Math.round((required.length - missing.length) / required.length * 100),
126
- message: passed ? "All conventional sections present." : `Missing required sections: ${missing.join(", ")}.`,
127
- hints: passed ? [] : [`Add ${missing.join(", ")} to your YAML.`]
128
- };
129
- };
130
- var dateFormatConsistency = (resume) => {
131
- const all = [];
132
- for (const w of resume.work || []) {
133
- if (w.startDate) all.push({ date: w.startDate, where: `work "${w.name || ""}".startDate` });
134
- if (w.endDate) all.push({ date: w.endDate, where: `work "${w.name || ""}".endDate` });
135
- }
136
- for (const e of resume.education || []) {
137
- if (e.startDate)
138
- all.push({ date: e.startDate, where: `education "${e.institution || ""}".startDate` });
139
- if (e.endDate)
140
- all.push({ date: e.endDate, where: `education "${e.institution || ""}".endDate` });
141
- }
142
- if (all.length === 0) {
143
- return {
144
- id: "date-format-consistency",
145
- tier: "parsing",
146
- weight: "medium",
147
- status: "skipped",
148
- score: 0,
149
- message: "No dates to check.",
150
- hints: []
151
- };
152
- }
153
- const bad = all.filter((d) => !ISO_DATE.test(d.date));
154
- const passed = bad.length === 0;
155
- return {
156
- id: "date-format-consistency",
157
- tier: "parsing",
158
- weight: "medium",
159
- status: passed ? "pass" : bad.length <= 1 ? "warn" : "fail",
160
- score: Math.round((all.length - bad.length) / all.length * 100),
161
- message: passed ? "All dates use ISO-8601 format." : `Non-ISO dates: ${bad.slice(0, 3).map((b) => `${b.where}=${b.date}`).join("; ")}.`,
162
- hints: passed ? [] : ["Use YYYY-MM or YYYY-MM-DD for every date field."]
163
- };
164
- };
165
- var contactInBody = (resume) => {
166
- const b = resume.basics;
167
- const checks = [
168
- { ok: !!b?.name, field: "name" },
169
- { ok: !!b?.email, field: "email" },
170
- { ok: !!b?.phone, field: "phone" },
171
- { ok: !!b?.location?.city, field: "location.city" }
172
- ];
173
- const missing = checks.filter((c) => !c.ok).map((c) => c.field);
174
- const passed = missing.length === 0;
175
- return {
176
- id: "contact-in-body",
177
- tier: "parsing",
178
- weight: "high",
179
- status: passed ? "pass" : "fail",
180
- score: Math.round((checks.length - missing.length) / checks.length * 100),
181
- message: passed ? "Contact information present in basics." : `Missing contact fields: ${missing.join(", ")}.`,
182
- hints: passed ? [] : [`Add ${missing.join(", ")} to basics.`]
183
- };
184
- };
185
- var reverseChronOrder = (resume) => {
186
- const work = resume.work || [];
187
- if (work.length < 2) {
188
- return {
189
- id: "reverse-chron-order",
190
- tier: "parsing",
191
- weight: "medium",
192
- status: "skipped",
193
- score: 100,
194
- message: "Single or no work entry.",
195
- hints: []
196
- };
197
- }
198
- let outOfOrder = 0;
199
- for (let i = 0; i < work.length - 1; i++) {
200
- const curr = work[i];
201
- const next = work[i + 1];
202
- const a = curr?.startDate || "";
203
- const b = next?.startDate || "";
204
- if (a && b && a < b) outOfOrder++;
20
+ function trimTokenBoundary(tok) {
21
+ let start = 0;
22
+ let end = tok.length;
23
+ while (start < end) {
24
+ const ch = tok.charAt(start);
25
+ if (isLetter(ch) || isDigit(ch) || ch === "+" || ch === "#") break;
26
+ start++;
205
27
  }
206
- const passed = outOfOrder === 0;
207
- return {
208
- id: "reverse-chron-order",
209
- tier: "parsing",
210
- weight: "medium",
211
- status: passed ? "pass" : "fail",
212
- score: passed ? 100 : Math.max(0, 100 - outOfOrder * 50),
213
- message: passed ? "Work entries in reverse-chronological order." : `${outOfOrder} pair(s) out of reverse-chronological order.`,
214
- hints: passed ? [] : ["Reorder work[] so the most recent role is first."]
215
- };
216
- };
217
- var educationComplete = (resume) => {
218
- const edu = resume.education || [];
219
- if (edu.length === 0) {
220
- return {
221
- id: "education-complete",
222
- tier: "parsing",
223
- weight: "low",
224
- status: "fail",
225
- score: 0,
226
- message: "No education entries.",
227
- hints: ["Add at least one education entry with institution, area, and studyType."]
228
- };
28
+ while (end > start) {
29
+ const ch = tok.charAt(end - 1);
30
+ if (isLetter(ch) || isDigit(ch) || ch === "+" || ch === "#") break;
31
+ end--;
229
32
  }
230
- const incomplete = edu.filter((e) => !e.institution || !e.area || !e.studyType);
231
- const passed = incomplete.length === 0;
232
- return {
233
- id: "education-complete",
234
- tier: "parsing",
235
- weight: "low",
236
- status: passed ? "pass" : "fail",
237
- score: Math.round((edu.length - incomplete.length) / edu.length * 100),
238
- message: passed ? "All education entries complete." : `${incomplete.length} education entry(ies) missing institution/area/studyType.`,
239
- hints: passed ? [] : ["Fill in institution, area and studyType for every education entry."]
240
- };
241
- };
242
- var allParsingChecks = [
243
- conventionalSections,
244
- dateFormatConsistency,
245
- contactInBody,
246
- reverseChronOrder,
247
- educationComplete
248
- ];
249
-
250
- // src/ats/i18n/en.ts
251
- var en = {
252
- actionVerbs: [
253
- // Leadership & Management
254
- "achieved",
255
- "administered",
256
- "advanced",
257
- "allocated",
258
- "approved",
259
- "assigned",
260
- "authorized",
261
- "chaired",
262
- "consolidated",
263
- "coordinated",
264
- "delegated",
265
- "directed",
266
- "established",
267
- "executed",
268
- "headed",
269
- "hired",
270
- "hosted",
271
- "led",
272
- "managed",
273
- "mentored",
274
- "motivated",
275
- "orchestrated",
276
- "organized",
277
- "oversaw",
278
- "planned",
279
- "presided",
280
- "prioritized",
281
- "produced",
282
- "recruited",
283
- "spearheaded",
284
- "supervised",
285
- // Technical & Engineering
286
- "architected",
287
- "automated",
288
- "built",
289
- "coded",
290
- "configured",
291
- "debugged",
292
- "deployed",
293
- "designed",
294
- "developed",
295
- "devised",
296
- "engineered",
297
- "implemented",
298
- "installed",
299
- "integrated",
300
- "launched",
301
- "maintained",
302
- "migrated",
303
- "modernized",
304
- "optimized",
305
- "overhauled",
306
- "programmed",
307
- "prototyped",
308
- "refactored",
309
- "reengineered",
310
- "resolved",
311
- "restructured",
312
- "revamped",
313
- "scaled",
314
- "shipped",
315
- "standardized",
316
- "streamlined",
317
- "tested",
318
- "troubleshot",
319
- "upgraded",
320
- // Achievement & Impact
321
- "accelerated",
322
- "accomplished",
323
- "boosted",
324
- "completed",
325
- "contributed",
326
- "converted",
327
- "decreased",
328
- "delivered",
329
- "doubled",
330
- "earned",
331
- "eliminated",
332
- "exceeded",
333
- "expanded",
334
- "expedited",
335
- "generated",
336
- "grew",
337
- "improved",
338
- "increased",
339
- "maximized",
340
- "minimized",
341
- "outperformed",
342
- "pioneered",
343
- "recovered",
344
- "reduced",
345
- "saved",
346
- "simplified",
347
- "solved",
348
- "surpassed",
349
- "transformed",
350
- "tripled",
351
- // Communication & Collaboration
352
- "advised",
353
- "advocated",
354
- "briefed",
355
- "collaborated",
356
- "communicated",
357
- "consulted",
358
- "convinced",
359
- "counseled",
360
- "defined",
361
- "demonstrated",
362
- "documented",
363
- "educated",
364
- "facilitated",
365
- "guided",
366
- "influenced",
367
- "informed",
368
- "instructed",
369
- "liaised",
370
- "negotiated",
371
- "partnered",
372
- "persuaded",
373
- "presented",
374
- "promoted",
375
- "proposed",
376
- "published",
377
- "recommended",
378
- "represented",
379
- "trained",
380
- // Analysis & Research
381
- "analyzed",
382
- "assessed",
383
- "audited",
384
- "benchmarked",
385
- "calculated",
386
- "compared",
387
- "compiled",
388
- "conducted",
389
- "discovered",
390
- "evaluated",
391
- "examined",
392
- "explored",
393
- "forecasted",
394
- "identified",
395
- "inspected",
396
- "interpreted",
397
- "investigated",
398
- "mapped",
399
- "measured",
400
- "modeled",
401
- "monitored",
402
- "quantified",
403
- "researched",
404
- "reviewed",
405
- "surveyed",
406
- "synthesized",
407
- "tracked",
408
- "validated",
409
- "verified",
410
- // Creation & Innovation
411
- "conceptualized",
412
- "crafted",
413
- "created",
414
- "customized",
415
- "formulated",
416
- "founded",
417
- "initiated",
418
- "innovated",
419
- "introduced",
420
- "invented",
421
- "originated",
422
- "shaped"
423
- ],
424
- pronouns: ["i", "me", "my", "mine", "myself", "we", "our", "ours"],
425
- stopWords: [
426
- // Articles & determiners
427
- "a",
428
- "an",
429
- "the",
430
- "and",
431
- "or",
432
- "but",
433
- "in",
434
- "on",
435
- "at",
436
- "to",
437
- "for",
438
- "of",
439
- "with",
440
- "by",
441
- "from",
442
- "is",
443
- "was",
444
- "are",
445
- "were",
446
- "be",
447
- "been",
448
- "being",
449
- "have",
450
- "has",
451
- "had",
452
- "do",
453
- "does",
454
- "did",
455
- "will",
456
- "would",
457
- "could",
458
- "should",
459
- "may",
460
- "might",
461
- "shall",
462
- "can",
463
- "this",
464
- "that",
465
- "these",
466
- "those",
467
- "it",
468
- "its",
469
- "as",
470
- "if",
471
- "not",
472
- "no",
473
- "so",
474
- "up",
475
- "out",
476
- "about",
477
- "into",
478
- "over",
479
- "after",
480
- "before",
481
- "between",
482
- "under",
483
- "above",
484
- "below",
485
- "all",
486
- "each",
487
- "every",
488
- "both",
489
- "few",
490
- "more",
491
- "most",
492
- "other",
493
- "some",
494
- "such",
495
- "than",
496
- "too",
497
- "very",
498
- // Pronouns & possessives (also checked by pronoun check, but filter from JD keywords)
499
- "you",
500
- "your",
501
- "yours",
502
- "yourself",
503
- "we",
504
- "our",
505
- "ours",
506
- "ourselves",
507
- "they",
508
- "them",
509
- "their",
510
- "theirs",
511
- "he",
512
- "she",
513
- "his",
514
- "her",
515
- "hers",
516
- "who",
517
- "whom",
518
- "whose",
519
- "which",
520
- "what",
521
- "where",
522
- "when",
523
- "how",
524
- "why",
525
- // Common JD filler words (not meaningful for skill matching)
526
- "able",
527
- "also",
528
- "across",
529
- "already",
530
- "always",
531
- "among",
532
- "any",
533
- "apply",
534
- "become",
535
- "believe",
536
- "best",
537
- "bring",
538
- "change",
539
- "come",
540
- "committed",
541
- "company",
542
- "comfortable",
543
- "critical",
544
- "current",
545
- "day",
546
- "desired",
547
- "either",
548
- "end",
549
- "ensure",
550
- "environment",
551
- "equal",
552
- "even",
553
- "excellent",
554
- "exciting",
555
- "exceptional",
556
- "expected",
557
- "experience",
558
- "fast",
559
- "field",
560
- "find",
561
- "first",
562
- "focused",
563
- "follow",
564
- "get",
565
- "give",
566
- "go",
567
- "going",
568
- "good",
569
- "great",
570
- "group",
571
- "grow",
572
- "growing",
573
- "growth",
574
- "help",
575
- "here",
576
- "high",
577
- "highly",
578
- "ideal",
579
- "impact",
580
- "important",
581
- "include",
582
- "includes",
583
- "including",
584
- "industry",
585
- "interested",
586
- "job",
587
- "join",
588
- "just",
589
- "keep",
590
- "key",
591
- "know",
592
- "large",
593
- "latest",
594
- "lead",
595
- "level",
596
- "like",
597
- "location",
598
- "long",
599
- "look",
600
- "looking",
601
- "love",
602
- "make",
603
- "many",
604
- "much",
605
- "must",
606
- "need",
607
- "new",
608
- "next",
609
- "offer",
610
- "one",
611
- "only",
612
- "open",
613
- "opportunity",
614
- "order",
615
- "others",
616
- "own",
617
- "pace",
618
- "part",
619
- "partner",
620
- "passionate",
621
- "people",
622
- "per",
623
- "play",
624
- "plus",
625
- "position",
626
- "preferred",
627
- "provide",
628
- "put",
629
- "qualifications",
630
- "quickly",
631
- "range",
632
- "related",
633
- "required",
634
- "requirements",
635
- "requirement",
636
- "responsible",
637
- "responsibilities",
638
- "responsibility",
639
- "result",
640
- "right",
641
- "role",
642
- "run",
643
- "same",
644
- "see",
645
- "seek",
646
- "seeking",
647
- "set",
648
- "several",
649
- "since",
650
- "skills",
651
- "someone",
652
- "start",
653
- "state",
654
- "still",
655
- "strong",
656
- "success",
657
- "successful",
658
- "support",
659
- "sure",
660
- "take",
661
- "team",
662
- "then",
663
- "there",
664
- "thing",
665
- "think",
666
- "through",
667
- "time",
668
- "together",
669
- "top",
670
- "truly",
671
- "try",
672
- "two",
673
- "type",
674
- "use",
675
- "used",
676
- "using",
677
- "value",
678
- "want",
679
- "way",
680
- "well",
681
- "while",
682
- "within",
683
- "without",
684
- "work",
685
- "working",
686
- "world",
687
- "would",
688
- "year",
689
- "years",
690
- // Section headers & structural words (not technical skills)
691
- "description",
692
- "overview",
693
- "summary",
694
- "duties",
695
- "bachelor",
696
- "bachelors",
697
- "master",
698
- "masters",
699
- "degree",
700
- "phd",
701
- "minimum",
702
- "preferred",
703
- "implement",
704
- "process",
705
- "robust",
706
- "consistent",
707
- "operations",
708
- // URL/email/domain fragments
709
- "http",
710
- "https",
711
- "www",
712
- "com",
713
- "org",
714
- "net",
715
- "mailto",
716
- // Resume/YAML schema field names (in case raw YAML is pasted)
717
- "name",
718
- "keywords",
719
- "highlights",
720
- "startdate",
721
- "enddate",
722
- "website",
723
- "profiles",
724
- "basics",
725
- "position",
726
- "institution",
727
- "studytype",
728
- "fluency",
729
- "issuer",
730
- "network",
731
- "username",
732
- "countrycode",
733
- "region",
734
- // Generic nouns that aren't skills
735
- "product",
736
- "company",
737
- "service",
738
- "services",
739
- "platform",
740
- "solutions",
741
- "ability",
742
- "opportunity",
743
- "candidate",
744
- "applicant",
745
- "position",
746
- "salary",
747
- "compensation",
748
- "benefits",
749
- "perks",
750
- "bonus",
751
- "development",
752
- "management",
753
- "knowledge",
754
- "modern",
755
- "advanced",
756
- "practices",
757
- "nice",
758
- "technologies",
759
- "technology",
760
- "frameworks",
761
- "framework",
762
- "tools",
763
- "data",
764
- "based",
765
- "contribute",
766
- "contributions",
767
- "migration",
768
- "leading",
769
- "source",
770
- "visit",
771
- // Common verbs & verb forms (not technical skills, supplement action verbs list)
772
- "collaborate",
773
- "collaborating",
774
- "collaboratively",
775
- "communicate",
776
- "communicating",
777
- "contributing",
778
- "coordinate",
779
- "coordinating",
780
- "demonstrate",
781
- "demonstrating",
782
- "design",
783
- "designing",
784
- "designed",
785
- "develop",
786
- "developing",
787
- "developed",
788
- "drive",
789
- "driving",
790
- "driven",
791
- "enable",
792
- "enabling",
793
- "evaluate",
794
- "evaluating",
795
- "execute",
796
- "executing",
797
- "facilitate",
798
- "facilitating",
799
- "identify",
800
- "identifying",
801
- "influence",
802
- "influencing",
803
- "interact",
804
- "interacting",
805
- "lead",
806
- "leverage",
807
- "leveraging",
808
- "manage",
809
- "managing",
810
- "mentor",
811
- "mentoring",
812
- "operate",
813
- "operating",
814
- "optimize",
815
- "optimizing",
816
- "participate",
817
- "participating",
818
- "report",
819
- "reporting",
820
- "solve",
821
- "solving",
822
- "understand",
823
- "understanding",
824
- // Common adjectives & descriptors (not technical skills)
825
- "fluent",
826
- "proficient",
827
- "deep",
828
- "solid",
829
- "proven",
830
- "hands-on",
831
- "detail-oriented",
832
- "results-driven",
833
- "self-motivated",
834
- "proactive",
835
- "creative",
836
- "innovative",
837
- "dynamic",
838
- "strategic",
839
- "analytical",
840
- "collaborative",
841
- "effective",
842
- "efficient",
843
- "reliable",
844
- "flexible",
845
- "adaptable",
846
- "motivated",
847
- "dedicated",
848
- "capable",
849
- "qualified",
850
- "diverse",
851
- "inclusive",
852
- "global",
853
- "local",
854
- "remote",
855
- "hybrid",
856
- "onsite",
857
- "full-time",
858
- "part-time",
859
- "contract",
860
- "permanent",
861
- // Role titles & department names (not skills themselves)
862
- "designer",
863
- "designers",
864
- "developer",
865
- "developers",
866
- "engineer",
867
- "engineers",
868
- "manager",
869
- "managers",
870
- "director",
871
- "analyst",
872
- "analysts",
873
- "architect",
874
- "architects",
875
- "consultant",
876
- "consultants",
877
- "specialist",
878
- "specialists",
879
- "coordinator",
880
- "lead",
881
- "principal",
882
- "staff",
883
- "junior",
884
- "mid",
885
- "department",
886
- "organization",
887
- "division",
888
- "stakeholder",
889
- "stakeholders",
890
- "client",
891
- "clients",
892
- "customer",
893
- "customers",
894
- // Date & time words
895
- "date",
896
- "dates",
897
- "month",
898
- "months",
899
- "week",
900
- "weeks",
901
- "daily",
902
- "weekly",
903
- "monthly",
904
- "quarterly",
905
- "annual",
906
- "annually",
907
- // More generic words that aren't skills
908
- "code",
909
- "coding",
910
- "url",
911
- "contact",
912
- "information",
913
- "apply",
914
- "application",
915
- "review",
916
- "reviews",
917
- "quality",
918
- "scale",
919
- "scalable",
920
- "system",
921
- "systems",
922
- "solution",
923
- "feature",
924
- "features",
925
- "project",
926
- "projects",
927
- "build",
928
- "building",
929
- "deliver",
930
- "delivery",
931
- "cross-functional"
932
- ]
933
- };
934
- var en_default = en;
935
-
936
- // src/ats/i18n/de.ts
937
- var de = {
938
- actionVerbs: [
939
- // Führung & Management
940
- "geleitet",
941
- "gef\xFChrt",
942
- "koordiniert",
943
- "organisiert",
944
- "verwaltet",
945
- "delegiert",
946
- "beaufsichtigt",
947
- "betreut",
948
- "eingestellt",
949
- "motiviert",
950
- "verantwortet",
951
- "gesteuert",
952
- "\xFCberwacht",
953
- "priorisiert",
954
- "geplant",
955
- // Technik & Entwicklung
956
- "entwickelt",
957
- "implementiert",
958
- "programmiert",
959
- "konfiguriert",
960
- "automatisiert",
961
- "deployt",
962
- "gebaut",
963
- "entworfen",
964
- "integriert",
965
- "migriert",
966
- "modernisiert",
967
- "optimiert",
968
- "refaktoriert",
969
- "skaliert",
970
- "standardisiert",
971
- "getestet",
972
- "aufgebaut",
973
- "eingef\xFChrt",
974
- "bereitgestellt",
975
- "umgesetzt",
976
- // Leistung & Ergebnisse
977
- "verbessert",
978
- "gesteigert",
979
- "reduziert",
980
- "beschleunigt",
981
- "erreicht",
982
- "\xFCbertroffen",
983
- "erweitert",
984
- "vereinfacht",
985
- "gel\xF6st",
986
- "transformiert",
987
- "erh\xF6ht",
988
- "verdoppelt",
989
- "verdreifacht",
990
- "generiert",
991
- "gespart",
992
- "maximiert",
993
- "minimiert",
994
- "eliminiert",
995
- "geliefert",
996
- "abgeschlossen",
997
- // Kommunikation & Zusammenarbeit
998
- "beraten",
999
- "pr\xE4sentiert",
1000
- "dokumentiert",
1001
- "geschult",
1002
- "trainiert",
1003
- "vermittelt",
1004
- "kommuniziert",
1005
- "verhandelt",
1006
- "zusammengearbeitet",
1007
- "unterst\xFCtzt",
1008
- "gef\xF6rdert",
1009
- "empfohlen",
1010
- "vorgestellt",
1011
- "publiziert",
1012
- // Analyse & Forschung
1013
- "analysiert",
1014
- "bewertet",
1015
- "evaluiert",
1016
- "untersucht",
1017
- "erforscht",
1018
- "identifiziert",
1019
- "gemessen",
1020
- "\xFCberwacht",
1021
- "validiert",
1022
- "verifiziert",
1023
- "gepr\xFCft",
1024
- "verglichen",
1025
- "recherchiert",
1026
- "quantifiziert",
1027
- // Kreation & Innovation
1028
- "konzipiert",
1029
- "erstellt",
1030
- "gestaltet",
1031
- "initiiert",
1032
- "innoviert",
1033
- "eingef\xFChrt",
1034
- "gegr\xFCndet",
1035
- "formuliert"
1036
- ],
1037
- pronouns: [
1038
- "ich",
1039
- "mich",
1040
- "mir",
1041
- "mein",
1042
- "meine",
1043
- "meinem",
1044
- "meiner",
1045
- "meines",
1046
- "wir",
1047
- "unser",
1048
- "unsere"
1049
- ],
1050
- stopWords: [
1051
- "ein",
1052
- "eine",
1053
- "einer",
1054
- "eines",
1055
- "einem",
1056
- "der",
1057
- "die",
1058
- "das",
1059
- "den",
1060
- "dem",
1061
- "des",
1062
- "und",
1063
- "oder",
1064
- "aber",
1065
- "in",
1066
- "an",
1067
- "auf",
1068
- "zu",
1069
- "f\xFCr",
1070
- "von",
1071
- "mit",
1072
- "bei",
1073
- "aus",
1074
- "ist",
1075
- "war",
1076
- "sind",
1077
- "waren",
1078
- "wird",
1079
- "wurde",
1080
- "werden",
1081
- "hat",
1082
- "hatte",
1083
- "haben",
1084
- "hatten",
1085
- "sein",
1086
- "kann",
1087
- "k\xF6nnte",
1088
- "soll",
1089
- "sollte",
1090
- "muss",
1091
- "musste",
1092
- "darf",
1093
- "diese",
1094
- "dieser",
1095
- "dieses",
1096
- "diesem",
1097
- "diesen",
1098
- "als",
1099
- "wenn",
1100
- "nicht",
1101
- "kein",
1102
- "keine",
1103
- "so",
1104
- "auch",
1105
- "noch",
1106
- "schon",
1107
- "nach",
1108
- "vor",
1109
- "\xFCber",
1110
- "unter",
1111
- "zwischen",
1112
- "durch",
1113
- "ohne",
1114
- "um",
1115
- "bis",
1116
- "alle",
1117
- "jede",
1118
- "jeder",
1119
- "jedes",
1120
- "mehr",
1121
- "viel",
1122
- "sehr"
1123
- ]
1124
- };
1125
- var de_default = de;
1126
-
1127
- // src/ats/i18n/index.ts
1128
- var languages = { en: en_default, de: de_default };
1129
- function getLanguageData(language) {
1130
- return languages[language] ?? languages["en"] ?? en_default;
33
+ return start === 0 && end === tok.length ? tok : tok.slice(start, end);
1131
34
  }
1132
-
1133
- // src/ats/checks/yoe.ts
1134
- function computeYoeYears(work) {
1135
- const ranges = (work || []).filter((w) => w.startDate).map((w) => {
1136
- const s = new Date(w.startDate).getTime();
1137
- const e = w.endDate ? new Date(w.endDate).getTime() : Date.now();
1138
- return [s, e];
1139
- }).sort((a, b) => a[0] - b[0]);
1140
- if (ranges.length === 0) return 0;
1141
- const merged = [ranges[0]];
1142
- for (let i = 1; i < ranges.length; i++) {
1143
- const last = merged[merged.length - 1];
1144
- const curr = ranges[i];
1145
- if (curr[0] <= last[1]) last[1] = Math.max(last[1], curr[1]);
1146
- else merged.push(curr);
35
+ function tokenize(text) {
36
+ const tokens = [];
37
+ const len = text.length;
38
+ let i = 0;
39
+ while (i < len) {
40
+ while (i < len && !isTokenChar(text.charAt(i))) i++;
41
+ const start = i;
42
+ while (i < len && isTokenChar(text.charAt(i))) i++;
43
+ if (i === start) continue;
44
+ const raw = trimTokenBoundary(text.slice(start, i));
45
+ if (!raw) continue;
46
+ let hasCore = false;
47
+ for (let k = 0; k < raw.length; k++) {
48
+ const ch = raw.charAt(k);
49
+ if (isLetter(ch) || isDigit(ch) || ch === "+" || ch === "#") {
50
+ hasCore = true;
51
+ break;
52
+ }
53
+ }
54
+ if (!hasCore) continue;
55
+ let letterCount = 0;
56
+ let upperCount = 0;
57
+ for (let k = 0; k < raw.length; k++) {
58
+ const ch = raw.charAt(k);
59
+ if (isLetter(ch)) {
60
+ letterCount++;
61
+ if (ch >= "A" && ch <= "Z") upperCount++;
62
+ }
63
+ }
64
+ const isAllUpper = letterCount >= 2 && upperCount === letterCount;
65
+ tokens.push({ raw, norm: raw.toLowerCase(), isAllUpper });
1147
66
  }
1148
- const ms = merged.reduce((sum, [s, e]) => sum + (e - s), 0);
1149
- return ms / (1e3 * 60 * 60 * 24 * 365.25);
67
+ return tokens;
1150
68
  }
1151
-
1152
- // src/ats/checks/recruiter.ts
1153
- var wordCount = (s) => s.trim().split(/\s+/).filter(Boolean).length;
1154
- var firstWord = (s) => s.trim().split(/\s+/)[0]?.toLowerCase().replace(/[^a-zA-ZäöüßÄÖÜàáâãéèêëíìîïóòôõúùûñç]/g, "") || "";
1155
- function isSenior(resume, cfg) {
1156
- return computeYoeYears(resume.work) >= cfg.thresholds.seniorYoeCutoff;
69
+ function phraseToTokens(phrase) {
70
+ return phrase.toLowerCase().split(/\s+/).map((t) => trimTokenBoundary(t)).filter(Boolean);
1157
71
  }
1158
- var summaryLength = (resume) => {
1159
- const s = resume.basics?.summary?.trim();
1160
- if (!s) {
1161
- return {
1162
- id: "summary-length",
1163
- tier: "recruiter",
1164
- weight: "medium",
1165
- status: "fail",
1166
- score: 0,
1167
- message: "No professional summary.",
1168
- hints: ["Add a 2-4 sentence summary (20-50 words) to basics.summary."]
1169
- };
1170
- }
1171
- const w = wordCount(s);
1172
- if (w >= 20 && w <= 50) {
1173
- return {
1174
- id: "summary-length",
1175
- tier: "recruiter",
1176
- weight: "medium",
1177
- status: "pass",
1178
- score: 100,
1179
- message: `Summary length good (${w} words).`,
1180
- hints: []
1181
- };
1182
- }
1183
- if (w >= 10 && w <= 80) {
1184
- return {
1185
- id: "summary-length",
1186
- tier: "recruiter",
1187
- weight: "medium",
1188
- status: "warn",
1189
- score: 70,
1190
- message: `Summary ${w < 20 ? "short" : "long"} (${w} words). Aim for 20-50.`,
1191
- hints: [w < 20 ? "Expand to 20-50 words." : "Trim to the most impactful 20-50 words."]
1192
- };
1193
- }
1194
- return {
1195
- id: "summary-length",
1196
- tier: "recruiter",
1197
- weight: "medium",
1198
- status: "fail",
1199
- score: Math.max(0, 100 - Math.abs(w - 35) * 2),
1200
- message: `Summary length ${w} words is far from target.`,
1201
- hints: ["Rewrite the summary in 2-4 sentences (20-50 words)."]
1202
- };
1203
- };
1204
- var actionVerbStart = (resume, language) => {
1205
- const verbs = new Set(getLanguageData(language).actionVerbs);
1206
- const all = [];
1207
- (resume.work || []).forEach((w, i) => {
1208
- (w.highlights || []).forEach(
1209
- (h, j) => all.push({ text: h, path: `work[${i}].highlights[${j}]` })
1210
- );
1211
- });
1212
- (resume.projects || []).forEach((p, i) => {
1213
- (p.highlights || []).forEach(
1214
- (h, j) => all.push({ text: h, path: `projects[${i}].highlights[${j}]` })
1215
- );
1216
- });
1217
- if (all.length === 0) {
1218
- return {
1219
- id: "action-verb-start",
1220
- tier: "recruiter",
1221
- weight: "medium",
1222
- status: "skipped",
1223
- score: 0,
1224
- message: "No highlights.",
1225
- hints: []
1226
- };
1227
- }
1228
- const without = all.filter((h) => !verbs.has(firstWord(h.text)));
1229
- const passed = without.length === 0;
1230
- return {
1231
- id: "action-verb-start",
1232
- tier: "recruiter",
1233
- weight: "medium",
1234
- status: passed ? "pass" : without.length / all.length > 0.3 ? "fail" : "warn",
1235
- score: Math.round((all.length - without.length) / all.length * 100),
1236
- message: passed ? "All highlights start with action verbs." : `${without.length} of ${all.length} highlights miss an action verb.`,
1237
- hints: passed ? [] : without.slice(0, 3).map((h) => `${h.path}: start with an action verb instead of "${firstWord(h.text)}".`)
1238
- };
1239
- };
1240
- var QUANT_RE = /\d+%?|\$[\d,]+|[\d,]+\+?\s*(users|clients|customers|people|team|members|projects|applications|servers|services|endpoints|requests|transactions)/i;
1241
- var quantificationDensity = (resume) => {
1242
- const all = [];
1243
- (resume.work || []).forEach((w, i) => {
1244
- (w.highlights || []).forEach(
1245
- (h, j) => all.push({ text: h, path: `work[${i}].highlights[${j}]` })
1246
- );
1247
- });
1248
- (resume.projects || []).forEach((p, i) => {
1249
- (p.highlights || []).forEach(
1250
- (h, j) => all.push({ text: h, path: `projects[${i}].highlights[${j}]` })
1251
- );
1252
- });
1253
- if (all.length === 0) {
1254
- return {
1255
- id: "quantification-density",
1256
- tier: "recruiter",
1257
- weight: "high",
1258
- status: "skipped",
1259
- score: 0,
1260
- message: "No highlights.",
1261
- hints: []
1262
- };
1263
- }
1264
- const quantified = all.filter((h) => QUANT_RE.test(h.text));
1265
- const ratio = quantified.length / all.length;
1266
- const status = ratio >= 0.5 ? "pass" : ratio >= 0.3 ? "warn" : "fail";
1267
- return {
1268
- id: "quantification-density",
1269
- tier: "recruiter",
1270
- weight: "high",
1271
- status,
1272
- score: Math.min(100, Math.round(ratio * 200)),
1273
- message: `${quantified.length}/${all.length} highlights quantified (${Math.round(ratio * 100)}%).`,
1274
- hints: status === "pass" ? [] : all.filter((h) => !QUANT_RE.test(h.text)).slice(0, 3).map((h) => `${h.path}: add a number/metric.`)
1275
- };
1276
- };
1277
- var pronounLeakage = (resume, language) => {
1278
- const set = new Set(getLanguageData(language).pronouns);
1279
- const blocks = [];
1280
- if (resume.basics?.summary) blocks.push({ text: resume.basics.summary, path: "basics.summary" });
1281
- (resume.work || []).forEach((w, i) => {
1282
- if (w.summary) blocks.push({ text: w.summary, path: `work[${i}].summary` });
1283
- (w.highlights || []).forEach(
1284
- (h, j) => blocks.push({ text: h, path: `work[${i}].highlights[${j}]` })
1285
- );
1286
- });
1287
- const hits = [];
1288
- for (const b of blocks) {
1289
- for (const w of b.text.toLowerCase().split(/\s+/)) {
1290
- const clean = w.replace(/[^a-zA-ZäöüßÄÖÜ]/g, "");
1291
- if (set.has(clean)) hits.push({ pronoun: clean, path: b.path });
72
+ function looksLikeAcronym(phrase) {
73
+ const trimmed = phrase.trim();
74
+ if (/\s/.test(trimmed)) return false;
75
+ if (trimmed.length < 2 || trimmed.length > 6) return false;
76
+ const letters = trimmed.replace(/[^a-zA-Z]/g, "");
77
+ return letters.length >= 2 && letters === letters.toUpperCase();
78
+ }
79
+ var SkillIndex = class {
80
+ skills;
81
+ byFirstToken = /* @__PURE__ */ new Map();
82
+ maxPhraseLen;
83
+ constructor(skills) {
84
+ this.skills = skills;
85
+ let maxLen = 1;
86
+ skills.forEach((skill, i) => {
87
+ for (const phrase of [skill.canonical, ...skill.aliases]) {
88
+ const tokens = phraseToTokens(phrase);
89
+ const first = tokens[0];
90
+ if (!first) continue;
91
+ maxLen = Math.max(maxLen, tokens.length);
92
+ const list = this.byFirstToken.get(first);
93
+ const entry = {
94
+ skillIdx: i,
95
+ phraseTokens: tokens,
96
+ requiresCaseMatch: looksLikeAcronym(phrase)
97
+ };
98
+ if (list) list.push(entry);
99
+ else this.byFirstToken.set(first, [entry]);
100
+ }
101
+ });
102
+ for (const list of this.byFirstToken.values()) {
103
+ list.sort((a, b) => b.phraseTokens.length - a.phraseTokens.length);
1292
104
  }
105
+ this.maxPhraseLen = maxLen;
1293
106
  }
1294
- const passed = hits.length === 0;
1295
- return {
1296
- id: "pronoun-leakage",
1297
- tier: "recruiter",
1298
- weight: "low",
1299
- status: passed ? "pass" : hits.length > 3 ? "fail" : "warn",
1300
- score: passed ? 100 : Math.max(0, 100 - hits.length * 15),
1301
- message: passed ? "No first-person pronouns." : `${hits.length} pronoun(s): ${[...new Set(hits.map((h) => h.pronoun))].join(", ")}.`,
1302
- hints: passed ? [] : hits.slice(0, 3).map((h) => `${h.path}: drop "${h.pronoun}" (convention, not ATS).`)
1303
- };
1304
- };
1305
- var bulletsPerRole = (resume, _l, cfg) => {
1306
- const work = resume.work || [];
1307
- if (work.length === 0) {
1308
- return {
1309
- id: "bullets-per-role",
1310
- tier: "recruiter",
1311
- weight: "medium",
1312
- status: "skipped",
1313
- score: 0,
1314
- message: "No work entries.",
1315
- hints: []
1316
- };
1317
- }
1318
- const senior = isSenior(resume, cfg);
1319
- const min = cfg.thresholds.bulletsPerRole.min;
1320
- const max = senior ? cfg.thresholds.bulletsPerRole.seniorMax : cfg.thresholds.bulletsPerRole.max;
1321
- const offenders = [];
1322
- work.forEach((w, i) => {
1323
- const n = (w.highlights || []).length;
1324
- if (n < min || n > max) offenders.push({ path: `work[${i}]`, n });
1325
- });
1326
- const passed = offenders.length === 0;
1327
- return {
1328
- id: "bullets-per-role",
1329
- tier: "recruiter",
1330
- weight: "medium",
1331
- status: passed ? "pass" : work.length === 1 ? "warn" : offenders.length > work.length / 2 ? "fail" : "warn",
1332
- score: Math.round((work.length - offenders.length) / work.length * 100),
1333
- message: passed ? `All roles have ${min}-${max} highlights.` : `${offenders.length} role(s) outside ${min}-${max} highlights.`,
1334
- hints: passed ? [] : offenders.slice(0, 3).map((o) => `${o.path}: ${o.n} highlights, target ${min}-${max}.`)
1335
- };
1336
- };
1337
- var wordCountTotal = (resume, _l, cfg) => {
1338
- const senior = isSenior(resume, cfg);
1339
- const min = cfg.thresholds.wordCount.min;
1340
- const max = senior ? cfg.thresholds.wordCount.seniorMax : cfg.thresholds.wordCount.max;
1341
- const parts = [];
1342
- if (resume.basics?.summary) parts.push(resume.basics.summary);
1343
- for (const w of resume.work || []) {
1344
- if (w.summary) parts.push(w.summary);
1345
- parts.push(...w.highlights || []);
107
+ /**
108
+ * Scan text for skills. Longest-match wins at each position, e.g. "Next.js"
109
+ * doesn't also fire "Next", and "Google Cloud Platform" doesn't also fire
110
+ * "Google".
111
+ *
112
+ * Returns a list of unique skills with occurrence counts.
113
+ */
114
+ scan(text) {
115
+ const tokens = tokenize(text);
116
+ const hits = /* @__PURE__ */ new Map();
117
+ let i = 0;
118
+ while (i < tokens.length) {
119
+ const head = tokens[i];
120
+ if (!head) break;
121
+ const bucket = this.byFirstToken.get(head.norm);
122
+ if (!bucket) {
123
+ i++;
124
+ continue;
125
+ }
126
+ let matched = false;
127
+ for (const cand of bucket) {
128
+ const len = cand.phraseTokens.length;
129
+ if (i + len > tokens.length) continue;
130
+ let ok = true;
131
+ for (let k = 0; k < len; k++) {
132
+ const tok = tokens[i + k];
133
+ if (!tok || tok.norm !== cand.phraseTokens[k]) {
134
+ ok = false;
135
+ break;
136
+ }
137
+ }
138
+ if (!ok) continue;
139
+ if (cand.requiresCaseMatch && !head.isAllUpper) continue;
140
+ hits.set(cand.skillIdx, (hits.get(cand.skillIdx) ?? 0) + 1);
141
+ i += len;
142
+ matched = true;
143
+ break;
144
+ }
145
+ if (!matched) i++;
146
+ }
147
+ const result = [];
148
+ for (const [skillIdx, count] of hits) {
149
+ const skill = this.skills[skillIdx];
150
+ if (!skill) continue;
151
+ result.push({ skill, occurrences: count });
152
+ }
153
+ result.sort((a, b) => {
154
+ if (a.skill.hot !== b.skill.hot) return a.skill.hot ? -1 : 1;
155
+ if (a.occurrences !== b.occurrences) return b.occurrences - a.occurrences;
156
+ return a.skill.canonical.localeCompare(b.skill.canonical);
157
+ });
158
+ return result;
1346
159
  }
1347
- for (const p of resume.projects || []) {
1348
- if (p.description) parts.push(p.description);
1349
- parts.push(...p.highlights || []);
160
+ get size() {
161
+ return this.skills.length;
1350
162
  }
1351
- const total = wordCount(parts.join(" "));
1352
- const passed = total >= min && total <= max;
1353
- const status = passed ? "pass" : total < min * 0.7 || total > max * 1.5 ? "fail" : "warn";
1354
- return {
1355
- id: "word-count-total",
1356
- tier: "recruiter",
1357
- weight: "low",
1358
- status,
1359
- score: passed ? 100 : Math.max(0, 100 - Math.round(Math.abs(total - (min + max) / 2) / 10)),
1360
- message: `Resume body: ${total} words (target ${min}-${max}${senior ? ", senior" : ""}).`,
1361
- hints: passed ? [] : [total < min ? "Add more depth to highlights." : "Trim less impactful highlights."]
1362
- };
1363
- };
1364
- var highlightLength = (resume) => {
1365
- const all = [];
1366
- (resume.work || []).forEach((w, i) => {
1367
- (w.highlights || []).forEach(
1368
- (h, j) => all.push({ text: h, path: `work[${i}].highlights[${j}]` })
1369
- );
1370
- });
1371
- if (all.length === 0) {
1372
- return {
1373
- id: "highlight-length",
1374
- tier: "recruiter",
1375
- weight: "low",
1376
- status: "skipped",
1377
- score: 0,
1378
- message: "No highlights.",
1379
- hints: []
1380
- };
163
+ get maxPhraseTokens() {
164
+ return this.maxPhraseLen;
1381
165
  }
1382
- const long = all.filter((h) => wordCount(h.text) > 30);
1383
- const passed = long.length === 0;
1384
- return {
1385
- id: "highlight-length",
1386
- tier: "recruiter",
1387
- weight: "low",
1388
- status: passed ? "pass" : long.length / all.length > 0.3 ? "fail" : "warn",
1389
- score: Math.round((all.length - long.length) / all.length * 100),
1390
- message: passed ? "All highlights at most 30 words." : `${long.length} highlight(s) over 30 words.`,
1391
- hints: passed ? [] : long.slice(0, 3).map((h) => `${h.path}: trim to 30 words or fewer.`)
1392
- };
1393
166
  };
1394
- function isLinkedInUrl(url) {
1395
- if (!url) return false;
1396
- try {
1397
- const host = new URL(url).hostname.toLowerCase();
1398
- return host === "linkedin.com" || host.endsWith(".linkedin.com");
1399
- } catch {
1400
- return false;
1401
- }
167
+ function buildSkillIndex(skills) {
168
+ return new SkillIndex(skills);
1402
169
  }
1403
- var hasLinkedin = (resume) => {
1404
- const profiles = resume.basics?.profiles || [];
1405
- const found = profiles.some(
1406
- (p) => p.network?.toLowerCase() === "linkedin" || isLinkedInUrl(p.url)
1407
- );
1408
- return {
1409
- id: "has-linkedin",
1410
- tier: "recruiter",
1411
- weight: "low",
1412
- status: found ? "pass" : "warn",
1413
- score: found ? 100 : 0,
1414
- message: found ? "LinkedIn profile present." : "No LinkedIn profile.",
1415
- hints: found ? [] : ["Add a LinkedIn profile to basics.profiles."]
1416
- };
1417
- };
1418
- var skillsPopulated = (resume) => {
1419
- const skills = resume.skills || [];
1420
- const withKeywords = skills.filter((s) => (s.keywords?.length ?? 0) > 0);
1421
- const passed = withKeywords.length >= 3;
1422
- return {
1423
- id: "skills-populated",
1424
- tier: "recruiter",
1425
- weight: "medium",
1426
- status: passed ? "pass" : withKeywords.length < 2 ? "fail" : "warn",
1427
- score: Math.min(100, Math.round(withKeywords.length / 3 * 100)),
1428
- message: passed ? `${withKeywords.length} skill categories with keywords.` : `Only ${withKeywords.length} skill categories with keywords (need 3+).`,
1429
- hints: passed ? [] : ["Add at least 3 skill categories with keywords."]
1430
- };
1431
- };
1432
- var allRecruiterChecks = [
1433
- summaryLength,
1434
- actionVerbStart,
1435
- quantificationDensity,
1436
- pronounLeakage,
1437
- bulletsPerRole,
1438
- wordCountTotal,
1439
- highlightLength,
1440
- hasLinkedin,
1441
- skillsPopulated
1442
- ];
1443
170
 
1444
171
  // data/skills/skills.json
1445
172
  var skills_default = {
@@ -16407,1028 +15134,21 @@ var skills_default = {
16407
15134
  ]
16408
15135
  };
16409
15136
 
16410
- // src/ats/skills/matcher.ts
16411
- function isTokenChar(ch) {
16412
- if (ch >= "a" && ch <= "z") return true;
16413
- if (ch >= "A" && ch <= "Z") return true;
16414
- if (ch >= "0" && ch <= "9") return true;
16415
- return ch === "." || ch === "/" || ch === "-" || ch === "+" || ch === "#" || ch === "_";
16416
- }
16417
- function isLetter(ch) {
16418
- return ch >= "a" && ch <= "z" || ch >= "A" && ch <= "Z";
16419
- }
16420
- function isDigit(ch) {
16421
- return ch >= "0" && ch <= "9";
16422
- }
16423
- function trimTokenBoundary(tok) {
16424
- let start = 0;
16425
- let end = tok.length;
16426
- while (start < end) {
16427
- const ch = tok.charAt(start);
16428
- if (isLetter(ch) || isDigit(ch) || ch === "+" || ch === "#") break;
16429
- start++;
16430
- }
16431
- while (end > start) {
16432
- const ch = tok.charAt(end - 1);
16433
- if (isLetter(ch) || isDigit(ch) || ch === "+" || ch === "#") break;
16434
- end--;
16435
- }
16436
- return start === 0 && end === tok.length ? tok : tok.slice(start, end);
16437
- }
16438
- function tokenize(text) {
16439
- const tokens = [];
16440
- const len = text.length;
16441
- let i = 0;
16442
- while (i < len) {
16443
- while (i < len && !isTokenChar(text.charAt(i))) i++;
16444
- const start = i;
16445
- while (i < len && isTokenChar(text.charAt(i))) i++;
16446
- if (i === start) continue;
16447
- const raw = trimTokenBoundary(text.slice(start, i));
16448
- if (!raw) continue;
16449
- let hasCore = false;
16450
- for (let k = 0; k < raw.length; k++) {
16451
- const ch = raw.charAt(k);
16452
- if (isLetter(ch) || isDigit(ch) || ch === "+" || ch === "#") {
16453
- hasCore = true;
16454
- break;
16455
- }
16456
- }
16457
- if (!hasCore) continue;
16458
- let letterCount = 0;
16459
- let upperCount = 0;
16460
- for (let k = 0; k < raw.length; k++) {
16461
- const ch = raw.charAt(k);
16462
- if (isLetter(ch)) {
16463
- letterCount++;
16464
- if (ch >= "A" && ch <= "Z") upperCount++;
16465
- }
16466
- }
16467
- const isAllUpper = letterCount >= 2 && upperCount === letterCount;
16468
- tokens.push({ raw, norm: raw.toLowerCase(), isAllUpper });
16469
- }
16470
- return tokens;
16471
- }
16472
- function phraseToTokens(phrase) {
16473
- return phrase.toLowerCase().split(/\s+/).map((t) => trimTokenBoundary(t)).filter(Boolean);
16474
- }
16475
- function looksLikeAcronym(phrase) {
16476
- const trimmed = phrase.trim();
16477
- if (/\s/.test(trimmed)) return false;
16478
- if (trimmed.length < 2 || trimmed.length > 6) return false;
16479
- const letters = trimmed.replace(/[^a-zA-Z]/g, "");
16480
- return letters.length >= 2 && letters === letters.toUpperCase();
16481
- }
16482
- var SkillIndex = class {
16483
- skills;
16484
- byFirstToken = /* @__PURE__ */ new Map();
16485
- maxPhraseLen;
16486
- constructor(skills) {
16487
- this.skills = skills;
16488
- let maxLen = 1;
16489
- skills.forEach((skill, i) => {
16490
- for (const phrase of [skill.canonical, ...skill.aliases]) {
16491
- const tokens = phraseToTokens(phrase);
16492
- const first = tokens[0];
16493
- if (!first) continue;
16494
- maxLen = Math.max(maxLen, tokens.length);
16495
- const list = this.byFirstToken.get(first);
16496
- const entry = {
16497
- skillIdx: i,
16498
- phraseTokens: tokens,
16499
- requiresCaseMatch: looksLikeAcronym(phrase)
16500
- };
16501
- if (list) list.push(entry);
16502
- else this.byFirstToken.set(first, [entry]);
16503
- }
16504
- });
16505
- for (const list of this.byFirstToken.values()) {
16506
- list.sort((a, b) => b.phraseTokens.length - a.phraseTokens.length);
16507
- }
16508
- this.maxPhraseLen = maxLen;
16509
- }
16510
- /**
16511
- * Scan text for skills. Longest-match wins at each position, e.g. "Next.js"
16512
- * doesn't also fire "Next", and "Google Cloud Platform" doesn't also fire
16513
- * "Google".
16514
- *
16515
- * Returns a list of unique skills with occurrence counts.
16516
- */
16517
- scan(text) {
16518
- const tokens = tokenize(text);
16519
- const hits = /* @__PURE__ */ new Map();
16520
- let i = 0;
16521
- while (i < tokens.length) {
16522
- const head = tokens[i];
16523
- if (!head) break;
16524
- const bucket = this.byFirstToken.get(head.norm);
16525
- if (!bucket) {
16526
- i++;
16527
- continue;
16528
- }
16529
- let matched = false;
16530
- for (const cand of bucket) {
16531
- const len = cand.phraseTokens.length;
16532
- if (i + len > tokens.length) continue;
16533
- let ok = true;
16534
- for (let k = 0; k < len; k++) {
16535
- const tok = tokens[i + k];
16536
- if (!tok || tok.norm !== cand.phraseTokens[k]) {
16537
- ok = false;
16538
- break;
16539
- }
16540
- }
16541
- if (!ok) continue;
16542
- if (cand.requiresCaseMatch && !head.isAllUpper) continue;
16543
- hits.set(cand.skillIdx, (hits.get(cand.skillIdx) ?? 0) + 1);
16544
- i += len;
16545
- matched = true;
16546
- break;
16547
- }
16548
- if (!matched) i++;
16549
- }
16550
- const result = [];
16551
- for (const [skillIdx, count] of hits) {
16552
- const skill = this.skills[skillIdx];
16553
- if (!skill) continue;
16554
- result.push({ skill, occurrences: count });
16555
- }
16556
- result.sort((a, b) => {
16557
- if (a.skill.hot !== b.skill.hot) return a.skill.hot ? -1 : 1;
16558
- if (a.occurrences !== b.occurrences) return b.occurrences - a.occurrences;
16559
- return a.skill.canonical.localeCompare(b.skill.canonical);
16560
- });
16561
- return result;
16562
- }
16563
- get size() {
16564
- return this.skills.length;
16565
- }
16566
- get maxPhraseTokens() {
16567
- return this.maxPhraseLen;
16568
- }
16569
- };
16570
- function buildSkillIndex(skills) {
16571
- return new SkillIndex(skills);
16572
- }
16573
-
16574
15137
  // src/ats/skills/index.ts
16575
15138
  var cached = null;
16576
15139
  function getSkillIndex() {
16577
15140
  if (!cached) cached = buildSkillIndex(skills_default.skills);
16578
15141
  return cached;
16579
15142
  }
16580
-
16581
- // src/ats/jdMatcher.ts
16582
- function extractResumeText(resume) {
16583
- const parts = [];
16584
- if (resume.basics?.summary) parts.push(resume.basics.summary);
16585
- if (resume.basics?.label) parts.push(resume.basics.label);
16586
- for (const w of resume.work || []) {
16587
- if (w.position) parts.push(w.position);
16588
- if (w.summary) parts.push(w.summary);
16589
- parts.push(...w.highlights || []);
16590
- }
16591
- for (const s of resume.skills || []) {
16592
- if (s.name) parts.push(s.name);
16593
- parts.push(...s.keywords || []);
16594
- }
16595
- for (const p of resume.projects || []) {
16596
- if (p.name) parts.push(p.name);
16597
- if (p.description) parts.push(p.description);
16598
- parts.push(...p.highlights || []);
16599
- }
16600
- for (const e of resume.education || []) {
16601
- if (e.area) parts.push(e.area);
16602
- if (e.studyType) parts.push(e.studyType);
16603
- parts.push(...e.courses || []);
16604
- }
16605
- for (const c of resume.certificates || []) {
16606
- if (c.name) parts.push(c.name);
16607
- }
16608
- return parts.join(" ");
16609
- }
16610
- function splitJdSections(text) {
16611
- const lines = text.split("\n");
16612
- const reqPatterns = /^(required|requirements?|minimum|preferred|qualifications?|must[\s-]have|nice[\s-]to[\s-]have|what you.?ll|what we.?re looking|skills|technical|you.?ll need|responsibilities|you will)/i;
16613
- const nonReqPatterns = /^(about|summary|who we are|our (company|team|mission)|description|overview|benefits|perks|compensation|salary)/i;
16614
- let inReqSection = false;
16615
- const reqLines = [];
16616
- for (const line of lines) {
16617
- const header = line.trim().replace(/[:#*-]/g, "").trim();
16618
- if (reqPatterns.test(header)) inReqSection = true;
16619
- else if (nonReqPatterns.test(header)) inReqSection = false;
16620
- if (inReqSection) reqLines.push(line);
16621
- }
16622
- return {
16623
- requirementText: reqLines.join("\n"),
16624
- fullText: text
16625
- };
16626
- }
16627
- function rankSkills(fullMatches, requirementMatches) {
16628
- const reqIds = new Set(requirementMatches.map((m) => m.skill.id));
16629
- return fullMatches.map((m) => {
16630
- const inRequirementSection = reqIds.has(m.skill.id);
16631
- const score = m.occurrences * 1 + (inRequirementSection ? 3 : 0) + (m.skill.hot ? 1.5 : 0);
16632
- return {
16633
- canonical: m.skill.canonical,
16634
- occurrences: m.occurrences,
16635
- inRequirementSection,
16636
- hot: m.skill.hot,
16637
- score
16638
- };
16639
- }).sort((a, b) => {
16640
- if (a.inRequirementSection !== b.inRequirementSection) return a.inRequirementSection ? -1 : 1;
16641
- if (a.score !== b.score) return b.score - a.score;
16642
- return a.canonical.localeCompare(b.canonical);
16643
- });
16644
- }
16645
- function matchJobDescription(resume, jobDescription, _language = "en") {
16646
- if (!jobDescription.trim()) {
16647
- return { matched: [], missing: [], extra: [], matchPercentage: 0 };
16648
- }
16649
- const skillIndex = getSkillIndex();
16650
- const fullMatches = skillIndex.scan(jobDescription);
16651
- const { requirementText } = splitJdSections(jobDescription);
16652
- const requirementMatches = requirementText.trim() ? skillIndex.scan(requirementText) : fullMatches;
16653
- const ranked = rankSkills(fullMatches, requirementMatches).slice(0, 25);
16654
- const resumeText = extractResumeText(resume);
16655
- const resumeMatches = skillIndex.scan(resumeText);
16656
- const resumeSkillIds = new Set(resumeMatches.map((m) => m.skill.id));
16657
- const matched = [];
16658
- const missing = [];
16659
- for (const r of ranked) {
16660
- const match = fullMatches.find((m) => m.skill.canonical === r.canonical);
16661
- if (!match) continue;
16662
- if (resumeSkillIds.has(match.skill.id)) matched.push(r.canonical);
16663
- else missing.push(r.canonical);
16664
- }
16665
- const matchPercentage = ranked.length > 0 ? Math.round(matched.length / ranked.length * 100) : 0;
16666
- const jdSkillIds = new Set(fullMatches.map((m) => m.skill.id));
16667
- const extra = [];
16668
- for (const m of resumeMatches) {
16669
- if (!jdSkillIds.has(m.skill.id)) extra.push(m.skill.canonical);
16670
- }
16671
- extra.splice(25);
16672
- return { matched, missing, extra, matchPercentage };
16673
- }
16674
-
16675
- // src/ats/checks/match.ts
16676
- var SENIORITY = /\b(junior|senior|lead|staff|principal|head of|vp|chief)\b/gi;
16677
- var STOPWORDS = /* @__PURE__ */ new Set([
16678
- "a",
16679
- "an",
16680
- "the",
16681
- "of",
16682
- "at",
16683
- "for",
16684
- "in",
16685
- "on",
16686
- "to",
16687
- "with",
16688
- "and",
16689
- "or"
16690
- ]);
16691
- function tokenize2(s) {
16692
- return s.toLowerCase().replace(SENIORITY, "").replace(/[^a-z0-9 ]/g, " ").split(/\s+/).filter((w) => w && !STOPWORDS.has(w));
16693
- }
16694
- function jaccard(a, b) {
16695
- const A = new Set(a), B = new Set(b);
16696
- let inter = 0;
16697
- for (const x of A) if (B.has(x)) inter++;
16698
- const union = A.size + B.size - inter;
16699
- return union === 0 ? 0 : inter / union;
16700
- }
16701
- function trimTitle(t) {
16702
- return t.trim().replace(/\s*[,\-–—]\s.*$/, "").trim();
16703
- }
16704
- function extractJdTitle(jd) {
16705
- const lines = jd.split("\n").slice(0, 8);
16706
- for (const l of lines) {
16707
- const m = l.match(/(?:role|position|title)[\s:-]+(.+)/i) || l.match(/looking for (?:an? )?(.+?)(?:\s+with|\s+to|$)/i);
16708
- if (m?.[1]) return trimTitle(m[1]);
16709
- }
16710
- const fallback = lines.find(
16711
- (l) => /\b(engineer|developer|manager|designer|analyst|scientist|architect|lead)\b/i.test(l)
16712
- );
16713
- return fallback ? trimTitle(fallback) : void 0;
16714
- }
16715
- var titleAlignment = (resume, _l, { jobDescription }) => {
16716
- if (!jobDescription) {
16717
- return {
16718
- id: "title-alignment",
16719
- tier: "match",
16720
- weight: "high",
16721
- status: "skipped",
16722
- score: 0,
16723
- message: "No JD.",
16724
- hints: []
16725
- };
16726
- }
16727
- const resumeTitle = resume.work?.[0]?.position || resume.basics?.label;
16728
- const jdTitle = extractJdTitle(jobDescription);
16729
- if (!resumeTitle || !jdTitle) {
16730
- return {
16731
- id: "title-alignment",
16732
- tier: "match",
16733
- weight: "high",
16734
- status: "warn",
16735
- score: 50,
16736
- message: "Could not extract title from JD or resume.",
16737
- hints: ["Set basics.label to your target title."]
16738
- };
16739
- }
16740
- const j = jaccard(tokenize2(resumeTitle), tokenize2(jdTitle));
16741
- const status = j >= 0.6 ? "pass" : j >= 0.3 ? "warn" : "fail";
16742
- return {
16743
- id: "title-alignment",
16744
- tier: "match",
16745
- weight: "high",
16746
- status,
16747
- score: Math.round(j * 100),
16748
- message: `Title overlap ${Math.round(j * 100)}% (resume "${resumeTitle}" vs JD "${jdTitle}").`,
16749
- hints: status === "pass" ? [] : [`Consider aligning basics.label closer to "${jdTitle}".`]
16750
- };
16751
- };
16752
- var EDU_RE = {
16753
- 3: /\b(phd|ph\.?d|doctorate|doctoral)\b/i,
16754
- 2: /\b(master|m\.?s|m\.?a|mba|graduate degree)\b/i,
16755
- 1: /\b(bachelor|b\.?s|b\.?a|undergraduate)\b/i
16756
- };
16757
- function eduLevel(text) {
16758
- if (!text) return 0;
16759
- if (EDU_RE[3].test(text)) return 3;
16760
- if (EDU_RE[2].test(text)) return 2;
16761
- if (EDU_RE[1].test(text)) return 1;
16762
- return 0;
16763
- }
16764
- var educationLevel = (resume, _l, { jobDescription }) => {
16765
- if (!jobDescription) {
16766
- return {
16767
- id: "education-level",
16768
- tier: "match",
16769
- weight: "medium",
16770
- status: "skipped",
16771
- score: 0,
16772
- message: "No JD.",
16773
- hints: []
16774
- };
16775
- }
16776
- const required = eduLevel(jobDescription);
16777
- if (required === 0) {
16778
- return {
16779
- id: "education-level",
16780
- tier: "match",
16781
- weight: "medium",
16782
- status: "skipped",
16783
- score: 0,
16784
- message: "JD does not specify education level.",
16785
- hints: []
16786
- };
16787
- }
16788
- const have = Math.max(0, ...(resume.education || []).map((e) => eduLevel(e.studyType || "")));
16789
- const passed = have >= required;
16790
- return {
16791
- id: "education-level",
16792
- tier: "match",
16793
- weight: "medium",
16794
- status: passed ? "pass" : "fail",
16795
- score: passed ? 100 : Math.round(have / required * 100),
16796
- message: `Resume level ${have}, JD required ${required}.`,
16797
- hints: passed ? [] : ["JD requires a higher degree level than the resume reports."]
16798
- };
16799
- };
16800
- var YOE_RE = /(\d+)\s*(?:\+|[-–—]\s*\d+|to\s*\d+)?\s*years?/i;
16801
- var yoeMatch = (resume, _l, { jobDescription }) => {
16802
- if (!jobDescription) {
16803
- return {
16804
- id: "yoe-match",
16805
- tier: "match",
16806
- weight: "high",
16807
- status: "skipped",
16808
- score: 0,
16809
- message: "No JD.",
16810
- hints: []
16811
- };
16812
- }
16813
- const m = jobDescription.match(YOE_RE);
16814
- if (!m) {
16815
- return {
16816
- id: "yoe-match",
16817
- tier: "match",
16818
- weight: "high",
16819
- status: "skipped",
16820
- score: 0,
16821
- message: "JD does not specify years requirement.",
16822
- hints: []
16823
- };
16824
- }
16825
- const required = parseInt(m[1] ?? "0", 10);
16826
- const have = Math.floor(computeYoeYears(resume.work || []));
16827
- const status = have >= required ? "pass" : have >= required - 1 ? "warn" : "fail";
16828
- return {
16829
- id: "yoe-match",
16830
- tier: "match",
16831
- weight: "high",
16832
- status,
16833
- score: Math.min(100, Math.round(have / required * 100)),
16834
- message: `${have} YOE detected vs ${required} required.`,
16835
- hints: status === "pass" ? [] : ["Highlight relevant earlier roles or projects to fill the gap."]
16836
- };
16837
- };
16838
- var hardSkillOverlap = (resume, language, { jobDescription }) => {
16839
- if (!jobDescription) {
16840
- return {
16841
- id: "hard-skill-overlap",
16842
- tier: "match",
16843
- weight: "high",
16844
- status: "skipped",
16845
- score: 0,
16846
- message: "No JD.",
16847
- hints: []
16848
- };
16849
- }
16850
- const km = matchJobDescription(resume, jobDescription, language);
16851
- const pct = km.matchPercentage;
16852
- const status = pct >= 70 ? "pass" : pct >= 50 ? "warn" : "fail";
16853
- return {
16854
- id: "hard-skill-overlap",
16855
- tier: "match",
16856
- weight: "high",
16857
- status,
16858
- score: pct,
16859
- message: `${km.matched.length}/${km.matched.length + km.missing.length} hard skills matched (${pct}%).`,
16860
- hints: status === "pass" ? [] : km.missing.slice(0, 5).map((s) => `Add evidence of "${s}" to skills/highlights.`)
16861
- };
16862
- };
16863
- var allMatchChecks = [hardSkillOverlap, titleAlignment, educationLevel, yoeMatch];
16864
- var KNOCKOUTS = [
16865
- {
16866
- signal: "work-auth",
16867
- jdPattern: /(work\s*auth|authorization to work|right to work|us citizen|green card|h-?1b|visa sponsorship)/i,
16868
- resumeMatch: (r) => /(work auth|authorized|citizen|green card|visa)/i.test(r.basics?.summary || ""),
16869
- recommendation: "Confirm authorization status in the application form."
16870
- },
16871
- {
16872
- signal: "location",
16873
- jdPattern: /(must be located|on-?site|relocate|based in)\s+([a-zA-Z ,]+)/i,
16874
- resumeMatch: (r, m) => {
16875
- const jdLoc = (m[2] ?? "").toLowerCase().trim();
16876
- const resumeCity = (r.basics?.location?.city ?? "").toLowerCase().trim();
16877
- if (!jdLoc || !resumeCity) return false;
16878
- return jdLoc.includes(resumeCity) || resumeCity.includes(jdLoc.split(/[, ]+/)[0] ?? "");
16879
- },
16880
- recommendation: "Verify location requirement against your basics.location.city."
16881
- },
16882
- {
16883
- signal: "clearance",
16884
- jdPattern: /(ts\/sci|secret clearance|security clearance|active clearance)/i,
16885
- resumeMatch: (r) => /clearance/i.test(JSON.stringify(r)),
16886
- recommendation: "Confirm clearance level on the application form."
16887
- },
16888
- {
16889
- signal: "certification",
16890
- jdPattern: /(cissp|aws certified|pmp|ccna|cpa|required certification)/i,
16891
- resumeMatch: (r) => (r.certificates?.length ?? 0) > 0,
16892
- recommendation: "List required certificates in the certificates section if held."
16893
- }
16894
- ];
16895
- function extractKnockouts(resume, jobDescription) {
16896
- const out = [];
16897
- for (const k of KNOCKOUTS) {
16898
- const m = jobDescription.match(k.jdPattern);
16899
- if (!m) continue;
16900
- if (k.resumeMatch(resume, m)) continue;
16901
- out.push({
16902
- signal: k.signal,
16903
- evidence: `JD: "${m[0]}"; resume silent.`,
16904
- recommendation: k.recommendation
16905
- });
16906
- }
16907
- return out;
16908
- }
16909
-
16910
- // src/ats/scoring.ts
16911
- var weightMultiplier = {
16912
- high: 3,
16913
- medium: 2,
16914
- low: 1
16915
- };
16916
- function gradeFromScore(score, t) {
16917
- if (score >= t.A) return "A";
16918
- if (score >= t.B) return "B";
16919
- if (score >= t.C) return "C";
16920
- if (score >= t.D) return "D";
16921
- return "F";
16922
- }
16923
- function computeTierScore(checks) {
16924
- const active = checks.filter((c) => c.status !== "skipped");
16925
- if (active.length === 0) return 0;
16926
- let weighted = 0;
16927
- let total = 0;
16928
- for (const c of active) {
16929
- const m = weightMultiplier[c.weight];
16930
- weighted += c.score * m;
16931
- total += 100 * m;
16932
- }
16933
- return total > 0 ? Math.round(weighted / total * 100) : 0;
16934
- }
16935
- function computeTotalScore(tiers, weights) {
16936
- if (tiers.match === void 0) {
16937
- const sum2 = weights.parsing + weights.recruiter;
16938
- const wp = weights.parsing / sum2;
16939
- const wr = weights.recruiter / sum2;
16940
- return Math.round(
16941
- tiers.parsing * (sum2 === 50 ? 0.4 : wp) + tiers.recruiter * (sum2 === 50 ? 0.6 : wr)
16942
- );
16943
- }
16944
- const sum = weights.parsing + weights.match + weights.recruiter;
16945
- return Math.round(
16946
- (tiers.parsing * weights.parsing + tiers.match * weights.match + tiers.recruiter * weights.recruiter) / sum
16947
- );
16948
- }
16949
- function scoreToRating(score, t) {
16950
- if (score >= t.excellent) return "excellent";
16951
- if (score >= t.good) return "good";
16952
- if (score >= t.needsWork) return "needs-work";
16953
- return "poor";
16954
- }
16955
- function generateSummary(score, rating, hasJd, knockouts) {
16956
- const ratingLabel = {
16957
- excellent: "Excellent",
16958
- good: "Good",
16959
- "needs-work": "Needs Work",
16960
- poor: "Poor"
16961
- }[rating];
16962
- const knockoutNote = knockouts > 0 ? ` ${knockouts} knockout signal${knockouts === 1 ? "" : "s"} flagged.` : "";
16963
- return `ATS ${score}/100 (${ratingLabel}).${hasJd ? " Includes JD match." : ""}${knockoutNote}`;
16964
- }
16965
-
16966
- // src/ats/index.ts
16967
- function applyConfig(checks, cfg) {
16968
- return checks.filter((c) => !cfg.disable.includes(c.id)).map((c) => ({ ...c, weight: effectiveWeight(c.id, c.weight, cfg) }));
16969
- }
16970
- function buildTier(_tier, checks, cfg) {
16971
- const filtered = applyConfig(checks, cfg);
16972
- const score = computeTierScore(filtered);
16973
- return {
16974
- score,
16975
- grade: gradeFromScore(score, cfg.thresholds.grade),
16976
- checks: filtered
16977
- };
16978
- }
16979
- function analyzeAts(resume, options = {}) {
16980
- const cfg = options.config ?? defaultConfig;
16981
- const language = options.language ?? cfg.locale;
16982
- const parsingChecks = allParsingChecks.map((fn) => fn(resume, language));
16983
- const recruiterChecks = allRecruiterChecks.map((fn) => fn(resume, language, cfg));
16984
- const parsing = buildTier("parsing", parsingChecks, cfg);
16985
- const recruiter = buildTier("recruiter", recruiterChecks, cfg);
16986
- let match;
16987
- let knockouts = [];
16988
- if (options.jobDescription) {
16989
- const matchChecks = allMatchChecks.map(
16990
- (fn) => fn(resume, language, { jobDescription: options.jobDescription })
16991
- );
16992
- match = buildTier("match", matchChecks, cfg);
16993
- knockouts = extractKnockouts(resume, options.jobDescription);
16994
- }
16995
- const totalScore = computeTotalScore(
16996
- { parsing: parsing.score, match: match?.score, recruiter: recruiter.score },
16997
- cfg.weights.tiers
16998
- );
16999
- const rating = scoreToRating(totalScore, cfg.thresholds.rating);
17000
- const summary = generateSummary(totalScore, rating, !!options.jobDescription, knockouts.length);
17001
- return {
17002
- score: totalScore,
17003
- rating,
17004
- tiers: match ? { parsing, match, recruiter } : { parsing, recruiter },
17005
- knockouts,
17006
- summary
17007
- };
17008
- }
17009
-
17010
- // src/utils/themeLoader.ts
17011
- import { execFileSync } from "child_process";
17012
- import { createRequire } from "module";
17013
- var require2 = createRequire(import.meta.url);
17014
- function installTheme(packageName) {
17015
- try {
17016
- execFileSync("npm", ["install", packageName], {
17017
- stdio: ["inherit", "pipe", "pipe"],
17018
- encoding: "utf8"
17019
- });
17020
- } catch (error) {
17021
- throw new Error(`Failed to install ${packageName}: ${error.message}`);
17022
- }
17023
- }
17024
- function loadTheme(themeName, options) {
17025
- let jsonResumeThemeName;
17026
- let nativeThemeName;
17027
- const autoInstall = options?.autoInstall !== false;
17028
- try {
17029
- jsonResumeThemeName = themeName.startsWith("jsonresume-theme-") ? themeName : `jsonresume-theme-${themeName}`;
17030
- try {
17031
- return require2(jsonResumeThemeName);
17032
- } catch (_jsonResumeError) {
17033
- nativeThemeName = `@resuml/theme-${themeName}`;
17034
- try {
17035
- return require2(nativeThemeName);
17036
- } catch (_nativeError) {
17037
- if (!autoInstall) {
17038
- throw new Error(
17039
- `Theme package ${jsonResumeThemeName} or ${nativeThemeName} not found in node_modules.
17040
- Please install the theme package manually.`
17041
- );
17042
- }
17043
- console.log(`\u{1F4E6} Theme ${jsonResumeThemeName} not found. Installing...`);
17044
- try {
17045
- installTheme(jsonResumeThemeName);
17046
- console.log(`\u2705 Successfully installed ${jsonResumeThemeName}`);
17047
- return require2(jsonResumeThemeName);
17048
- } catch (installError) {
17049
- throw new Error(
17050
- `Failed to auto-install theme ${jsonResumeThemeName}: ${installError.message}`
17051
- );
17052
- }
17053
- }
17054
- }
17055
- } catch (error) {
17056
- if (error instanceof Error && error.message.includes("Failed to auto-install")) {
17057
- throw error;
17058
- }
17059
- throw new Error(`Theme package ${themeName} not found`);
17060
- }
17061
- }
17062
-
17063
- // src/utils/resumeTemplate.ts
17064
- function generateResumeYaml(name, email, label) {
17065
- return `# =============================================================================
17066
- # Resume - Generated by resuml
17067
- # Documentation: https://github.com/phoinixi/resuml
17068
- # Schema: https://jsonresume.org/schema/
17069
- # =============================================================================
17070
-
17071
- # --- Basic Information ---
17072
- basics:
17073
- name: '${name}'
17074
- label: '${label}'
17075
- # image: 'https://example.com/photo.jpg'
17076
- email: '${email}'
17077
- # phone: '+1-555-123-4567'
17078
- # url: 'https://yourwebsite.com'
17079
- summary: >-
17080
- Write a short professional summary here.
17081
- This supports multi-line strings in YAML.
17082
- location:
17083
- # address: '123 Main Street'
17084
- # postalCode: '12345'
17085
- city: 'Your City'
17086
- countryCode: 'US'
17087
- # region: 'Your State'
17088
- profiles:
17089
- - network: 'LinkedIn'
17090
- username: 'your-username'
17091
- url: 'https://linkedin.com/in/your-username'
17092
- - network: 'GitHub'
17093
- username: 'your-username'
17094
- url: 'https://github.com/your-username'
17095
-
17096
- # --- Work Experience ---
17097
- work:
17098
- - name: 'Company Name'
17099
- position: 'Job Title'
17100
- url: 'https://company.com'
17101
- startDate: '2020-01-01'
17102
- # endDate: '2023-12-31' # Omit for current position
17103
- summary: 'Brief description of your role and responsibilities.'
17104
- highlights:
17105
- - 'Key achievement or responsibility'
17106
- - 'Another notable accomplishment'
17107
-
17108
- # --- Education ---
17109
- education:
17110
- - institution: 'University Name'
17111
- url: 'https://university.edu'
17112
- area: 'Field of Study'
17113
- studyType: 'Bachelor of Science'
17114
- startDate: '2014-09-01'
17115
- endDate: '2018-06-01'
17116
- # score: '3.8'
17117
- courses:
17118
- - 'Relevant Course 1'
17119
- - 'Relevant Course 2'
17120
-
17121
- # --- Skills ---
17122
- skills:
17123
- - name: 'Programming Languages'
17124
- level: 'Expert'
17125
- keywords:
17126
- - 'JavaScript'
17127
- - 'TypeScript'
17128
- - 'Python'
17129
- - name: 'Frameworks'
17130
- level: 'Advanced'
17131
- keywords:
17132
- - 'React'
17133
- - 'Node.js'
17134
-
17135
- # --- Projects ---
17136
- # projects:
17137
- # - name: 'Project Name'
17138
- # description: 'Brief project description'
17139
- # highlights:
17140
- # - 'Key feature or result'
17141
- # keywords:
17142
- # - 'Technology Used'
17143
- # startDate: '2023-01-01'
17144
- # endDate: '2023-06-30'
17145
- # url: 'https://github.com/you/project'
17146
- # roles:
17147
- # - 'Developer'
17148
- # type: 'application'
17149
-
17150
- # --- Languages ---
17151
- # languages:
17152
- # - language: 'English'
17153
- # fluency: 'Native speaker'
17154
- # - language: 'Spanish'
17155
- # fluency: 'Professional working proficiency'
17156
-
17157
- # --- Interests ---
17158
- # interests:
17159
- # - name: 'Open Source'
17160
- # keywords:
17161
- # - 'Contributing'
17162
- # - 'Community'
17163
-
17164
- # --- References ---
17165
- # references:
17166
- # - name: 'Jane Smith'
17167
- # reference: 'It was a pleasure working with...'
17168
-
17169
- # --- Awards ---
17170
- # awards:
17171
- # - title: 'Award Name'
17172
- # date: '2023-01-01'
17173
- # awarder: 'Organization'
17174
- # summary: 'Description of the award'
17175
-
17176
- # --- Certificates ---
17177
- # certificates:
17178
- # - name: 'Certificate Name'
17179
- # date: '2023-01-01'
17180
- # issuer: 'Issuing Organization'
17181
- # url: 'https://example.com/cert'
17182
-
17183
- # --- Publications ---
17184
- # publications:
17185
- # - name: 'Publication Title'
17186
- # publisher: 'Publisher'
17187
- # releaseDate: '2023-01-01'
17188
- # url: 'https://example.com/publication'
17189
- # summary: 'Brief description'
17190
-
17191
- # --- Volunteer ---
17192
- # volunteers:
17193
- # - organization: 'Organization Name'
17194
- # position: 'Volunteer Role'
17195
- # url: 'https://organization.com'
17196
- # startDate: '2022-01-01'
17197
- # summary: 'Description of volunteer work'
17198
- # highlights:
17199
- # - 'Notable contribution'
17200
- `;
17201
- }
17202
-
17203
- // src/utils/themeInfo.ts
17204
- import { createRequire as createRequire2 } from "module";
17205
- var KNOWN_THEMES = [
17206
- {
17207
- name: "stackoverflow",
17208
- pkg: "jsonresume-theme-stackoverflow",
17209
- description: "Stack Overflow inspired theme"
17210
- },
17211
- { name: "elegant", pkg: "jsonresume-theme-elegant", description: "Elegant and professional" },
17212
- { name: "react", pkg: "jsonresume-theme-react", description: "Built with React components" },
17213
- { name: "even", pkg: "jsonresume-theme-even", description: "Clean and minimal" },
17214
- { name: "kendall", pkg: "jsonresume-theme-kendall", description: "Simple and clean layout" },
17215
- { name: "macchiato", pkg: "jsonresume-theme-macchiato", description: "Beautiful and modern" },
17216
- { name: "flat", pkg: "jsonresume-theme-flat", description: "Flat design theme" },
17217
- { name: "class", pkg: "jsonresume-theme-class", description: "Classic professional look" },
17218
- { name: "short", pkg: "jsonresume-theme-short", description: "Compact single-page resume" },
17219
- { name: "spartan", pkg: "jsonresume-theme-spartan", description: "Minimalist Spartan design" },
17220
- { name: "paper", pkg: "jsonresume-theme-paper", description: "Paper-like clean design" },
17221
- { name: "onepage", pkg: "jsonresume-theme-onepage", description: "One page resume layout" }
17222
- ];
17223
- function isThemeInstalled(pkg) {
17224
- try {
17225
- const req = createRequire2(process.cwd() + "/");
17226
- req.resolve(pkg);
17227
- return true;
17228
- } catch {
17229
- return false;
17230
- }
17231
- }
17232
- function getInstalledVersion(pkg) {
17233
- try {
17234
- const req = createRequire2(process.cwd() + "/");
17235
- const pkgJson = req(`${pkg}/package.json`);
17236
- return pkgJson.version ?? null;
17237
- } catch {
17238
- return null;
17239
- }
17240
- }
17241
-
17242
- // src/ats/rubric.ts
17243
- var rubric = [
17244
- // Parsing tier - Greenhouse-doc-grounded
17245
- {
17246
- id: "conventional-sections",
17247
- tier: "parsing",
17248
- weight: "high",
17249
- evidenceLevel: "evidence",
17250
- description: "Required JSON Resume sections (basics, work, education) are present.",
17251
- source: "https://support.greenhouse.io/hc/en-us/articles/200989175-Unsuccessful-resume-parse"
17252
- },
17253
- {
17254
- id: "date-format-consistency",
17255
- tier: "parsing",
17256
- weight: "medium",
17257
- evidenceLevel: "evidence",
17258
- description: "Dates use ISO-8601 (YYYY-MM-DD or YYYY-MM); no mixed formats; no >6mo gaps.",
17259
- source: "https://hireflow.net/blog/taleo-resume-parsing-problems-explained"
17260
- },
17261
- {
17262
- id: "contact-in-body",
17263
- tier: "parsing",
17264
- weight: "high",
17265
- evidenceLevel: "evidence",
17266
- description: "Email and phone present in basics block (parseable by Textkernel/Sovren).",
17267
- source: "https://developer.textkernel.com/tx-platform/v10/faq/"
17268
- },
17269
- {
17270
- id: "reverse-chron-order",
17271
- tier: "parsing",
17272
- weight: "medium",
17273
- evidenceLevel: "evidence",
17274
- description: "work[] and education[] sorted descending by startDate."
17275
- },
17276
- {
17277
- id: "education-complete",
17278
- tier: "parsing",
17279
- weight: "low",
17280
- evidenceLevel: "evidence",
17281
- description: "Each education entry has institution, area, and studyType."
17282
- },
17283
- {
17284
- id: "pdf-text-extractable",
17285
- tier: "parsing",
17286
- weight: "high",
17287
- evidenceLevel: "evidence",
17288
- description: "Rendered PDF body text is selectable (>=70% of resume word count extractable).",
17289
- source: "https://www.ashbyhq.com/product-updates/ai-assisted-application-review"
17290
- },
17291
- {
17292
- id: "pdf-size-under-2.5mb",
17293
- tier: "parsing",
17294
- weight: "medium",
17295
- evidenceLevel: "evidence",
17296
- description: "Rendered PDF is under 2.5 MB (Greenhouse documented limit).",
17297
- source: "https://support.greenhouse.io/hc/en-us/articles/200989175-Unsuccessful-resume-parse"
17298
- },
17299
- // Match tier - JD-aware
17300
- {
17301
- id: "hard-skill-overlap",
17302
- tier: "match",
17303
- weight: "high",
17304
- evidenceLevel: "evidence",
17305
- description: "Hard skill overlap with JD using bundled O*NET-trie skill index. ESCO migration tracked separately."
17306
- },
17307
- {
17308
- id: "title-alignment",
17309
- tier: "match",
17310
- weight: "high",
17311
- evidenceLevel: "evidence",
17312
- description: "Most-recent work[].position aligns with JD title via token Jaccard after stripping seniority modifiers."
17313
- },
17314
- {
17315
- id: "education-level",
17316
- tier: "match",
17317
- weight: "medium",
17318
- evidenceLevel: "evidence",
17319
- description: "Max studyType meets/exceeds JD education requirement (Bachelor/Master/PhD detection)."
17320
- },
17321
- {
17322
- id: "yoe-match",
17323
- tier: "match",
17324
- weight: "high",
17325
- evidenceLevel: "evidence",
17326
- description: "Total years of experience (overlap-merged from work[]) meets JD years requirement."
17327
- },
17328
- // Recruiter tier - Rezi/Teal/Enhancv-grounded numbers
17329
- {
17330
- id: "summary-length",
17331
- tier: "recruiter",
17332
- weight: "medium",
17333
- evidenceLevel: "convention",
17334
- description: "Professional summary 20-50 words / 2-4 sentences.",
17335
- source: "https://www.rezi.ai/rezi-docs/the-rezi-score-explained"
17336
- },
17337
- {
17338
- id: "action-verb-start",
17339
- tier: "recruiter",
17340
- weight: "medium",
17341
- evidenceLevel: "convention",
17342
- description: "Highlights start with an action verb."
17343
- },
17344
- {
17345
- id: "quantification-density",
17346
- tier: "recruiter",
17347
- weight: "high",
17348
- evidenceLevel: "convention",
17349
- description: "At least 50% of highlights include numbers/metrics."
17350
- },
17351
- {
17352
- id: "pronoun-leakage",
17353
- tier: "recruiter",
17354
- weight: "low",
17355
- evidenceLevel: "convention",
17356
- description: "Convention, not ATS: ATS does not filter on pronouns; this is recruiter style.",
17357
- source: "https://resume.io/blog/first-person-resume"
17358
- },
17359
- {
17360
- id: "bullets-per-role",
17361
- tier: "recruiter",
17362
- weight: "medium",
17363
- evidenceLevel: "convention",
17364
- description: "Each work entry has 3-6 highlights (10 for senior).",
17365
- source: "https://www.rezi.ai/rezi-docs/the-rezi-score-explained"
17366
- },
17367
- {
17368
- id: "word-count-total",
17369
- tier: "recruiter",
17370
- weight: "low",
17371
- evidenceLevel: "convention",
17372
- description: "Total resume body 400-800 words (1600 senior).",
17373
- source: "https://www.rezi.ai/rezi-docs/the-rezi-score-explained"
17374
- },
17375
- {
17376
- id: "highlight-length",
17377
- tier: "recruiter",
17378
- weight: "low",
17379
- evidenceLevel: "convention",
17380
- description: "Each highlight fits within two visual lines (~30 words)."
17381
- },
17382
- {
17383
- id: "has-linkedin",
17384
- tier: "recruiter",
17385
- weight: "low",
17386
- evidenceLevel: "convention",
17387
- description: "LinkedIn profile present in basics.profiles."
17388
- },
17389
- {
17390
- id: "skills-populated",
17391
- tier: "recruiter",
17392
- weight: "medium",
17393
- evidenceLevel: "convention",
17394
- description: "At least 3 skill categories with keywords."
17395
- }
17396
- ];
17397
- var byId = new Map(rubric.map((r) => [r.id, r]));
17398
- function getRubricEntry(id, opts = {}) {
17399
- const entry = byId.get(id);
17400
- if (!entry && opts.strict) throw new Error(`Unknown rubric id: ${id}`);
17401
- return entry;
17402
- }
17403
- function rubricByTier(tier) {
17404
- return rubric.filter((r) => r.tier === tier);
17405
- }
17406
- function listRubricMarkdown() {
17407
- const tiers = ["parsing", "match", "recruiter"];
17408
- const sections = tiers.map((t) => {
17409
- const entries = rubricByTier(t);
17410
- const lines = entries.map(
17411
- (e) => `- **${e.id}** (${e.weight}, ${e.evidenceLevel}): ${e.description}${e.source ? `
17412
- Source: ${e.source}` : ""}`
17413
- ).join("\n");
17414
- return `## Tier: ${t}
17415
-
17416
- ${lines}`;
17417
- });
17418
- return ["# resuml ATS Rubric", "", ...sections].join("\n\n");
15143
+ function _resetSkillIndexCache() {
15144
+ cached = null;
17419
15145
  }
17420
15146
 
17421
15147
  export {
17422
15148
  __export,
17423
- processResumeData,
17424
- loadConfig,
17425
- analyzeAts,
17426
- loadTheme,
17427
- generateResumeYaml,
17428
- KNOWN_THEMES,
17429
- isThemeInstalled,
17430
- getInstalledVersion,
17431
- getRubricEntry,
17432
- listRubricMarkdown
15149
+ tokenize,
15150
+ SkillIndex,
15151
+ getSkillIndex,
15152
+ _resetSkillIndexCache
17433
15153
  };
17434
- //# sourceMappingURL=chunk-R4MD5YMV.js.map
15154
+ //# sourceMappingURL=chunk-QR77BRMN.js.map