diff --git a/hooks/scripts/__tests__/personalization-score.test.mjs b/hooks/scripts/__tests__/personalization-score.test.mjs index 96feea1..18fbdc0 100644 --- a/hooks/scripts/__tests__/personalization-score.test.mjs +++ b/hooks/scripts/__tests__/personalization-score.test.mjs @@ -5,61 +5,80 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { calculateScore } from '../personalization-score.mjs'; -// Step 5 (remediation): the shipped voice profile is a PII-free placeholder -// carrying the sentinel below. Detection must key on the sentinel — NOT the old -// `[Your Name]` heuristic — so the placeholder earns 0 of the 25 voice points, -// and a populated profile (sentinel removed) earns them. +// M0-7: the scorer now reads INSTANCE data from the EXTERNAL data root +// (getDataRoot, stubbed via LINKEDIN_STUDIO_DATA), mirroring the migration's +// dests, and falls back to the plugin tree only for back-compat. The placeholder +// sentinel still keys voice detection (Step-5 remediation): the placeholder earns +// 0 of the 25 voice points; a populated profile (sentinel removed) earns them. const SENTINEL = ''; -// A >50-line body that contains NEITHER the sentinel NOR `[Your Name]`, so the -// only thing distinguishing placeholder from real profile is the sentinel. +// A >50-line body with NEITHER the sentinel NOR `[Your Name]`, so the sentinel is +// the only thing distinguishing placeholder from real profile. const LONG_BODY = Array.from({ length: 60 }, (_, i) => `- voice characteristic line ${i + 1}`).join('\n'); -function makePluginRoot() { - const root = mkdtempSync(join(tmpdir(), 'lis-personalization-')); - mkdirSync(join(root, 'assets', 'voice-samples'), { recursive: true }); - return root; +function makeRoots() { + const dataDir = mkdtempSync(join(tmpdir(), 'lis-score-data-')); + const pluginRoot = mkdtempSync(join(tmpdir(), 'lis-score-plugin-')); + mkdirSync(join(dataDir, 'voice-samples'), { recursive: true }); + return { dataDir, pluginRoot }; } -function writeVoice(root, content) { - writeFileSync(join(root, 'assets', 'voice-samples', 'authentic-voice-samples.md'), content, 'utf-8'); +function writeExternalVoice(dataDir, content) { + writeFileSync(join(dataDir, 'voice-samples', 'authentic-voice-samples.md'), content, 'utf-8'); } -describe('calculateScore — voice samples (sentinel-based placeholder detection)', () => { - let root; +describe('calculateScore — reads external instance data (M0-7)', () => { + let dataDir, pluginRoot; + const saved = { LINKEDIN_STUDIO_DATA: process.env.LINKEDIN_STUDIO_DATA }; afterEach(() => { - if (root && existsSync(root)) { - rmSync(root, { recursive: true, force: true }); + for (const d of [dataDir, pluginRoot]) { + if (d && existsSync(d)) rmSync(d, { recursive: true, force: true }); } - root = undefined; + dataDir = pluginRoot = undefined; + if (saved.LINKEDIN_STUDIO_DATA === undefined) delete process.env.LINKEDIN_STUDIO_DATA; + else process.env.LINKEDIN_STUDIO_DATA = saved.LINKEDIN_STUDIO_DATA; }); - test('placeholder with the sentinel scores 0 voice points (even >50 lines, no [Your Name])', () => { - root = makePluginRoot(); - writeVoice(root, `# Voice Profile (placeholder)\n${SENTINEL}\n\n${LONG_BODY}\n`); + test('external placeholder with the sentinel scores 0 voice points (even >50 lines, no [Your Name])', () => { + ({ dataDir, pluginRoot } = makeRoots()); + process.env.LINKEDIN_STUDIO_DATA = dataDir; + writeExternalVoice(dataDir, `# Voice Profile (placeholder)\n${SENTINEL}\n\n${LONG_BODY}\n`); - const { score, personalized } = calculateScore(root); + const { score, personalized } = calculateScore(pluginRoot); assert.equal(score, 0, 'placeholder must not earn the 25 voice points'); assert.equal(personalized, 0); }); - test('a real profile (no sentinel, >50 lines) earns the 25 voice points', () => { - root = makePluginRoot(); - writeVoice(root, `# My Voice Profile\n\n${LONG_BODY}\n`); + test('a real EXTERNAL profile (no sentinel, >50 lines) earns the 25 voice points', () => { + ({ dataDir, pluginRoot } = makeRoots()); + process.env.LINKEDIN_STUDIO_DATA = dataDir; + writeExternalVoice(dataDir, `# My Voice Profile\n\n${LONG_BODY}\n`); - const { score, personalized } = calculateScore(root); + const { score, personalized } = calculateScore(pluginRoot); - assert.equal(score, 25, 'a populated profile must earn the 25 voice points'); + assert.equal(score, 25, 'a populated external profile must earn the 25 voice points'); assert.equal(personalized, 1); }); - test('a short placeholder (<50 lines) also scores 0 voice points', () => { - root = makePluginRoot(); - writeVoice(root, `# Voice Profile (placeholder)\n${SENTINEL}\n`); + test('profile absent at external profile/user-profile.md → 0, no crash (graceful degradation)', () => { + ({ dataDir, pluginRoot } = makeRoots()); + process.env.LINKEDIN_STUDIO_DATA = dataDir; + // nothing seeded — no voice, no profile, no scaffolds anywhere - const { score } = calculateScore(root); + const { score, personalized } = calculateScore(pluginRoot); + + assert.equal(score, 0); + assert.equal(personalized, 0); + }); + + test('a short external placeholder (<50 lines) also scores 0 voice points', () => { + ({ dataDir, pluginRoot } = makeRoots()); + process.env.LINKEDIN_STUDIO_DATA = dataDir; + writeExternalVoice(dataDir, `# Voice Profile (placeholder)\n${SENTINEL}\n`); + + const { score } = calculateScore(pluginRoot); assert.equal(score, 0); }); diff --git a/hooks/scripts/personalization-score.mjs b/hooks/scripts/personalization-score.mjs index 393b28a..f1a5dbb 100644 --- a/hooks/scripts/personalization-score.mjs +++ b/hooks/scripts/personalization-score.mjs @@ -1,26 +1,46 @@ #!/usr/bin/env node // Personalization score calculator for linkedin-studio plugin -// Checks 8 asset categories for real user data vs placeholder templates +// Checks 8 asset categories for real user data vs placeholder templates. +// Instance data is read from the EXTERNAL data root (getDataRoot, M0); the plugin +// tree is a back-compat fallback during the migration window (external wins). // Standalone: outputs SCORE:N|M/8 assets personalized // Import: export function calculateScore(pluginRoot) => { score, personalized, categories } import { readFileSync, existsSync, readdirSync } from 'node:fs'; -import { join, basename, dirname } from 'node:path'; +import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { getDataRoot } from './data-root.mjs'; +import { MOVE_FILES, COPY_FILES } from './migrate-data.mjs'; const __dirname = dirname(fileURLToPath(import.meta.url)); +// Mirror the migration's external dests (single source of truth) so the scorer +// reads exactly where migrate-data.mjs writes — never a drifted path +// (migrate-data.mjs:13-16, STATE.md M0). Keyed by in-plugin source path. +const DEST = Object.fromEntries([...MOVE_FILES, ...COPY_FILES].map(([src, dest]) => [src, dest])); + export function calculateScore(pluginRoot) { + const dataRoot = getDataRoot(); let score = 0; let personalized = 0; const categories = 8; + // Instance lives in the external root; fall back to the plugin tree for + // back-compat during the migration window. External wins when present. + const pick = (dataRel, pluginRel) => { + const dataPath = join(dataRoot, dataRel); + return existsSync(dataPath) ? dataPath : join(pluginRoot, pluginRel); + }; + // --- 1. Voice samples (25 points) --- // The shipped file is a PII-free placeholder carrying the VOICE_PLACEHOLDER - // sentinel. Key detection on the sentinel (deterministic) rather than the old - // `[Your Name]` heuristic: a populated profile removes the sentinel and earns - // the points; the placeholder (or any file still carrying it) scores 0. - const voiceFile = join(pluginRoot, 'assets', 'voice-samples', 'authentic-voice-samples.md'); + // sentinel. Key detection on the sentinel (deterministic): a populated profile + // removes it and earns the points; the placeholder (or any file still carrying + // it) scores 0. + const voiceFile = pick( + DEST['assets/voice-samples/authentic-voice-samples.local.md'], + join('assets', 'voice-samples', 'authentic-voice-samples.md'), + ); if (existsSync(voiceFile)) { const content = readFileSync(voiceFile, 'utf-8'); const lineCount = content.split('\n').length; @@ -31,7 +51,10 @@ export function calculateScore(pluginRoot) { } // --- 2. User profile (20 points) --- - const profileFile = join(pluginRoot, 'config', 'user-profile.local.md'); + const profileFile = pick( + DEST['config/user-profile.local.md'], + join('config', 'user-profile.local.md'), + ); if (existsSync(profileFile)) { const content = readFileSync(profileFile, 'utf-8'); const placeholderCount = (content.match(/\[Your /g) || []).length; @@ -41,8 +64,9 @@ export function calculateScore(pluginRoot) { } } - // --- 3. Case studies (15 points) --- - const caseDir = join(pluginRoot, 'assets', 'case-studies'); + // --- 3. Case studies (15 points) --- dir; not in the move tables (migrated + // per-file) → brief-7.1 convention: external dest = in-plugin path minus assets/. + const caseDir = pick('case-studies', join('assets', 'case-studies')); if (existsSync(caseDir)) { let realCases = 0; try { @@ -57,7 +81,7 @@ export function calculateScore(pluginRoot) { } // --- 4. Frameworks (10 points) --- - const fwDir = join(pluginRoot, 'assets', 'frameworks'); + const fwDir = pick('frameworks', join('assets', 'frameworks')); if (existsSync(fwDir)) { let realFw = 0; try { @@ -72,7 +96,10 @@ export function calculateScore(pluginRoot) { } // --- 5. High-engagement posts (10 points) --- - const postsFile = join(pluginRoot, 'assets', 'examples', 'high-engagement-posts.md'); + const postsFile = pick( + DEST['assets/examples/high-engagement-posts.md'], + join('assets', 'examples', 'high-engagement-posts.md'), + ); if (existsSync(postsFile)) { const content = readFileSync(postsFile, 'utf-8'); const postCount = (content.match(/^## Post [0-9]/gm) || []).length; @@ -81,7 +108,10 @@ export function calculateScore(pluginRoot) { } // --- 6. Demographics (8 points) --- - const demoFile = join(pluginRoot, 'assets', 'audience-insights', 'demographics.md'); + const demoFile = pick( + DEST['assets/audience-insights/demographics.md'], + join('assets', 'audience-insights', 'demographics.md'), + ); if (existsSync(demoFile)) { const content = readFileSync(demoFile, 'utf-8'); const placeholderCount = (content.match(/\[Industry name\]|\[Function\]|\[Country\]|\[X\]%/g) || []).length; @@ -92,7 +122,10 @@ export function calculateScore(pluginRoot) { } // --- 7. Engagement patterns (7 points) --- - const patternsFile = join(pluginRoot, 'assets', 'audience-insights', 'engagement-patterns.md'); + const patternsFile = pick( + DEST['assets/audience-insights/engagement-patterns.md'], + join('assets', 'audience-insights', 'engagement-patterns.md'), + ); if (existsSync(patternsFile)) { const content = readFileSync(patternsFile, 'utf-8'); const placeholderCount = (content.match(/\[Day\]|\[Time\]|\[Topic\]|\[Format\]|\[Hook type\]/g) || []).length; @@ -103,7 +136,10 @@ export function calculateScore(pluginRoot) { } // --- 8. Post templates (5 points) --- - const templatesFile = join(pluginRoot, 'assets', 'templates', 'my-post-templates.md'); + const templatesFile = pick( + DEST['assets/templates/my-post-templates.md'], + join('assets', 'templates', 'my-post-templates.md'), + ); if (existsSync(templatesFile)) { const content = readFileSync(templatesFile, 'utf-8'); const unfilled = (content.match(/\[Name - e\.g\./g) || []).length;