import { randomUUID } from 'node:crypto'; import { promises as fs } from 'node:fs'; import path from 'node:path'; import { PreprocessError, PREPROCESS_ERROR_CODES } from '../errors'; import type { PreprocessContext, PreprocessTaskResult } from '../types'; import { normalizeDistinctStrings } from '../utils';
export type RawExperience = { id?: string; company: string; title: string; location?: string; startDate: string; endDate?: string | null; summary?: string; bullets?: string[]; skills?: string[]; linkedProjects?: string[]; experienceType?: 'full_time' | 'internship' | 'contract' | 'freelance' | 'other'; impactSummary?: string; sizeOrScope?: string; };
export type RawEducation = { id?: string; institution: string; degree?: string; field?: string; location?: string; startDate?: string; endDate?: string | null; summary?: string; bullets?: string[]; skills?: string[]; };
export type RawAward = { id?: string; title: string; issuer?: string; date?: string | null; summary?: string; bullets?: string[]; skills?: string[]; };
export type RawSkill = { id?: string; name: string; category?: string; summary?: string; skills?: string[]; };
export type ResumeSource = { snapshotDate?: string; experiences: RawExperience[]; education?: RawEducation[]; awards?: RawAward[]; skills?: RawSkill[]; };
export type NormalizedExperience = { type: 'experience'; id: string; slug: string; company: string; title: string; location?: string; startDate: string; endDate?: string | null; isCurrent: boolean; experienceType: 'full_time' | 'internship' | 'contract' | 'freelance' | 'other'; summary?: string; bullets: string[]; skills: string[]; linkedProjects: string[]; monthsOfExperience?: number | null; impactSummary?: string; sizeOrScope?: string; };
export type NormalizedEducation = { type: 'education'; id: string; institution: string; degree?: string; field?: string; location?: string; startDate?: string; endDate?: string | null; isCurrent?: boolean; summary?: string; bullets: string[]; skills: string[]; };
export type NormalizedAward = { type: 'award'; id: string; title: string; issuer?: string; date?: string | null; summary?: string; bullets: string[]; skills: string[]; };
export type NormalizedSkill = { type: 'skill'; id: string; name: string; category?: string; summary?: string; skills: string[]; };
function slugify(value: string) { return value .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .replace(/-{2,}/g, '-'); }
function normalizeDate(value: string, field: string): string {
if (!value) {
throw new PreprocessError(PREPROCESS_ERROR_CODES.RESUME_FIELD_INVALID, Experience ${field} is required);
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
throw new PreprocessError(
PREPROCESS_ERROR_CODES.RESUME_FIELD_INVALID,
Experience ${field} must be a valid date (received ${value})
);
}
return parsed.toISOString().split('T')[0] ?? value;
}
function diffInMonths(startDate: string, endDate?: string | null) { if (!endDate) { return null; } const start = new Date(startDate); const end = new Date(endDate); if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { return null; } const months = (end.getFullYear() - start.getFullYear()) * 12 + (end.getMonth() - start.getMonth() + 1); return months > 0 ? months : null; }
export function detectExperienceType(raw: RawExperience): NormalizedExperience['experienceType'] {
if (
raw.experienceType &&
['full_time', 'internship', 'contract', 'freelance', 'other'].includes(raw.experienceType)
) {
return raw.experienceType;
}
const haystack = ${raw.title ?? ''} ${raw.summary ?? ''}.toLowerCase();
if (haystack.includes('intern')) {
return 'internship';
}
if (haystack.includes('contract') || haystack.includes('contractor')) {
return 'contract';
}
if (haystack.includes('freelance') || haystack.includes('consultant')) {
return 'freelance';
}
return 'full_time';
}
function normalizeExperience(raw: RawExperience): NormalizedExperience {
if (!raw.company) {
throw new PreprocessError(PREPROCESS_ERROR_CODES.RESUME_FIELD_INVALID, 'Experience company is required');
}
if (!raw.title) {
throw new PreprocessError(PREPROCESS_ERROR_CODES.RESUME_FIELD_INVALID, 'Experience title is required');
}
const slugSeed = ${raw.company}-${raw.title}.trim() || randomUUID();
const slug = slugify(slugSeed) || slugify(randomUUID());
const id = raw.id?.trim() || slug;
const startDate = normalizeDate(raw.startDate, 'startDate');
const endDate = raw.endDate ? normalizeDate(raw.endDate, 'endDate') : null;
const bullets = normalizeDistinctStrings(raw.bullets);
const skills = normalizeDistinctStrings(raw.skills);
const linkedProjects = normalizeDistinctStrings(raw.linkedProjects);
const experienceType = detectExperienceType(raw);
return { type: 'experience', id, slug, company: raw.company.trim(), title: raw.title.trim(), location: raw.location?.trim() || undefined, startDate, endDate, isCurrent: !endDate, experienceType, summary: raw.summary?.trim() || undefined, impactSummary: raw.impactSummary?.trim() || undefined, sizeOrScope: raw.sizeOrScope?.trim() || undefined, bullets, skills, linkedProjects, monthsOfExperience: diffInMonths(startDate, endDate), }; }
function normalizeEducation(raw: RawEducation): NormalizedEducation { const id = raw.id?.trim() || slugify(raw.institution || raw.degree || randomUUID()); return { type: 'education', id, institution: raw.institution?.trim() || '', degree: raw.degree?.trim() || undefined, field: raw.field?.trim() || undefined, location: raw.location?.trim() || undefined, startDate: raw.startDate ? normalizeDate(raw.startDate, 'startDate') : undefined, endDate: raw.endDate ? normalizeDate(raw.endDate, 'endDate') : undefined, isCurrent: raw.endDate == null, summary: raw.summary?.trim() || undefined, bullets: normalizeDistinctStrings(raw.bullets), skills: normalizeDistinctStrings(raw.skills), }; }
function normalizeAward(raw: RawAward): NormalizedAward { const id = raw.id?.trim() || slugify(raw.title || randomUUID()); return { type: 'award', id, title: raw.title?.trim() || '', issuer: raw.issuer?.trim() || undefined, date: raw.date ? normalizeDate(raw.date, 'date') : undefined, summary: raw.summary?.trim() || undefined, bullets: normalizeDistinctStrings(raw.bullets), skills: normalizeDistinctStrings(raw.skills), }; }
function normalizeSkill(raw: RawSkill): NormalizedSkill { const id = raw.id?.trim() || slugify(raw.name || randomUUID()); return { type: 'skill', id, name: raw.name?.trim() || '', category: raw.category?.trim() || undefined, summary: raw.summary?.trim() || undefined, skills: normalizeDistinctStrings(raw.skills), }; }
const shouldExpandSkill = (skill: NormalizedSkill, containerPatterns: RegExp[]): boolean => { if (!containerPatterns.length) return false; if (!skill.skills?.length) return false; const name = skill.name?.trim(); if (!name) return false; return containerPatterns.some((pattern) => pattern.test(name)); };
function expandSkillEntries(skills: NormalizedSkill[], containerPatterns: RegExp[]): NormalizedSkill[] { const seen = new Set(); const result: NormalizedSkill[] = [];
const addSkill = (skill: NormalizedSkill) => {
const baseId = skill.id?.trim() || slugify(skill.name || randomUUID());
let candidate = baseId || slugify(randomUUID());
let suffix = 1;
while (seen.has(candidate)) {
candidate = ${baseId}-${suffix++};
}
seen.add(candidate);
result.push({ ...skill, id: candidate });
};
for (const skill of skills) { if (shouldExpandSkill(skill, containerPatterns)) { const childNames = normalizeDistinctStrings(skill.skills); for (const childName of childNames) { addSkill({ type: 'skill', id: slugify(childName), name: childName, category: skill.category, summary: skill.summary, skills: [], }); } continue; }
addSkill({ ...skill, skills: normalizeDistinctStrings(skill.skills) });
}
return result; }
export async function runResumeTask(context: PreprocessContext): Promise { const sourcePath = context.paths.resumeJson; const outputPath = context.paths.experiencesOutput; const rootDir = context.paths.rootDir; const relativeSource = path.relative(rootDir, sourcePath);
const sourceExists = await fs .access(sourcePath) .then(() => true) .catch(() => false); const resolvedSourcePath = sourceExists ? sourcePath : null;
if (!resolvedSourcePath) {
throw new PreprocessError(PREPROCESS_ERROR_CODES.NO_RESUME, Resume source file not found at ${relativeSource});
}
const rawContents = await fs.readFile(resolvedSourcePath, 'utf-8'); const parsed = JSON.parse(rawContents) as ResumeSource; if (!Array.isArray(parsed.experiences)) { throw new PreprocessError( PREPROCESS_ERROR_CODES.RESUME_SOURCE_INVALID, 'Resume source must include an experiences array' ); }
const normalized = parsed.experiences.map(normalizeExperience).sort((a, b) => { if (a.startDate === b.startDate) { return 0; } return a.startDate < b.startDate ? 1 : -1; });
const education = Array.isArray(parsed.education) ? parsed.education.map(normalizeEducation) : []; const awards = Array.isArray(parsed.awards) ? parsed.awards.map(normalizeAward) : []; const skills = Array.isArray(parsed.skills) ? expandSkillEntries(parsed.skills.map(normalizeSkill), context.config.resume.skillContainerPatterns) : [];
const snapshotDate = parsed.snapshotDate ?? 'unspecified'; const payload = { snapshotDate, experiences: normalized, education, awards, skills }; const artifact = await context.artifacts.writeJson({ id: 'resume', filePath: outputPath, data: payload, });
return {
description: Normalized ${normalized.length} experiences, ${education.length} education entries,
counts: [
{ label: 'Experiences', value: normalized.length },
{ label: 'Education', value: education.length },
{ label: 'Awards', value: awards.length },
{ label: 'Skills', value: skills.length },
],
artifacts: [{ path: artifact.relativePath, note: snapshot ${snapshotDate} }],
};
}
