import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { metricClaims, stripMarkup } from './verify-cv-facts.mjs';
export const BANNED_PHRASES = [
'passionate about',
'results-oriented',
'results-driven',
'proven track record',
'leveraged',
'leveraging',
'leverage',
'spearheaded',
'spearheading',
'spearhead',
'facilitated',
'facilitating',
'synergies',
'synergy',
'cutting-edge',
'cutting edge',
"in today's fast-paced world",
'demonstrated ability to',
'best practices',
'significant impact',
'measurable impact',
'orchestrated',
'championed',
'utilized',
'delve',
'tapestry',
'pivotal',
];
const EM_DASH = '\u2014';
const EN_DASH = '\u2013';
function norm(text) {
return String(text).toLowerCase().replace(/\s+/g, ' ').trim();
}
// A dash between two years is how a range is written, not a stock tell.
// A month may sit before the end year: "Mar 2020 – Dec 2022".
// The HTML builder prints "2022–2024" from the start and end fields itself.
// A metric such as "20–30%" is not a date, so it stays a tell.
const MONTH = 'jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?';
const DATE_RANGE_DASH = new RegExp(
`((?:19|20)\\d{2})\\s*[\\u2013\\u2014]\\s*(?=(?:(?:${MONTH})\\b\\.?\\s+)?(?:(?:19|20)\\d{2}\\b|present\\b|current\\b|now\\b|today\\b))`,
'giu',
);
function linesOf(text) {
const broken = String(text)
// The
is not printed. Its carries the template's own
// "Name — CV" separator, which the candidate never wrote.
.replace(/][\s\S]*?<\/head>/gi, '\n')
.replace(//g, '\n')
.replace(/
/gi, '\n')
.replace(/<\/(?:p|li|h\d|div|tr|section|article)>/gi, '\n');
return stripMarkup(broken, { keepLineBreaks: true })
.split(/\n+/)
.map((line) => line.replace(/\s+/g, ' ').trim())
.filter(Boolean);
}
// The PDF path prints a long dash as "-", a curly quote as a straight one, and
// a no-break space as a space. The phrase check reads both sides that way, so
// "Results\u2013driven" is still "results-driven". The dash warning reads the raw line.
function wordingOf(text) {
return String(text)
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, '-')
.replace(/[\u2018\u2019\u201A\u201B]/g, "'")
.replace(/\u00A0/g, ' ');
}
function hasPhrase(text, phrase) {
// stripMarkup puts a space where bold or a tag ended, so "**Results**-driven"
// reads "Results -driven". A space on either side of a hyphen still matches.
const body = phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/-/g, ' ?- ?');
return new RegExp(`(? {
const key = `${kind}:${value.toLowerCase()}`;
if (seen.has(key)) return;
seen.add(key);
issues.push({ kind, value });
};
for (const line of linesOf(generated)) {
// A whole source line counts at any length, so a copied "2021–2024" stays.
// A fragment has to be long enough, and it has to sit inside one source
// line. Searching the collapsed CV would join "best" and "practices".
const lineNorm = norm(line);
const alreadyInSource = sourceLines.has(lineNorm)
|| (lineNorm.length >= 12 && sourceLineNorms.some((sourceLine) => sourceLine.includes(lineNorm)));
if (alreadyInSource) continue;
const prose = line.replace(DATE_RANGE_DASH, '$1 ');
if (prose.includes(EM_DASH)) push('em-dash', 'em dash');
if (prose.includes(EN_DASH)) push('em-dash', 'en dash');
const wording = wordingOf(line);
for (const phrase of BANNED_PHRASES) {
if (!hasPhrase(wording, phrase)) continue;
if (hasPhrase(sourcePhrases, phrase)) continue;
push('banned-phrase', phrase);
}
}
const allowed = metricClaims(sourceText);
for (const claim of metricClaims(String(generated ?? ''))) {
if (!allowed.has(claim)) push('invented-number', claim);
}
return issues;
}
/**
* The wording failure a renderer shows. A long dash is a warning, not a
* failure: the PDF path already normalizes it. Null when nothing blocks.
*
* @param {{kind: string, value: string}[]} issues
* @param {string} [label]
* @returns {string|null}
*/
export function voiceTellMessage(issues, label) {
const tells = issues.filter((issue) => issue.kind === 'banned-phrase');
if (!tells.length) return null;
const where = label ? ` for ${label}` : '';
const bits = tells.map((issue) => `"${issue.value}"`);
return `Voice check failed${where}: ${bits.join(', ')}. That wording is not in the source CV. Keep the candidate's own words.`;
}
/**
* @param {{kind: string, value: string}[]} issues
* @param {string} [label]
* @returns {string|null}
*/
export function voiceDashWarning(issues, label) {
const dashes = issues.filter((issue) => issue.kind === 'em-dash');
if (!dashes.length) return null;
const where = label ? ` for ${label}` : '';
const bits = [...new Set(dashes.map((issue) => issue.value))];
return `Voice check warning${where}: ${bits.map((value) => `an ${value}`).join(', ')}. The build continues. PDF generation normalizes long dashes for ATS. LaTeX and CV Studio leave the dash in the text.`;
}