#!/usr/bin/env bun import { existsSync, readdirSync, readFileSync, writeFileSync } from 'fs' import { join } from 'path' const root = process.cwd() const outRoot = join(root, 'content_population_exports') const contentRoot = join(root, 'data', 'content') const productRoot = join(contentRoot, 'products') const workflowRoot = join(contentRoot, 'workflow') const intelRoot = join(root, 'data', 'intelligence') function readJson(path: string, fallback: any = null) { if (!existsSync(path)) return fallback return JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, '')) } function writeJson(path: string, data: any) { writeFileSync(path, JSON.stringify(data, null, 2) + '\n', 'utf8') } function csv(rows: any[], headers: string[]) { const esc = (value: any) => { if (Array.isArray(value)) value = value.join(' | ') if (value === undefined || value === null) value = '' value = String(value) return /[",\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value } return [headers.join(','), ...rows.map(row => headers.map(header => esc(row[header])).join(','))].join('\n') + '\n' } function files(dir: string, suffix = '.json') { if (!existsSync(dir)) return [] return readdirSync(dir).filter(file => file.endsWith(suffix)) } function gate(name: string, ready: boolean, evidence: any, blocker: string) { return { name, status: ready ? 'pass' : 'blocked', ready, evidence, blocker: ready ? '' : blocker } } const products = files(productRoot).map(file => readJson(join(productRoot, file))).filter(Boolean) const workflowFiles = files(workflowRoot).filter(file => file !== 'index.json') const asset = readJson(join(outRoot, 'asset_manifest_validation.json'), {}) const handoff = readJson(join(outRoot, 'handoff_manifest.json'), {}) const template = readJson(join(outRoot, 'template_schema_validation.json'), {}) const sourceDrops = readJson(join(outRoot, 'source_drop_validation.json'), {}) const claims = readJson(join(outRoot, 'claims_library_validation.json'), {}) const sourceRequest = readJson(join(outRoot, 'source_request_manifest.json'), {}) const plannedImages = readJson(join(outRoot, 'planned_image_filenames.json'), {}) const meta = readJson(join(contentRoot, 'metaobjects.json'), { definitions: {} }) const collections = readJson(join(contentRoot, 'smart-collections.json'), { collections: [] }) const requiredIntel = ['review-aspects', 'strengths-weaknesses', 'conversion-blockers', 'improvements', 'image-audit', 'photo-brief', 'competitor-comparison', 'amazon-reviews', 'reddit-pulse', 'flavor-intelligence', 'audience-profile', 'claims-evidence'] const intelCoverage = requiredIntel.map(view => { const count = products.filter(product => existsSync(join(intelRoot, product.sku, `${view}.json`))).length return { view, count, missing: products.length - count } }) const approvalCounts = products.reduce((acc: any, product: any) => { for (const lane of ['content', 'compliance', 'images', 'export']) { const value = product.approval?.[lane] || 'missing' acc[lane] = acc[lane] || {} acc[lane][value] = (acc[lane][value] || 0) + 1 } return acc }, {}) const promoCards = meta.definitions?.promo_card || [] const mappedCollections = (collections.collections || []).filter((row: any) => row.promoCard).length const gates = [ gate('catalogue_content_seeded', products.length === 190, { products: products.length }, 'Expected 190 product content records.'), gate('workflow_seeded', workflowFiles.length === products.length, { workflowFiles: workflowFiles.length, products: products.length }, 'Every product needs a workflow record.'), gate('template_schema', Boolean(template.ready), { checks: template.checks?.length || 0 }, 'Dashboard/migration CSV headers must match Lewis/Euan XLSX template.'), gate('handoff_ready', Boolean(handoff.readyForLewis), { blockers: handoff.counts?.blockers, blockedSkus: handoff.counts?.blockedSkus }, 'Handoff manifest still has blockers.'), gate('asset_filenames_assigned', (asset.missingReferenceCount || 0) === 0 && plannedImages.totalRows === products.length * 3, { missingReferenceCount: asset.missingReferenceCount, plannedImageRows: plannedImages.totalRows }, 'All product image filenames must be assigned.'), gate('asset_uploads_ready', Boolean(asset.ready), { requiredCount: asset.requiredCount, missingUploadCount: asset.missingUploadCount }, 'Referenced product/metaobject assets must exist in the asset root / Shopify Files.'), gate('source_drops_ready', Boolean(sourceDrops.ready), { issues: sourceDrops.issues?.length || 0 }, 'Amazon/Trustpilot/Reddit source drops are missing or invalid.'), gate('claims_library_ready', Boolean(claims.ready), { claimCount: claims.claimCount, issues: claims.issueCount }, 'Clinical claims library is missing or invalid.'), gate('source_request_pack_ready', Boolean(sourceRequest.counts?.sourceRequestItems), sourceRequest.counts || {}, 'Source request pack must exist for external blockers.'), gate('promo_cards_seeded', promoCards.length > 0 && mappedCollections === (collections.collections || []).length, { promoCards: promoCards.length, mappedCollections, collections: (collections.collections || []).length }, 'Collection promo-card candidates/mappings missing.'), gate('full_intelligence_coverage', intelCoverage.every(row => row.count === products.length), { coverage: intelCoverage }, 'All intelligence files must exist for every catalogue SKU.'), gate('human_approval_complete', products.every(product => product.approval?.content === 'approved' && product.approval?.compliance === 'approved' && product.approval?.images === 'ready' && product.approval?.export === 'ready'), approvalCounts, 'Approval lanes are not complete.'), ] const ready = gates.every(item => item.ready) const report = { generatedAt: new Date().toISOString(), ready, objective: 'jv-dashboard/CONTENT_POPULATION_PLAN.md', summary: { totalGates: gates.length, passing: gates.filter(item => item.ready).length, blocked: gates.filter(item => !item.ready).length, productCount: products.length, workflowCount: workflowFiles.length, exportBlockers: handoff.counts?.blockers ?? null, missingAssetUploads: asset.missingUploadCount ?? null, sourceDropIssues: sourceDrops.issues?.length ?? null, claimsIssues: claims.issueCount ?? null, }, gates, nextActions: gates.filter(item => !item.ready).map(item => item.blocker) } writeJson(join(outRoot, 'content_population_readiness.json'), report) writeFileSync(join(outRoot, 'content_population_readiness.csv'), csv(gates.map(item => ({ Gate: item.name, Status: item.status, Blocker: item.blocker, Evidence: JSON.stringify(item.evidence) })), ['Gate', 'Status', 'Blocker', 'Evidence']), 'utf8') const md = [ '# JV content population readiness audit', '', `Generated: ${report.generatedAt}`, '', `Overall ready: **${ready ? 'YES' : 'NO'}**`, '', '## Summary', `- Gates passing: ${report.summary.passing}/${report.summary.totalGates}`, `- Product records: ${report.summary.productCount}`, `- Workflow records: ${report.summary.workflowCount}`, `- Export blockers: ${report.summary.exportBlockers}`, `- Missing asset uploads/files: ${report.summary.missingAssetUploads}`, `- Source drop issues: ${report.summary.sourceDropIssues}`, `- Claims validation issues: ${report.summary.claimsIssues}`, '', '## Gates', ...gates.map(item => `- ${item.ready ? '[PASS]' : '[BLOCKED]'} ${item.name}${item.blocker ? ` — ${item.blocker}` : ''}`), '', '## Next actions', ...report.nextActions.map(action => `- ${action}`), '' ].join('\n') writeFileSync(join(outRoot, 'content_population_readiness.md'), md, 'utf8') console.log(`Content population ready: ${ready}`) console.log(`Gates passing: ${report.summary.passing}/${report.summary.totalGates}`) console.log(`Blocked gates: ${report.summary.blocked}`) if (!ready) process.exitCode = 1