From ff18ece14c0de3166c974a738add5faabda0598f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?je=CC=81ro=CC=82me=20boileux?= Date: Wed, 21 Oct 2026 12:56:17 +0200 Subject: [PATCH 1/4] feat(enjeu): add the enjeu schema, Stage ids and validation Validate enjeux/fr and enjeux/en files against a strict Zod schema with length caps, array counts and the seven bow tie Stage ids. Cap errors state the length, the cap and the overage. A non-empty body fails. Six golden fixtures, built from the enjeux-*.html mockups, pass. Two caps differ from spec #91, both following the spec's own rule: case.headline is 120 (Revenue AI headline is 98 characters) and method.tools allows 9 (Sales Experience shows 9 tools). Refs #94 --- scripts/__tests__/enjeu.schema.test.js | 282 ++++++++++++++++++ scripts/__tests__/fixtures/enjeux/clients.md | 103 +++++++ scripts/__tests__/fixtures/enjeux/ia.md | 116 +++++++ .../enjeux/organisation-commerciale.md | 107 +++++++ scripts/__tests__/fixtures/enjeux/outils.md | 108 +++++++ .../fixtures/enjeux/previsibilite.md | 92 ++++++ .../fixtures/enjeux/remuneration-variable.md | 99 ++++++ scripts/__tests__/validate-repository.test.js | 40 ++- scripts/schemas/enjeu.schema.js | 120 ++++++++ scripts/schemas/stage-ids.js | 13 + scripts/validate-repository.js | 14 +- 11 files changed, 1091 insertions(+), 3 deletions(-) create mode 100644 scripts/__tests__/enjeu.schema.test.js create mode 100644 scripts/__tests__/fixtures/enjeux/clients.md create mode 100644 scripts/__tests__/fixtures/enjeux/ia.md create mode 100644 scripts/__tests__/fixtures/enjeux/organisation-commerciale.md create mode 100644 scripts/__tests__/fixtures/enjeux/outils.md create mode 100644 scripts/__tests__/fixtures/enjeux/previsibilite.md create mode 100644 scripts/__tests__/fixtures/enjeux/remuneration-variable.md create mode 100644 scripts/schemas/enjeu.schema.js create mode 100644 scripts/schemas/stage-ids.js diff --git a/scripts/__tests__/enjeu.schema.test.js b/scripts/__tests__/enjeu.schema.test.js new file mode 100644 index 0000000..ce213e7 --- /dev/null +++ b/scripts/__tests__/enjeu.schema.test.js @@ -0,0 +1,282 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import matter from 'gray-matter'; +import { describe, expect, it } from 'vitest'; +import { validateEnjeu } from '../schemas/enjeu.schema.js'; +import { STAGE_IDS } from '../schemas/stage-ids.js'; + +// One golden fixture per enjeu, built from the text of the six enjeux-*.html +// mockups in ocobo-revops/prototype, without the partner block and the agent +// window. +const FIXTURES_DIR = join(dirname(fileURLToPath(import.meta.url)), 'fixtures/enjeux'); +const loadFixture = (file) => matter(readFileSync(join(FIXTURES_DIR, file), 'utf8')).data; + +const base = loadFixture('previsibilite.md'); + +const withValue = (path, value) => { + const data = structuredClone(base); + const parent = path.slice(0, -1).reduce((node, key) => node[key], data); + parent[path.at(-1)] = value; + return data; +}; + +const without = (path) => { + const data = structuredClone(base); + const parent = path.slice(0, -1).reduce((node, key) => node[key], data); + delete parent[path.at(-1)]; + return data; +}; + +const issuesOf = (data) => { + const result = validateEnjeu(data); + return result.success ? [] : result.error.issues.map((i) => ({ path: i.path, message: i.message })); +}; + +// ── golden fixtures ─────────────────────────────────────────────────────────── + +describe('enjeu golden fixtures', () => { + const files = readdirSync(FIXTURES_DIR).filter((f) => f.endsWith('.md')); + + it('has one fixture per enjeu key', () => { + expect(files.map((f) => loadFixture(f).key).sort()).toEqual(['ai', 'cx', 'rp', 'sc', 'se', 'sy']); + }); + + it.each(files)('%s passes', (file) => { + expect(issuesOf(loadFixture(file))).toEqual([]); + }); + + it('Revenue AI overrides the method and workstreams headings', () => { + const ai = loadFixture('ia.md'); + expect(ai.method.title).toBeDefined(); + expect(ai.method.steps.every((s) => s.title)).toBe(true); + expect(ai.workstreams.title).toBeDefined(); + }); +}); + +// ── caps ────────────────────────────────────────────────────────────────────── + +const CAPS = [ + [['name'], 30], + [['tagline'], 60], + [['hero', 'title', 0], 34], + [['hero', 'title', 1], 42], + [['hero', 'quote'], 80], + [['hero', 'lead'], 240], + [['hero', 'highlight'], 110], + [['hero', 'cta'], 48], + [['bowtie', 'caption'], 140], + [['symptoms', 'title', 0], 64], + [['symptoms', 'title', 1], 44], + [['symptoms', 'items', 0], 130], + [['audience', 0, 'team'], 20], + [['audience', 0, 'roles'], 30], + [['audience', 0, 'benefit'], 100], + [['workstreams', 'title'], 50], + [['workstreams', 'intro'], 120], + [['workstreams', 'items', 0, 'category'], 26], + [['workstreams', 'items', 0, 'title'], 64], + [['workstreams', 'items', 0, 'description'], 210], + [['workstreams', 'items', 0, 'deliverable'], 170], + [['workstreams', 'items', 0, 'note'], 100], + [['aiBridge', 'title'], 92], + [['aiBridge', 'bullets', 0], 125], + [['method', 'title'], 30], + [['method', 'subtitle'], 75], + [['method', 'steps', 0, 'title'], 28], + [['method', 'steps', 0, 'text'], 150], + [['method', 'note'], 170], + [['case', 'headline'], 120], + [['case', 'summary'], 220], + [['case', 'metrics', 0, 'value'], 12], + [['case', 'metrics', 0, 'label'], 90], + [['diagnostic', 'question'], 88], + [['diagnostic', 'subtitle'], 125], +]; + +describe('enjeu caps', () => { + it.each(CAPS)('%j accepts %i characters', (path, cap) => { + expect(issuesOf(withValue(path, 'a'.repeat(cap)))).toEqual([]); + }); + + it.each(CAPS)('%j rejects one character over cap %i, stating length, cap and overage', (path, cap) => { + expect(issuesOf(withValue(path, 'a'.repeat(cap + 1)))).toEqual([ + { path, message: `${cap + 1} characters, cap ${cap} (1 over)` }, + ]); + }); + + it('measures the overage of a long text', () => { + expect(issuesOf(withValue(['hero', 'lead'], 'a'.repeat(263)))).toEqual([ + { path: ['hero', 'lead'], message: '263 characters, cap 240 (23 over)' }, + ]); + }); + + it('measures the length after trimming', () => { + expect(issuesOf(withValue(['name'], ` ${'a'.repeat(30)} `))).toEqual([]); + }); +}); + +// ── strings ─────────────────────────────────────────────────────────────────── + +describe('enjeu strings', () => { + it('trims every string', () => { + const result = validateEnjeu(withValue(['hero', 'quote'], ' Une citation. ')); + expect(result.data.hero.quote).toBe('Une citation.'); + }); + + it.each([ + [['name']], + [['symptoms', 'items', 2]], + [['audience', 3, 'benefit']], + [['method', 'tools', 0]], + ])('rejects a blank string at %j', (path) => { + expect(issuesOf(withValue(path, ' '))).toEqual([{ path, message: expect.any(String) }]); + }); +}); + +// ── array counts ────────────────────────────────────────────────────────────── + +const COUNTS = [ + [['hero', 'title'], 2, 2], + [['symptoms', 'title'], 2, 2], + [['symptoms', 'items'], 5, 6], + [['audience'], 4, 4], + [['workstreams', 'items'], 4, 6], + [['aiBridge', 'bullets'], 3, 3], + [['method', 'steps'], 3, 3], + [['method', 'tools'], 0, 9], + [['case', 'metrics'], 2, 2], +]; + +const resize = (path, count) => { + const items = path.reduce((node, key) => node[key], base); + return Array.from({ length: count }, (_, i) => structuredClone(items[i % items.length])); +}; + +describe('enjeu array counts', () => { + it.each(COUNTS)('%j accepts %i to %i items', (path, min, max) => { + expect(issuesOf(withValue(path, resize(path, min)))).toEqual([]); + expect(issuesOf(withValue(path, resize(path, max)))).toEqual([]); + }); + + it.each(COUNTS)('%j rejects fewer than %i or more than %i items', (path, min, max) => { + if (min > 0) { + expect(issuesOf(withValue(path, resize(path, min - 1)))).not.toEqual([]); + } + expect(issuesOf(withValue(path, resize(path, max + 1)))).not.toEqual([]); + }); +}); + +// ── Stage ids ───────────────────────────────────────────────────────────────── + +describe('enjeu Stage ids', () => { + it('lists the seven Stages in bow tie order', () => { + expect(STAGE_IDS).toEqual([ + 'demand-generation', + 'discovery', + 'negotiation', + 'closing', + 'onboarding', + 'adoption', + 'expansion', + ]); + expect(Object.isFrozen(STAGE_IDS)).toBe(true); + }); + + it('accepts every Stage id', () => { + expect(issuesOf(withValue(['bowtie', 'activeStages'], [...STAGE_IDS]))).toEqual([]); + }); + + it('accepts a single Stage', () => { + expect(issuesOf(withValue(['bowtie', 'activeStages'], ['closing']))).toEqual([]); + }); + + it('rejects no Stage', () => { + expect(issuesOf(withValue(['bowtie', 'activeStages'], []))).not.toEqual([]); + }); + + it('rejects an unknown Stage id', () => { + expect(issuesOf(withValue(['bowtie', 'activeStages'], ['discovery', 'qualification']))).toEqual([ + { path: ['bowtie', 'activeStages', 1], message: expect.any(String) }, + ]); + }); + + it('rejects a duplicate Stage id', () => { + expect(issuesOf(withValue(['bowtie', 'activeStages'], ['discovery', 'closing', 'discovery']))).toEqual([ + { path: ['bowtie', 'activeStages'], message: 'Stage ids must be unique' }, + ]); + }); +}); + +// ── key, order, step ────────────────────────────────────────────────────────── + +describe('enjeu key, order and step', () => { + it.each(['rp', 'se', 'sc', 'cx', 'sy', 'ai'])('accepts key %s', (key) => { + expect(issuesOf(withValue(['key'], key))).toEqual([]); + }); + + it('rejects an unknown key', () => { + expect(issuesOf(withValue(['key'], 'ops'))).not.toEqual([]); + }); + + it.each([1, 6])('accepts order %i', (order) => { + expect(issuesOf(withValue(['order'], order))).toEqual([]); + }); + + it.each([0, 7, 2.5, '3'])('rejects order %j', (order) => { + expect(issuesOf(withValue(['order'], order))).not.toEqual([]); + }); + + it.each([1, 6])('accepts workstream step %i', (step) => { + expect(issuesOf(withValue(['workstreams', 'items', 0, 'step'], step))).toEqual([]); + }); + + it.each([0, 7, 1.5])('rejects workstream step %j', (step) => { + expect(issuesOf(withValue(['workstreams', 'items', 0, 'step'], step))).not.toEqual([]); + }); +}); + +// ── optional and required fields ────────────────────────────────────────────── + +describe('enjeu optional fields', () => { + it('accepts a case without a Story', () => { + expect(base.case.story).toBeUndefined(); + expect(issuesOf(base)).toEqual([]); + }); + + it('accepts a case citing a Story', () => { + expect(issuesOf(withValue(['case', 'story'], 'qonto'))).toEqual([]); + }); + + it.each([ + [['hero', 'lead']], + [['bowtie', 'activeStages']], + [['workstreams', 'intro']], + [['method', 'tools']], + [['diagnostic']], + ])('rejects a missing %j', (path) => { + expect(issuesOf(without(path))).not.toEqual([]); + }); +}); + +// ── unknown keys ────────────────────────────────────────────────────────────── + +describe('enjeu unknown keys', () => { + it('rejects extraSections', () => { + expect(issuesOf({ ...base, extraSections: [{ type: 'stack' }] })).toEqual([ + { path: [], message: 'Unrecognized key: "extraSections"' }, + ]); + }); + + it.each([ + [['hero', 'subtitle']], + [['audience', 0, 'icon']], + [['workstreams', 'items', 0, 'image']], + [['method', 'steps', 1, 'icon']], + [['case', 'metrics', 0, 'trend']], + ])('rejects the unknown key %j', (path) => { + expect(issuesOf(withValue(path, 'x'))).toEqual([ + { path: path.slice(0, -1), message: `Unrecognized key: "${path.at(-1)}"` }, + ]); + }); +}); diff --git a/scripts/__tests__/fixtures/enjeux/clients.md b/scripts/__tests__/fixtures/enjeux/clients.md new file mode 100644 index 0000000..9c56f32 --- /dev/null +++ b/scripts/__tests__/fixtures/enjeux/clients.md @@ -0,0 +1,103 @@ +--- +key: cx +order: 2 +name: Customer Experience +tagline: Des clients qui restent et achètent plus +hero: + title: + - Des clients qui restent + - et achètent plus. + quote: On signe, puis on perd le fil. + lead: "Une grande part de votre chiffre de demain est déjà chez vos clients d'aujourd'hui. Nous construisons l'organisation, les processus, les outils et les indicateurs qui la sécurisent," + highlight: de la signature au renouvellement. + cta: Sécuriser mon revenu récurrent +bowtie: + activeStages: + - onboarding + - adoption + - expansion + caption: "Après la signature : démarrage, valeur délivrée, renouvellement et expansion." +symptoms: + title: + - Vous découvrez vos départs de clients + - quand ils sont déjà partis. + items: + - La passation entre la vente et la production se fait par mail, et chacun repose les mêmes questions au client. + - Les renouvellements se découvrent quelques semaines avant l'échéance. + - Personne ne sait quels clients sont à risque avant qu'ils ne le disent. + - Les opportunités de développement dépendent du réflexe de chaque chargé de compte. + - La vente, le support et la gestion de comptes ne partagent pas la même vue du client. +audience: + - team: Customer Success + roles: Head of CS, CCO + benefit: Une vision claire de la santé du parc et des renouvellements à venir. + - team: Customer Success + roles: CSM, Account Managers + benefit: Moins de pompiers, plus de rendez-vous qui préparent l'expansion. + - team: Direction + roles: CEO, DG + benefit: Un NRR qui progresse et une croissance qui ne repose plus que sur l'acquisition. + - team: Ventes + roles: Équipes commerciales + benefit: Une passation nette après la signature, sans perte d'information. +workstreams: + intro: Six chantiers, de la signature au développement du compte. + items: + - category: Organisation + title: Qui s'occupe de quel client, et comment + description: Segmentation du portefeuille, rôles entre gestion de comptes, succès client et support, niveaux de service par segment. + deliverable: Modèle d'organisation client et règles de couverture. + - category: Passation + step: 1 + title: Une passation sans perte d'information + description: "Ce que la vente a promis arrive à la production : fiche de passation dans le CRM, démarrage standardisé, jalons de mise en route." + deliverable: Processus de passation et de démarrage outillé. + - category: KPIs et pilotage + step: 3 + title: La santé de chaque client, lisible + description: Score de santé, signaux de risque, rétention et développement du revenu par segment, revues de portefeuille. + deliverable: Tableaux de bord client et rituels de revue. + - category: Renouvellement + step: 4 + title: Des renouvellements anticipés, un développement piloté + description: Échéances visibles des mois à l'avance, pipe de renouvellement et d'expansion, plans de compte pour les clients clés. + deliverable: Un pipe de renouvellement et d'expansion dans votre CRM. + - category: Processus et techno + title: Une seule vue du client, partagée + description: "CRM, support et facturation reliés : chaque équipe voit l'historique, les tickets, les contrats et les opportunités." + deliverable: Une fiche client unifiée et ses automatisations. + - category: Enablement + title: Des équipes client outillées + description: Playbooks de suivi, de renouvellement et de développement, intégrés aux outils des équipes. + deliverable: Playbooks client et parcours d'intégration des chargés de compte. +aiBridge: + title: Les signaux faibles remontent avant que le client ne parte. + bullets: + - Les tickets, mails et appels sont lus pour détecter un risque ou une opportunité. + - Chaque chargé de compte reçoit un résumé du compte avant son rendez-vous. + - Les revues de compte se préparent en quelques minutes. +method: + subtitle: De l'analyse du portefeuille à un revenu récurrent piloté. + steps: + - text: Le Design Sprint analyse votre portefeuille, vos départs et vos renouvellements, et identifie où le revenu se perd. + - text: La squad construit l'organisation, les processus et les indicateurs avec vos équipes vente, production et support. + - text: Vos équipes pilotent le portefeuille sur les indicateurs, et les renouvellements cessent d'être des surprises. + tools: + - hubspot + - salesforce + - planhat + - zendesk + - vitally + note: "Et ceux que vous avez déjà : nous partons de votre stack." +case: + headline: "[Nom du client] : [le passage en une phrase]." + summary: "[Contexte en deux lignes. Ce qu'on a construit. Ce qui a changé pour la direction.]" + metrics: + - value: "[X]" + label: "[Taux de renouvellement, avant et après]" + - value: "[X]" + label: "[Part du chiffre issue du développement des clients existants]" +diagnostic: + question: Quelle part de votre chiffre de l'an prochain est déjà chez vos clients ? + subtitle: Trente minutes avec un architecte du revenu pour le mesurer, et savoir par où commencer. +--- diff --git a/scripts/__tests__/fixtures/enjeux/ia.md b/scripts/__tests__/fixtures/enjeux/ia.md new file mode 100644 index 0000000..0b07af6 --- /dev/null +++ b/scripts/__tests__/fixtures/enjeux/ia.md @@ -0,0 +1,116 @@ +--- +key: ai +order: 6 +name: Revenue AI +tagline: Une IA qui travaille pour vos équipes Revenue +hero: + title: + - Vos équipes testent l'IA. + - Nous la faisons passer à l'échelle. + quote: Tout le monde utilise ChatGPT, personne ne vend mieux. + lead: "Un agent IA ne sait pas ce qu'est une opportunité qualifiée. Quelqu'un doit le lui avoir appris. Nous choisissons les usages qui comptent pour vos ventes, nous remettons d'aplomb" + highlight: les données et les processus sur lesquels ils tournent, et nous mesurons ce qu'ils rapportent. + cta: Lancer un Revenue AI Sprint +bowtie: + activeStages: + - demand-generation + - discovery + - negotiation + - closing + - onboarding + - adoption + - expansion + caption: À chaque étape, l'IA capte l'information et la restitue au bon moment. Pourvu que l'étape soit bien définie. +symptoms: + title: + - Beaucoup d'essais. + - Peu d'effet sur les ventes. + items: + - Chaque commercial a ses propres outils IA, et personne ne sait lesquels. + - Vous avez payé des licences que la moitié de l'équipe n'ouvre plus. + - Le pilote IA a plu en démonstration, puis s'est arrêté. + - Vos données clients partent dans des outils que personne n'a validés. + - Personne ne peut dire combien l'IA a rapporté ce trimestre. +audience: + - team: Direction + roles: CEO, COMEX + benefit: Une feuille de route IA qui produit des résultats mesurables, pas des POC. + - team: Ventes + roles: CRO, Directeur commercial + benefit: Des équipes qui préparent, relancent et vendent plus vite. + - team: Ops + roles: RevOps, Ops + benefit: Des agents branchés sur une donnée fiable, gouvernés et maintenus. + - team: Terrain + roles: Commerciaux et CSM + benefit: Moins de tâches administratives, plus de temps avec les clients. +workstreams: + title: Six cas d'usage Revenue, prêts à déployer + intro: Chacun se livre en un Revenue AI Sprint, avec ses prérequis RevOps vérifiés d'abord. + items: + - category: Cibler + title: Les bons comptes, enrichis et priorisés + description: Identification des comptes qui correspondent à votre cible, enrichissement automatique, puis priorisation selon les signaux d'achat. Vos commerciaux savent qui appeler, et pourquoi. + deliverable: "Un agent de scoring branché sur le CRM : chaque matin, la liste des comptes à appeler, enrichis et classés, avec le signal qui justifie l'appel." + note: "Prérequis : un ICP explicite et un CRM qui sert de référence." + - category: Capter + title: Les comptes rendus d'appel remplissent le CRM + description: Qualification, objections et prochaines étapes rattachées au bon deal, sans saisie. + deliverable: "Un agent post-appel connecté à votre téléphonie : résumé, champs de qualification et prochaine étape écrits dans le deal, à valider en un clic." + note: "Prérequis : des étapes de vente et des champs de qualification définis." + - category: Restituer + title: La préparation de rendez-vous en une minute + description: Historique du compte, derniers échanges, enjeux probables, questions à poser. + deliverable: "Un brief automatique envoyé avant chaque rendez-vous dans Slack ou par mail : contexte du compte, interlocuteurs, enjeux et questions à poser." + note: "Prérequis : une fiche compte et un historique d'activité fiables." + - category: Piloter + title: La revue de pipe assistée + description: Les deals à risque signalés avant la réunion, avec la raison du risque. + deliverable: "Une alerte hebdomadaire dans le canal de l'équipe : les deals à risque, la raison du risque et une relance préparée pour chacun." + note: "Prérequis : des critères de sortie d'étape et des dates de closing tenues." + - category: Piloter + title: Le prévisionnel en langage naturel + description: Le dirigeant pose sa question en français et obtient le chiffre, avec son détail. + deliverable: "Un assistant de prévision interrogeable en français, relié au CRM : le chiffre, son détail par deal et l'écart avec la semaine précédente." + note: "Prérequis : une méthode de prévision et des catégories partagées." + - category: Faire progresser + title: Le playbook dans l'outil du commercial + description: La bonne ressource au bon moment du cycle, sans chercher dans un drive. + deliverable: "Un assistant de vente branché sur votre playbook : la bonne ressource, l'objection type ou le script poussés au commercial selon l'étape du deal." + note: "Prérequis : un playbook à jour, rangé dans Notion ou votre base de connaissance." +aiBridge: + title: L'IA accélère un système qui fonctionne. Ailleurs, elle amplifie le désordre. + bullets: + - Un assistant branché sur un CRM mal rempli produit des réponses fausses avec assurance. + - "C'est pourquoi chaque sprint commence par vérifier les fondations : données, processus de vente, outils." + - Quand elles tiennent, on déploie. Quand elles manquent, on vous le dit, et on commence par là. +method: + title: Le Revenue AI Sprint + subtitle: Trois semaines pour passer du test à la décision. + steps: + - title: Vérifier les fondations + text: "Données, processus de vente, outils : ce qui est prêt pour l'IA et ce qui ne l'est pas." + - title: Prouver sur un cas réel + text: Un premier usage branché sur vos outils, utilisé par une vraie équipe, mesuré. + - title: Décider la suite + text: Une feuille de route chiffrée en impact. Si les fondations manquent, on commence par elles. + tools: + - hubspot + - salesforce + - claude + - notion + - modjo + - claap + note: Les données dans votre CRM, l'intelligence avec Claude, le savoir dans Notion. Déployable aussi sur la plateforme d'agents que vous avez déjà. +case: + headline: Ocobo fonctionne avec l'IA au quotidien, de la préparation des rendez-vous au coaching commercial. + summary: "[Deux lignes sur l'usage interne, puis un premier cas client issu d'une mission.]" + metrics: + - value: "[X]" + label: "[Temps gagné par commercial]" + - value: "[X]" + label: "[Effet mesuré sur le pipe]" +diagnostic: + question: Votre organisation Revenue est-elle prête pour l'IA ? + subtitle: Trente minutes avec un architecte du revenu pour le savoir, et choisir le premier usage à déployer. +--- diff --git a/scripts/__tests__/fixtures/enjeux/organisation-commerciale.md b/scripts/__tests__/fixtures/enjeux/organisation-commerciale.md new file mode 100644 index 0000000..c5b8726 --- /dev/null +++ b/scripts/__tests__/fixtures/enjeux/organisation-commerciale.md @@ -0,0 +1,107 @@ +--- +key: se +order: 1 +name: Sales Experience +tagline: Une organisation commerciale qui passe à l'échelle +hero: + title: + - Une organisation commerciale + - qui passe à l'échelle. + quote: Deux commerciaux font l'essentiel du chiffre. + lead: "Quand la performance tient à quelques talents, ajouter des commerciaux ne suffit pas. Nous construisons l'organisation, les processus, les outils et les indicateurs qui font tourner" + highlight: tout le cycle, du premier lead à l'argent encaissé. + cta: Structurer mon organisation commerciale +bowtie: + activeStages: + - demand-generation + - discovery + - negotiation + - closing + - onboarding + caption: "Du ciblage jusqu'à l'encaissement : tout le cycle de vente, outillé de bout en bout." +symptoms: + title: + - Vous recrutez des commerciaux. + - Le chiffre ne suit pas. + items: + - Vos commerciaux trouvent que les leads marketing ne valent pas la peine d'être rappelés. + - Les leads arrivent sans règle claire sur qui les traite, ni en combien de temps. + - Vos commerciaux prospectent, closent et gèrent leurs comptes à la fois. + - Vous ne savez pas à quelle étape du cycle vous perdez vos deals. + - Les devis se font à la main, et la facturation ressaisit tout ce que la vente a signé. + - Un nouveau commercial met des mois à produire, parce que rien n'est outillé pour lui. +audience: + - team: Ventes + roles: Directeur commercial, CRO + benefit: Une organisation qui tient la croissance sans dépendre de deux commerciaux. + - team: Ventes + roles: Managers commerciaux + benefit: Des rôles clairs, des process partagés, des équipes qui montent en compétence. + - team: Marketing + roles: Head of Marketing + benefit: Des leads mieux ciblés, assignés au bon commercial, et suivis. + - team: Ops + roles: Sales Ops, RevOps + benefit: Un quote-to-cash fluide et des KPIs que tout le monde comprend. +workstreams: + intro: Six chantiers, priorisés selon votre point de départ. Chacun livre un système qui reste chez vous. + items: + - category: Leads + title: Des leads mieux ciblés, qualifiés et assignés + description: Cible définie avec la vente, scoring, enrichissement, règles de routage et délais de prise en charge, boucle de retour entre marketing et vente. + deliverable: Un circuit des leads paramétré, du formulaire au commercial. + - category: Organisation + title: Les bons rôles, au bon moment du cycle + description: Segmentation des comptes, spécialisation prospection, vente et gestion de comptes, passations entre rôles, dimensionnement des équipes. + deliverable: Modèle d'organisation commerciale et plan de capacité. + - category: Processus et techno + title: Un cycle de vente outillé de bout en bout + description: Étapes et critères de sortie, séquences, tâches et automatisations dans le CRM. + deliverable: Processus de vente paramétré dans vos outils. + - category: Quote-to-cash + title: Du devis signé à l'argent encaissé, sans ressaisie + description: Catalogue et règles de prix, devis générés depuis le CRM, validations, signature, passage automatique à la facturation et suivi des encaissements. + deliverable: Un flux continu du deal gagné à la facture, relié à votre finance. + - category: KPIs et pilotage + title: Des managers qui pilotent sur des faits + description: Conversion par étape, durée du cycle, productivité et montée en charge par commercial, rituels de revue d'activité. + deliverable: Tableaux de bord managers et rituels de pilotage. + - category: Enablement + title: La méthode intégrée aux outils + description: Playbook accessible dans le CRM, contenus de vente au bon moment du cycle, parcours d'intégration outillé. + deliverable: Playbook et parcours d'intégration, réutilisables à chaque recrutement. +aiBridge: + title: Ce qui se dit en rendez-vous devient une donnée de pilotage. + bullets: + - "Les appels alimentent le CRM : qualification, objections, prochaines étapes." + - Les écarts au processus remontent dans les tableaux de bord des managers. + - Le playbook arrive dans l'outil du commercial, au moment où il en a besoin. +method: + subtitle: De l'analyse du funnel à une organisation qui tient sans nous. + steps: + - text: Le Design Sprint analyse votre funnel de bout en bout, votre organisation et vos outils, et identifie où la performance se perd. + - text: La squad construit l'organisation, les processus et les indicateurs dans vos outils, avec votre direction commerciale. + - text: Vos managers pilotent sur les indicateurs, et chaque recrutement s'intègre dans un système déjà en place. + tools: + - hubspot + - salesforce + - lemlist + - chargebee + - full-enrich + - aircall + - modjo + - oneflow + - hyperline + note: "Du ciblage à l'encaissement. Et ceux que vous avez déjà : nous partons de votre stack." +case: + headline: "[Nom du client] : [le passage en une phrase]." + summary: "[Contexte en deux lignes. Ce qu'on a construit. Ce qui a changé pour la direction commerciale.]" + metrics: + - value: "[X]" + label: "[Conversion lead vers opportunité, avant et après]" + - value: "[X]" + label: "[Délai entre signature et facturation]" +diagnostic: + question: À quelle étape de votre cycle perdez-vous vos deals ? + subtitle: Trente minutes avec un architecte du revenu pour le mesurer, et savoir par où commencer. +--- diff --git a/scripts/__tests__/fixtures/enjeux/outils.md b/scripts/__tests__/fixtures/enjeux/outils.md new file mode 100644 index 0000000..91caa8b --- /dev/null +++ b/scripts/__tests__/fixtures/enjeux/outils.md @@ -0,0 +1,108 @@ +--- +key: sy +order: 5 +name: Revenue Systems +tagline: Des outils que vos équipes utilisent vraiment +hero: + title: + - Des outils que vos équipes + - utilisent vraiment. + quote: On paie un CRM que personne ne remplit. + lead: "Votre stack s'est empilée au fil des besoins, et personne n'en a la vue d'ensemble. Nous dessinons l'architecture d'abord, puis nous implémentons, connectons et faisons adopter" + highlight: des outils au service de votre organisation Revenue. + cta: Remettre ma stack au service de la vente +bowtie: + activeStages: + - demand-generation + - discovery + - negotiation + - closing + - onboarding + - adoption + - expansion + caption: "Le socle de tout le cycle : les outils et la donnée sur lesquels chaque étape repose." +symptoms: + title: + - Beaucoup d'outils. + - Aucun système. + items: + - Le CRM est rempli par obligation, et le vrai suivi vit dans des tableurs. + - Le même client existe en trois versions entre le CRM, la facturation et l'ERP. + - Chaque équipe a ajouté ses outils, sans plan d'ensemble. + - Vous repoussez une migration de CRM depuis des mois, par peur de tout casser. + - Personne n'administre vraiment les outils, et chaque évolution passe par un prestataire. +audience: + - team: Direction + roles: CRO, COO + benefit: Une stack qui sert la stratégie, et un budget outils qui se justifie. + - team: Ops + roles: RevOps, Sales Ops + benefit: Un modèle de données propre, une gouvernance, moins de bricolage. + - team: IT + roles: DSI + benefit: Des intégrations maîtrisées, sécurisées et documentées. + - team: Terrain + roles: Sales, CS, Marketing + benefit: Des outils qu'on a envie d'utiliser, et moins de saisie. +workstreams: + title: L'architecture d'abord, les outils ensuite + intro: "Notre règle : aucun outil avant d'avoir dessiné le système." + items: + - category: Architecture + step: 1 + title: Une stack pensée pour votre organisation + description: Audit de l'existant, architecture cible, choix des outils sans parti pris d'éditeur, feuille de route. + deliverable: Schéma d'architecture cible et plan de déploiement. + - category: Implémentation + step: 2 + title: Un CRM implémenté ou migré sans casse + description: Paramétrage HubSpot ou Salesforce, migration depuis l'ancien outil ou les tableurs, reprise et nettoyage des données. + deliverable: Un CRM en production, données reprises et vérifiées. + - category: Intégration et données + step: 3 + title: Une donnée de référence, partout + description: CRM relié à la facturation, à l'ERP, au marketing et au support. Règles de synchronisation, modèle de données unique. + deliverable: Intégrations en production et modèle de données documenté. + - category: Adoption + step: 4 + title: Des outils simples à utiliser + description: Vues par métier, champs utiles au bon moment, automatisation des saisies, formation des équipes. + deliverable: Un CRM utilisé, mesuré par des indicateurs d'adoption. + - category: Gouvernance + step: 5 + title: Une stack qui évolue avec vous + description: Rôles d'administration, règles de qualité, processus de demande d'évolution, accompagnement en continu si besoin. + deliverable: Un cadre de gouvernance et une équipe interne capable de le tenir. +aiBridge: + title: Une stack propre est une stack prête pour l'IA. + bullets: + - Les saisies se remplissent à partir des mails, des appels et des documents. + - Vos assistants IA se connectent au CRM dans un cadre de sécurité maîtrisé. + - Les règles de qualité de données sont surveillées en continu. +method: + subtitle: De l'audit de la stack à une équipe qui l'administre seule. + steps: + - text: Le Design Sprint cartographie vos outils, vos flux de données et leurs usages réels, et dessine l'architecture cible. + - text: La squad implémente, migre et connecte, avec vos équipes métier et votre DSI. + - text: Vos administrateurs internes tiennent la stack. Nous restons en appui si vous le souhaitez. + tools: + - hubspot + - salesforce + - chargebee + - zendesk + - notion + - oneflow + - hyperline + note: "Et vos ERP, outils de facturation et de marketing. [Badges partenaires officiels à intégrer : HubSpot, Notion]" +case: + headline: "[Nom du client] : [le passage en une phrase]." + summary: "[Contexte en deux lignes. Ce qu'on a construit. Ce qui a changé pour les équipes.]" + metrics: + - value: "[X]" + label: "[Taux d'adoption du CRM, avant et après]" + - value: "[X]" + label: "[Outils supprimés ou temps de saisie économisé]" +diagnostic: + question: Combien payez-vous pour des outils que vos équipes n'utilisent pas ? + subtitle: Trente minutes avec un architecte du revenu pour le savoir, et décider quoi garder, connecter ou arrêter. +--- diff --git a/scripts/__tests__/fixtures/enjeux/previsibilite.md b/scripts/__tests__/fixtures/enjeux/previsibilite.md new file mode 100644 index 0000000..075ea75 --- /dev/null +++ b/scripts/__tests__/fixtures/enjeux/previsibilite.md @@ -0,0 +1,92 @@ +--- +key: rp +order: 3 +name: Revenue Performance +tagline: Un chiffre que vous voyez venir +hero: + title: + - Un chiffre + - que vous voyez venir. + quote: Je découvre mon trimestre à la fin du trimestre. + lead: "Votre board, votre fonds ou votre banque vous demandent un prévisionnel. Vos équipes vous donnent une intuition. Nous construisons le système qui transforme" + highlight: votre pipe en chiffre fiable, mois après mois. + cta: Fiabiliser mon prévisionnel +bowtie: + activeStages: + - discovery + - negotiation + - closing + - expansion + caption: "Du premier rendez-vous à la signature, puis l'expansion de vos comptes : partout où se construit votre prévisionnel." +symptoms: + title: + - Le chiffre existe dans la tête de quelques personnes. + - Nulle part ailleurs. + items: + - Votre prévisionnel se refait à la main chaque fin de mois, dans un tableur. + - Les étapes du pipe veulent dire une chose différente pour chaque commercial. + - Les gros deals glissent d'un trimestre à l'autre sans que personne ne l'ait vu venir. + - Le CRM et la finance donnent deux chiffres différents pour le même mois. + - Quand un commercial part, la visibilité sur ses comptes part avec lui. +audience: + - team: Direction + roles: CEO, DG + benefit: Un chiffre sur lequel s'engager devant le board, sans surprise en fin de trimestre. + - team: Finance + roles: CFO, DAF + benefit: Un prévisionnel commercial aligné sur le budget et la trésorerie. + - team: Ventes + roles: Directeur commercial, CRO + benefit: Un pipe lisible, des écarts repérés tôt, des décisions plus rapides. + - team: Ventes + roles: Managers commerciaux + benefit: Des revues de pipe utiles, centrées sur les deals qui comptent. +workstreams: + intro: Quatre chantiers, priorisés selon votre point de départ. + items: + - category: Processus et techno + title: Un pipe qui veut dire la même chose pour tous + description: Étapes de vente définies par des critères de sortie, qualification commune, règles de probabilité. + deliverable: Processus de vente documenté et paramétré dans votre CRM. + - category: Données + title: Des données sur lesquelles on peut compter + description: Champs obligatoires au bon moment, nettoyage, réconciliation entre CRM et finance. + deliverable: Un modèle de données unique et ses règles de qualité. + - category: KPIs et pilotage + title: Un prévisionnel qui se construit seul + description: Méthode de prévision par catégories, engagement des commerciaux, écart prévu contre réalisé. + deliverable: Prévisionnel automatisé, consolidé chaque semaine. + - category: Organisation + title: Un rituel de pilotage qui tient + description: Revue de pipe hebdomadaire, tableau de bord dirigeant, reporting board ou fonds. + deliverable: Tableaux de bord et rituels animés par vos managers. +aiBridge: + title: Une fois le système posé, l'IA le fait tourner plus vite. + bullets: + - Les comptes rendus d'appel mettent à jour le CRM sans saisie. + - Les deals à risque sont signalés avant la revue de pipe. + - Le dirigeant pose sa question en français et obtient le chiffre. +method: + subtitle: Du diagnostic à un chiffre que vos équipes tiennent seules. + steps: + - text: Le Design Sprint mesure l'écart entre votre prévisionnel et votre réalisé, et identifie d'où il vient. + - text: La squad livre les chantiers prioritaires dans votre CRM, avec vos managers commerciaux et votre direction financière. + - text: Vos équipes animent les rituels. Nous restons jusqu'à ce que l'écart prévu contre réalisé soit tenu. + tools: + - hubspot + - salesforce + - modjo + - notion + note: "Et ceux que vous avez déjà : nous partons de votre stack." +case: + headline: "[Nom du client] : [le passage en une phrase]." + summary: "[Contexte en deux lignes. Ce qu'on a construit. Ce qui a changé pour le dirigeant.]" + metrics: + - value: "[X]" + label: "[Écart prévu contre réalisé, avant et après]" + - value: "[X]" + label: "[Temps gagné sur le reporting]" +diagnostic: + question: Quel est l'écart entre votre prévisionnel et votre réalisé ? + subtitle: Trente minutes avec un architecte du revenu pour le mesurer, et savoir par où commencer. +--- diff --git a/scripts/__tests__/fixtures/enjeux/remuneration-variable.md b/scripts/__tests__/fixtures/enjeux/remuneration-variable.md new file mode 100644 index 0000000..f7b2f43 --- /dev/null +++ b/scripts/__tests__/fixtures/enjeux/remuneration-variable.md @@ -0,0 +1,99 @@ +--- +key: sc +order: 4 +name: Sales Compensation +tagline: Une rémunération variable qui tire dans le bon sens +hero: + title: + - Une rémunération variable + - qui tire dans le bon sens. + quote: Mes commerciaux optimisent leur commission, pas mon chiffre. + lead: "Votre plan de commissionnement est le document que vos commerciaux lisent le plus attentivement. S'il contredit votre stratégie, c'est lui qui l'emporte. Nous concevons, modélisons, outillons et pilotons" + highlight: une rémunération variable alignée sur ce que vous voulez vraiment vendre. + cta: Revoir mon plan de rémunération variable +bowtie: + activeStages: + - negotiation + - closing + - expansion + caption: "Là où le plan oriente les comportements : le choix des deals, la signature, le développement des comptes." +symptoms: + title: + - Un plan qui coûte cher et se calcule à la main. + - Et qui récompense les mauvais deals. + items: + - Les commissions se calculent dans un tableur, chaque mois, avec des erreurs et des contestations. + - Le plan récompense le volume signé, quand vous avez besoin de marge, de récurrence ou de nouveaux clients. + - Vos commerciaux ne savent pas ce qu'ils vont toucher avant la fin du mois. + - Le plan change chaque année sans que personne n'ait mesuré l'effet du précédent. + - Le coût du variable progresse plus vite que votre chiffre, sans que vous sachiez pourquoi. +audience: + - team: Ventes + roles: Directeur commercial, CRO + benefit: Des plans qui orientent les efforts vers les deals que veut l'entreprise. + - team: Finance + roles: CFO, DAF + benefit: Un coût du variable modélisé et maîtrisé, sans calcul manuel. + - team: RH + roles: DRH, People + benefit: Des plans lisibles, équitables et faciles à expliquer à l'embauche. + - team: Terrain + roles: Commerciaux et CSM + benefit: Savoir ce qu'on gagne, et pourquoi, sans attendre la fin du mois. +workstreams: + intro: De la stratégie au bulletin de paie, sans ressaisie. + items: + - category: Alignement + step: 1 + title: Un plan qui découle de votre stratégie + description: Objectifs par rôle, part fixe et variable, accélérateurs, traitement de la marge, des nouveaux clients et des renouvellements. + deliverable: Plans de rémunération variable par rôle, validés par la direction. + - category: KPIs et pilotage + step: 2 + title: Un coût modélisé avant d'être payé + description: Simulation du plan sur votre historique de ventes, scénarios de dépassement, coût de vente rapporté au chiffre. + deliverable: Un modèle financier du plan, partagé avec votre direction financière. + - category: Processus et techno + step: 3 + title: Des commissions calculées automatiquement + description: Données de vente prises dans le CRM, règles paramétrées dans un outil de commissionnement, export vers la paie. + deliverable: Un calcul automatisé, du deal signé à la paie. + - category: Enablement + step: 4 + title: Un plan que chaque commercial comprend + description: Présentation du plan aux équipes, simulateur individuel, suivi de ses gains en temps réel. + deliverable: Supports de lancement et visibilité en continu pour chaque commercial. + - category: Gouvernance + step: 5 + title: Un plan qui s'ajuste sur des faits + description: Mesure de l'effet du plan sur les comportements, traitement des cas particuliers, revue annuelle avec vos RH. + deliverable: Un rituel de revue et des règles de gestion des exceptions. +aiBridge: + title: Chaque commercial sait ce que lui rapporte le deal sur lequel il travaille. + bullets: + - Le commercial pose sa question en français et obtient le détail de sa commission. + - Les scénarios de plan se simulent en quelques minutes, sur vos vraies données. + - Les anomalies de calcul sont repérées avant la paie. +method: + subtitle: De l'analyse du plan actuel à un variable qui se pilote seul. + steps: + - text: Le Design Sprint analyse votre plan actuel, son coût et les comportements qu'il produit vraiment. + - text: La squad conçoit et outille le plan avec votre direction commerciale, votre direction financière et vos RH. + - text: Le calcul tourne seul, et vos équipes ajustent le plan chaque année sur des faits. + tools: + - qobra + - hubspot + - salesforce + note: Et votre outil de paie. Nous partons de votre stack. +case: + headline: "[Nom du client] : [le passage en une phrase]." + summary: "[Contexte en deux lignes. Ce qu'on a construit. Ce qui a changé pour la direction et les équipes.]" + metrics: + - value: "[X]" + label: "[Temps de calcul mensuel des commissions, avant et après]" + - value: "[X]" + label: "[Évolution du comportement visé : marge, récurrence ou nouveaux clients]" +diagnostic: + question: Votre plan récompense-t-il ce que vous voulez vraiment vendre ? + subtitle: Trente minutes avec un architecte du revenu pour le vérifier, et savoir par où commencer. +--- diff --git a/scripts/__tests__/validate-repository.test.js b/scripts/__tests__/validate-repository.test.js index 02d8acf..6337d1a 100644 --- a/scripts/__tests__/validate-repository.test.js +++ b/scripts/__tests__/validate-repository.test.js @@ -1,6 +1,7 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { validateRepository } from '../validate-repository.js'; @@ -98,3 +99,40 @@ describe('validateRepository', () => { expect(assetWarnings).toEqual([]); }); }); + +describe('validateRepository with enjeu files', () => { + const ENJEU = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), 'fixtures/enjeux/previsibilite.md'), + 'utf8', + ); + + it('accepts a French enjeu file', () => { + write('enjeux/fr/previsibilite.md', ENJEU); + + expect(validateRepository(repoRoot).failures).toEqual([]); + }); + + it('rejects an enjeu file with a markdown body', () => { + write('enjeux/fr/previsibilite.md', `${ENJEU}\nUn paragraphe.\n`); + + expect(validateRepository(repoRoot).failures).toEqual([ + { + file: 'enjeux/fr/previsibilite.md', + schemaErrors: [{ path: [], message: expect.stringContaining('body must be empty') }], + crossRefErrors: [], + }, + ]); + }); + + it('reports a cap error with the field, its length, the cap and the overage', () => { + write('enjeux/en/previsibilite.md', ENJEU.replace(/^ {2}lead: .*$/m, ` lead: ${'a'.repeat(263)}`)); + + expect(validateRepository(repoRoot).failures).toEqual([ + { + file: 'enjeux/en/previsibilite.md', + schemaErrors: [{ path: ['hero', 'lead'], message: '263 characters, cap 240 (23 over)' }], + crossRefErrors: [], + }, + ]); + }); +}); diff --git a/scripts/schemas/enjeu.schema.js b/scripts/schemas/enjeu.schema.js new file mode 100644 index 0000000..2cfdaca --- /dev/null +++ b/scripts/schemas/enjeu.schema.js @@ -0,0 +1,120 @@ +import { z } from 'zod'; +import { STAGE_IDS } from './stage-ids.js'; + +// Caps are character counts measured on the French mockup text (spec #91): +// the longest value in the six mockups plus about 20 %. Fixed-height flip +// cards and windows break when a text runs over, so the error states the +// overage the author has to cut. +const text = (cap) => + z + .string() + .trim() + .min(1) + .max(cap, { + error: (iss) => { + const length = iss.input.length; + return `${length} characters, cap ${cap} (${length - cap} over)`; + }, + }); + +// Slugs carry no cap: they never render as text. +const slug = z.string().trim().min(1); + +const ENJEU_KEYS = ['rp', 'se', 'sc', 'cx', 'sy', 'ai']; + +const activeStages = z + .array(z.enum(STAGE_IDS)) + .min(1) + .max(STAGE_IDS.length) + .refine((stages) => new Set(stages).size === stages.length, { + message: 'Stage ids must be unique', + }); + +const workstream = z.strictObject({ + category: text(26), + step: z.number().int().min(1).max(6).optional(), + title: text(64), + description: text(210), + deliverable: text(170), + note: text(100).optional(), +}); + +export const enjeuSchema = z.strictObject({ + key: z.enum(ENJEU_KEYS), + order: z.number().int().min(1).max(6), + name: text(30), + tagline: text(60), + hero: z.strictObject({ + title: z.tuple([text(34), text(42)]), + quote: text(80), + lead: text(240), + highlight: text(110), + cta: text(48), + }), + bowtie: z.strictObject({ + activeStages, + caption: text(140), + }), + symptoms: z.strictObject({ + title: z.tuple([text(64), text(44)]), + items: z.array(text(130)).min(5).max(6), + }), + audience: z + .array( + z.strictObject({ + team: text(20), + roles: text(30), + benefit: text(100), + }), + ) + .length(4), + workstreams: z.strictObject({ + title: text(50).optional(), + intro: text(120), + items: z.array(workstream).min(4).max(6), + }), + aiBridge: z.strictObject({ + title: text(92), + bullets: z.array(text(125)).length(3), + }), + method: z.strictObject({ + title: text(30).optional(), + subtitle: text(75), + steps: z + .array( + z.strictObject({ + title: text(28).optional(), + text: text(150), + }), + ) + .length(3), + // Tool slugs. Their cross-reference check comes in a later ticket. + // Spec #91 says 0 to 6. The Sales Experience mockup shows 9 tools, logos + // included, so the cap is the mockup count, like every other count. + tools: z.array(slug).max(9), + note: text(170), + }), + case: z.strictObject({ + // Optional until Ocobo confirms a Story for the Revenue AI client case + // (assumption A5 in spec #91). + story: slug.optional(), + // Spec #91 says 90. The Revenue AI headline is 98 characters, so the cap + // follows the spec's own rule: longest mockup value plus about 20 %. + headline: text(120), + summary: text(220), + metrics: z + .array( + z.strictObject({ + value: text(12), + label: text(90), + }), + ) + .length(2), + }), + diagnostic: z.strictObject({ + question: text(88), + subtitle: text(125), + }), +}); + +export const validateEnjeu = (data) => enjeuSchema.safeParse(data); diff --git a/scripts/schemas/stage-ids.js b/scripts/schemas/stage-ids.js new file mode 100644 index 0000000..4865769 --- /dev/null +++ b/scripts/schemas/stage-ids.js @@ -0,0 +1,13 @@ +// The seven Stages of the bow tie, in bow tie order. This is a copy of the +// website's list (`app/modules/bowtie-stages.ts` in ocobo-revops/website). +// A change here needs a PR in both repos: an id the website does not know is +// silently dropped from the bow tie. +export const STAGE_IDS = Object.freeze([ + 'demand-generation', + 'discovery', + 'negotiation', + 'closing', + 'onboarding', + 'adoption', + 'expansion', +]); diff --git a/scripts/validate-repository.js b/scripts/validate-repository.js index 046c4b3..8a8d608 100644 --- a/scripts/validate-repository.js +++ b/scripts/validate-repository.js @@ -3,6 +3,7 @@ import { join } from 'node:path'; import { glob } from 'glob'; import matter from 'gray-matter'; import { validateBlogPost } from './schemas/blog-post.schema.js'; +import { validateEnjeu } from './schemas/enjeu.schema.js'; import { validateJob } from './schemas/job.schema.js'; import { validateStory } from './schemas/story.schema.js'; import { validateTeamMember } from './schemas/team-member.schema.js'; @@ -16,6 +17,12 @@ const VALIDATORS = { 'blog-post': { patterns: ['blog/fr/*.md', 'blog/en/*.md'], validate: validateBlogPost }, story: { patterns: ['stories/fr/*.md', 'stories/en/*.md'], validate: validateStory }, job: { patterns: ['jobs/fr/*.md', 'jobs/en/*.md'], validate: validateJob }, + // Every enjeu section is a frontmatter field, so a body would never render. + enjeu: { + patterns: ['enjeux/fr/*.md', 'enjeux/en/*.md'], + validate: validateEnjeu, + frontmatterOnly: true, + }, }; // Cross-reference errors share the schema error shape ({ path, message }) so @@ -67,12 +74,12 @@ export const validateRepository = (repoRoot) => { const failures = []; const storySlugs = new Set(); - for (const [type, { patterns, validate }] of Object.entries(VALIDATORS)) { + for (const [type, { patterns, validate, frontmatterOnly }] of Object.entries(VALIDATORS)) { for (const pattern of patterns) { const files = glob.sync(pattern, { cwd: repoRoot }); for (const file of files) { const raw = readFileSync(join(repoRoot, file), 'utf8'); - const { data } = matter(raw); + const { data, content } = matter(raw); const slug = file.replace(/\.md$/, '').split('/').pop(); if (type === 'story') storySlugs.add(slug); @@ -80,6 +87,9 @@ export const validateRepository = (repoRoot) => { const schemaErrors = result.success ? [] : result.error.issues.map((i) => ({ path: i.path, message: i.message, })); + if (frontmatterOnly && content.trim() !== '') { + schemaErrors.push({ path: [], message: 'markdown body must be empty, content lives in frontmatter' }); + } const crossRefErrors = validateCrossRefs(type, data, resolver); if (schemaErrors.length > 0 || crossRefErrors.length > 0) { From f0be3695ee0913bcdc02d44136b70b92927b55c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?je=CC=81ro=CC=82me=20boileux?= Date: Wed, 21 Oct 2026 13:00:43 +0200 Subject: [PATCH 2/4] test(enjeu): tighten assertions and apply review fixes Exact error paths instead of non-empty checks, strictness cases for every section, 'must not be empty' for blank strings, ENJEU_KEYS exported, and the cap test writes a French file. --- scripts/__tests__/enjeu.schema.test.js | 30 +++++-- scripts/__tests__/validate-repository.test.js | 5 +- scripts/schemas/enjeu.schema.js | 82 +++++++++---------- 3 files changed, 65 insertions(+), 52 deletions(-) diff --git a/scripts/__tests__/enjeu.schema.test.js b/scripts/__tests__/enjeu.schema.test.js index ce213e7..d9e023f 100644 --- a/scripts/__tests__/enjeu.schema.test.js +++ b/scripts/__tests__/enjeu.schema.test.js @@ -46,7 +46,7 @@ describe('enjeu golden fixtures', () => { expect(issuesOf(loadFixture(file))).toEqual([]); }); - it('Revenue AI overrides the method and workstreams headings', () => { + it('the Revenue AI fixture sets the heading overrides', () => { const ai = loadFixture('ia.md'); expect(ai.method.title).toBeDefined(); expect(ai.method.steps.every((s) => s.title)).toBe(true); @@ -130,7 +130,7 @@ describe('enjeu strings', () => { [['audience', 3, 'benefit']], [['method', 'tools', 0]], ])('rejects a blank string at %j', (path) => { - expect(issuesOf(withValue(path, ' '))).toEqual([{ path, message: expect.any(String) }]); + expect(issuesOf(withValue(path, ' '))).toEqual([{ path, message: 'must not be empty' }]); }); }); @@ -160,10 +160,11 @@ describe('enjeu array counts', () => { }); it.each(COUNTS)('%j rejects fewer than %i or more than %i items', (path, min, max) => { + const error = [{ path, message: expect.any(String) }]; if (min > 0) { - expect(issuesOf(withValue(path, resize(path, min - 1)))).not.toEqual([]); + expect(issuesOf(withValue(path, resize(path, min - 1)))).toEqual(error); } - expect(issuesOf(withValue(path, resize(path, max + 1)))).not.toEqual([]); + expect(issuesOf(withValue(path, resize(path, max + 1)))).toEqual(error); }); }); @@ -192,7 +193,9 @@ describe('enjeu Stage ids', () => { }); it('rejects no Stage', () => { - expect(issuesOf(withValue(['bowtie', 'activeStages'], []))).not.toEqual([]); + expect(issuesOf(withValue(['bowtie', 'activeStages'], []))).toEqual([ + { path: ['bowtie', 'activeStages'], message: expect.any(String) }, + ]); }); it('rejects an unknown Stage id', () => { @@ -216,7 +219,7 @@ describe('enjeu key, order and step', () => { }); it('rejects an unknown key', () => { - expect(issuesOf(withValue(['key'], 'ops'))).not.toEqual([]); + expect(issuesOf(withValue(['key'], 'ops'))).toEqual([{ path: ['key'], message: expect.any(String) }]); }); it.each([1, 6])('accepts order %i', (order) => { @@ -224,7 +227,7 @@ describe('enjeu key, order and step', () => { }); it.each([0, 7, 2.5, '3'])('rejects order %j', (order) => { - expect(issuesOf(withValue(['order'], order))).not.toEqual([]); + expect(issuesOf(withValue(['order'], order))).toEqual([{ path: ['order'], message: expect.any(String) }]); }); it.each([1, 6])('accepts workstream step %i', (step) => { @@ -232,7 +235,9 @@ describe('enjeu key, order and step', () => { }); it.each([0, 7, 1.5])('rejects workstream step %j', (step) => { - expect(issuesOf(withValue(['workstreams', 'items', 0, 'step'], step))).not.toEqual([]); + expect(issuesOf(withValue(['workstreams', 'items', 0, 'step'], step))).toEqual([ + { path: ['workstreams', 'items', 0, 'step'], message: expect.any(String) }, + ]); }); }); @@ -255,7 +260,7 @@ describe('enjeu optional fields', () => { [['method', 'tools']], [['diagnostic']], ])('rejects a missing %j', (path) => { - expect(issuesOf(without(path))).not.toEqual([]); + expect(issuesOf(without(path))).toEqual([{ path, message: expect.any(String) }]); }); }); @@ -270,6 +275,13 @@ describe('enjeu unknown keys', () => { it.each([ [['hero', 'subtitle']], + [['bowtie', 'legend']], + [['symptoms', 'intro']], + [['workstreams', 'cta']], + [['aiBridge', 'demo']], + [['method', 'cta']], + [['case', 'quote']], + [['diagnostic', 'cta']], [['audience', 0, 'icon']], [['workstreams', 'items', 0, 'image']], [['method', 'steps', 1, 'icon']], diff --git a/scripts/__tests__/validate-repository.test.js b/scripts/__tests__/validate-repository.test.js index 6337d1a..aa63be8 100644 --- a/scripts/__tests__/validate-repository.test.js +++ b/scripts/__tests__/validate-repository.test.js @@ -107,6 +107,7 @@ describe('validateRepository with enjeu files', () => { ); it('accepts a French enjeu file', () => { + ['salesforce', 'modjo', 'notion'].forEach((tool) => write(`tools/${tool}.md`, TOOL)); write('enjeux/fr/previsibilite.md', ENJEU); expect(validateRepository(repoRoot).failures).toEqual([]); @@ -125,11 +126,11 @@ describe('validateRepository with enjeu files', () => { }); it('reports a cap error with the field, its length, the cap and the overage', () => { - write('enjeux/en/previsibilite.md', ENJEU.replace(/^ {2}lead: .*$/m, ` lead: ${'a'.repeat(263)}`)); + write('enjeux/fr/previsibilite.md', ENJEU.replace(/^ {2}lead: .*$/m, ` lead: ${'a'.repeat(263)}`)); expect(validateRepository(repoRoot).failures).toEqual([ { - file: 'enjeux/en/previsibilite.md', + file: 'enjeux/fr/previsibilite.md', schemaErrors: [{ path: ['hero', 'lead'], message: '263 characters, cap 240 (23 over)' }], crossRefErrors: [], }, diff --git a/scripts/schemas/enjeu.schema.js b/scripts/schemas/enjeu.schema.js index 2cfdaca..83c2586 100644 --- a/scripts/schemas/enjeu.schema.js +++ b/scripts/schemas/enjeu.schema.js @@ -2,14 +2,14 @@ import { z } from 'zod'; import { STAGE_IDS } from './stage-ids.js'; // Caps are character counts measured on the French mockup text (spec #91): -// the longest value in the six mockups plus about 20 %. Fixed-height flip +// the longest value in the six mockups plus about 20%. Fixed-height flip // cards and windows break when a text runs over, so the error states the // overage the author has to cut. -const text = (cap) => +const cappedText = (cap) => z .string() .trim() - .min(1) + .min(1, { error: 'must not be empty' }) .max(cap, { error: (iss) => { const length = iss.input.length; @@ -18,9 +18,9 @@ const text = (cap) => }); // Slugs carry no cap: they never render as text. -const slug = z.string().trim().min(1); +const slug = z.string().trim().min(1, { error: 'must not be empty' }); -const ENJEU_KEYS = ['rp', 'se', 'sc', 'cx', 'sy', 'ai']; +export const ENJEU_KEYS = ['rp', 'se', 'sc', 'cx', 'sy', 'ai']; const activeStages = z .array(z.enum(STAGE_IDS)) @@ -31,89 +31,89 @@ const activeStages = z }); const workstream = z.strictObject({ - category: text(26), + category: cappedText(26), step: z.number().int().min(1).max(6).optional(), - title: text(64), - description: text(210), - deliverable: text(170), - note: text(100).optional(), + title: cappedText(64), + description: cappedText(210), + deliverable: cappedText(170), + note: cappedText(100).optional(), }); export const enjeuSchema = z.strictObject({ key: z.enum(ENJEU_KEYS), order: z.number().int().min(1).max(6), - name: text(30), - tagline: text(60), + name: cappedText(30), + tagline: cappedText(60), hero: z.strictObject({ - title: z.tuple([text(34), text(42)]), - quote: text(80), - lead: text(240), - highlight: text(110), - cta: text(48), + title: z.tuple([cappedText(34), cappedText(42)]), + quote: cappedText(80), + lead: cappedText(240), + highlight: cappedText(110), + cta: cappedText(48), }), bowtie: z.strictObject({ activeStages, - caption: text(140), + caption: cappedText(140), }), symptoms: z.strictObject({ - title: z.tuple([text(64), text(44)]), - items: z.array(text(130)).min(5).max(6), + title: z.tuple([cappedText(64), cappedText(44)]), + items: z.array(cappedText(130)).min(5).max(6), }), audience: z .array( z.strictObject({ - team: text(20), - roles: text(30), - benefit: text(100), + team: cappedText(20), + roles: cappedText(30), + benefit: cappedText(100), }), ) .length(4), workstreams: z.strictObject({ - title: text(50).optional(), - intro: text(120), + title: cappedText(50).optional(), + intro: cappedText(120), items: z.array(workstream).min(4).max(6), }), aiBridge: z.strictObject({ - title: text(92), - bullets: z.array(text(125)).length(3), + title: cappedText(92), + bullets: z.array(cappedText(125)).length(3), }), method: z.strictObject({ - title: text(30).optional(), - subtitle: text(75), + title: cappedText(30).optional(), + subtitle: cappedText(75), steps: z .array( z.strictObject({ - title: text(28).optional(), - text: text(150), + title: cappedText(28).optional(), + text: cappedText(150), }), ) .length(3), // Tool slugs. Their cross-reference check comes in a later ticket. - // Spec #91 says 0 to 6. The Sales Experience mockup shows 9 tools, logos - // included, so the cap is the mockup count, like every other count. + // Spec #91 says 0 to 6. Two mockups exceed it, logos included (Sales + // Experience 9, Revenue Systems 7), so the cap is the largest mockup count. tools: z.array(slug).max(9), - note: text(170), + note: cappedText(170), }), case: z.strictObject({ // Optional until Ocobo confirms a Story for the Revenue AI client case // (assumption A5 in spec #91). story: slug.optional(), // Spec #91 says 90. The Revenue AI headline is 98 characters, so the cap - // follows the spec's own rule: longest mockup value plus about 20 %. - headline: text(120), - summary: text(220), + // follows the spec's own rule: longest mockup value plus about 20%. + headline: cappedText(120), + summary: cappedText(220), metrics: z .array( z.strictObject({ - value: text(12), - label: text(90), + value: cappedText(12), + label: cappedText(90), }), ) .length(2), }), diagnostic: z.strictObject({ - question: text(88), - subtitle: text(125), + question: cappedText(88), + subtitle: cappedText(125), }), }); From 864d3731206a0806900c7be680ac07b0c6c5138c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?je=CC=81ro=CC=82me=20boileux?= Date: Wed, 21 Oct 2026 16:07:04 +0200 Subject: [PATCH 3/4] test(enjeu): name fixtures with English slugs from the enjeu name The slug is the kebab-case of the enjeu name, the same in French and English. Cross-references keep using key; the slug only drives the URL. --- scripts/__tests__/enjeu.schema.test.js | 9 +++++++-- .../enjeux/{clients.md => customer-experience.md} | 0 .../fixtures/enjeux/{ia.md => revenue-ai.md} | 0 .../{previsibilite.md => revenue-performance.md} | 0 .../enjeux/{outils.md => revenue-systems.md} | 0 ...emuneration-variable.md => sales-compensation.md} | 0 ...ganisation-commerciale.md => sales-experience.md} | 0 scripts/__tests__/validate-repository.test.js | 12 ++++++------ 8 files changed, 13 insertions(+), 8 deletions(-) rename scripts/__tests__/fixtures/enjeux/{clients.md => customer-experience.md} (100%) rename scripts/__tests__/fixtures/enjeux/{ia.md => revenue-ai.md} (100%) rename scripts/__tests__/fixtures/enjeux/{previsibilite.md => revenue-performance.md} (100%) rename scripts/__tests__/fixtures/enjeux/{outils.md => revenue-systems.md} (100%) rename scripts/__tests__/fixtures/enjeux/{remuneration-variable.md => sales-compensation.md} (100%) rename scripts/__tests__/fixtures/enjeux/{organisation-commerciale.md => sales-experience.md} (100%) diff --git a/scripts/__tests__/enjeu.schema.test.js b/scripts/__tests__/enjeu.schema.test.js index d9e023f..602c75a 100644 --- a/scripts/__tests__/enjeu.schema.test.js +++ b/scripts/__tests__/enjeu.schema.test.js @@ -12,7 +12,7 @@ import { STAGE_IDS } from '../schemas/stage-ids.js'; const FIXTURES_DIR = join(dirname(fileURLToPath(import.meta.url)), 'fixtures/enjeux'); const loadFixture = (file) => matter(readFileSync(join(FIXTURES_DIR, file), 'utf8')).data; -const base = loadFixture('previsibilite.md'); +const base = loadFixture('revenue-performance.md'); const withValue = (path, value) => { const data = structuredClone(base); @@ -42,12 +42,17 @@ describe('enjeu golden fixtures', () => { expect(files.map((f) => loadFixture(f).key).sort()).toEqual(['ai', 'cx', 'rp', 'sc', 'se', 'sy']); }); + // The slug is the English kebab-case of the name, the same in both languages. + it.each(files)('%s is named after its enjeu', (file) => { + expect(file).toBe(`${loadFixture(file).name.toLowerCase().replaceAll(' ', '-')}.md`); + }); + it.each(files)('%s passes', (file) => { expect(issuesOf(loadFixture(file))).toEqual([]); }); it('the Revenue AI fixture sets the heading overrides', () => { - const ai = loadFixture('ia.md'); + const ai = loadFixture('revenue-ai.md'); expect(ai.method.title).toBeDefined(); expect(ai.method.steps.every((s) => s.title)).toBe(true); expect(ai.workstreams.title).toBeDefined(); diff --git a/scripts/__tests__/fixtures/enjeux/clients.md b/scripts/__tests__/fixtures/enjeux/customer-experience.md similarity index 100% rename from scripts/__tests__/fixtures/enjeux/clients.md rename to scripts/__tests__/fixtures/enjeux/customer-experience.md diff --git a/scripts/__tests__/fixtures/enjeux/ia.md b/scripts/__tests__/fixtures/enjeux/revenue-ai.md similarity index 100% rename from scripts/__tests__/fixtures/enjeux/ia.md rename to scripts/__tests__/fixtures/enjeux/revenue-ai.md diff --git a/scripts/__tests__/fixtures/enjeux/previsibilite.md b/scripts/__tests__/fixtures/enjeux/revenue-performance.md similarity index 100% rename from scripts/__tests__/fixtures/enjeux/previsibilite.md rename to scripts/__tests__/fixtures/enjeux/revenue-performance.md diff --git a/scripts/__tests__/fixtures/enjeux/outils.md b/scripts/__tests__/fixtures/enjeux/revenue-systems.md similarity index 100% rename from scripts/__tests__/fixtures/enjeux/outils.md rename to scripts/__tests__/fixtures/enjeux/revenue-systems.md diff --git a/scripts/__tests__/fixtures/enjeux/remuneration-variable.md b/scripts/__tests__/fixtures/enjeux/sales-compensation.md similarity index 100% rename from scripts/__tests__/fixtures/enjeux/remuneration-variable.md rename to scripts/__tests__/fixtures/enjeux/sales-compensation.md diff --git a/scripts/__tests__/fixtures/enjeux/organisation-commerciale.md b/scripts/__tests__/fixtures/enjeux/sales-experience.md similarity index 100% rename from scripts/__tests__/fixtures/enjeux/organisation-commerciale.md rename to scripts/__tests__/fixtures/enjeux/sales-experience.md diff --git a/scripts/__tests__/validate-repository.test.js b/scripts/__tests__/validate-repository.test.js index aa63be8..0c576d2 100644 --- a/scripts/__tests__/validate-repository.test.js +++ b/scripts/__tests__/validate-repository.test.js @@ -102,23 +102,23 @@ describe('validateRepository', () => { describe('validateRepository with enjeu files', () => { const ENJEU = readFileSync( - join(dirname(fileURLToPath(import.meta.url)), 'fixtures/enjeux/previsibilite.md'), + join(dirname(fileURLToPath(import.meta.url)), 'fixtures/enjeux/revenue-performance.md'), 'utf8', ); it('accepts a French enjeu file', () => { ['salesforce', 'modjo', 'notion'].forEach((tool) => write(`tools/${tool}.md`, TOOL)); - write('enjeux/fr/previsibilite.md', ENJEU); + write('enjeux/fr/revenue-performance.md', ENJEU); expect(validateRepository(repoRoot).failures).toEqual([]); }); it('rejects an enjeu file with a markdown body', () => { - write('enjeux/fr/previsibilite.md', `${ENJEU}\nUn paragraphe.\n`); + write('enjeux/fr/revenue-performance.md', `${ENJEU}\nUn paragraphe.\n`); expect(validateRepository(repoRoot).failures).toEqual([ { - file: 'enjeux/fr/previsibilite.md', + file: 'enjeux/fr/revenue-performance.md', schemaErrors: [{ path: [], message: expect.stringContaining('body must be empty') }], crossRefErrors: [], }, @@ -126,11 +126,11 @@ describe('validateRepository with enjeu files', () => { }); it('reports a cap error with the field, its length, the cap and the overage', () => { - write('enjeux/fr/previsibilite.md', ENJEU.replace(/^ {2}lead: .*$/m, ` lead: ${'a'.repeat(263)}`)); + write('enjeux/fr/revenue-performance.md', ENJEU.replace(/^ {2}lead: .*$/m, ` lead: ${'a'.repeat(263)}`)); expect(validateRepository(repoRoot).failures).toEqual([ { - file: 'enjeux/fr/previsibilite.md', + file: 'enjeux/fr/revenue-performance.md', schemaErrors: [{ path: ['hero', 'lead'], message: '263 characters, cap 240 (23 over)' }], crossRefErrors: [], }, From 2077110582d5bbdcaf98d8c42da4ec39de144496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?je=CC=81ro=CC=82me=20boileux?= Date: Wed, 21 Oct 2026 16:19:22 +0200 Subject: [PATCH 4/4] refactor(challenge): rename the enjeu content type to challenge The content type, its directory (challenges//), schema file, exports (CHALLENGE_KEYS, challengeSchema, validateChallenge), fixtures and tests now say challenge. Enjeu stays the French UI word (posts#110). --- ...chema.test.js => challenge.schema.test.js} | 30 +++++++++---------- .../customer-experience.md | 0 .../{enjeux => challenges}/revenue-ai.md | 0 .../revenue-performance.md | 0 .../{enjeux => challenges}/revenue-systems.md | 0 .../sales-compensation.md | 0 .../sales-experience.md | 0 scripts/__tests__/validate-repository.test.js | 20 ++++++------- .../{enjeu.schema.js => challenge.schema.js} | 8 ++--- scripts/validate-repository.js | 10 +++---- 10 files changed, 34 insertions(+), 34 deletions(-) rename scripts/__tests__/{enjeu.schema.test.js => challenge.schema.test.js} (93%) rename scripts/__tests__/fixtures/{enjeux => challenges}/customer-experience.md (100%) rename scripts/__tests__/fixtures/{enjeux => challenges}/revenue-ai.md (100%) rename scripts/__tests__/fixtures/{enjeux => challenges}/revenue-performance.md (100%) rename scripts/__tests__/fixtures/{enjeux => challenges}/revenue-systems.md (100%) rename scripts/__tests__/fixtures/{enjeux => challenges}/sales-compensation.md (100%) rename scripts/__tests__/fixtures/{enjeux => challenges}/sales-experience.md (100%) rename scripts/schemas/{enjeu.schema.js => challenge.schema.js} (93%) diff --git a/scripts/__tests__/enjeu.schema.test.js b/scripts/__tests__/challenge.schema.test.js similarity index 93% rename from scripts/__tests__/enjeu.schema.test.js rename to scripts/__tests__/challenge.schema.test.js index 602c75a..e6df0e2 100644 --- a/scripts/__tests__/enjeu.schema.test.js +++ b/scripts/__tests__/challenge.schema.test.js @@ -3,13 +3,13 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import matter from 'gray-matter'; import { describe, expect, it } from 'vitest'; -import { validateEnjeu } from '../schemas/enjeu.schema.js'; +import { validateChallenge } from '../schemas/challenge.schema.js'; import { STAGE_IDS } from '../schemas/stage-ids.js'; -// One golden fixture per enjeu, built from the text of the six enjeux-*.html +// One golden fixture per challenge, built from the text of the six enjeux-*.html // mockups in ocobo-revops/prototype, without the partner block and the agent // window. -const FIXTURES_DIR = join(dirname(fileURLToPath(import.meta.url)), 'fixtures/enjeux'); +const FIXTURES_DIR = join(dirname(fileURLToPath(import.meta.url)), 'fixtures/challenges'); const loadFixture = (file) => matter(readFileSync(join(FIXTURES_DIR, file), 'utf8')).data; const base = loadFixture('revenue-performance.md'); @@ -29,21 +29,21 @@ const without = (path) => { }; const issuesOf = (data) => { - const result = validateEnjeu(data); + const result = validateChallenge(data); return result.success ? [] : result.error.issues.map((i) => ({ path: i.path, message: i.message })); }; // ── golden fixtures ─────────────────────────────────────────────────────────── -describe('enjeu golden fixtures', () => { +describe('challenge golden fixtures', () => { const files = readdirSync(FIXTURES_DIR).filter((f) => f.endsWith('.md')); - it('has one fixture per enjeu key', () => { + it('has one fixture per challenge key', () => { expect(files.map((f) => loadFixture(f).key).sort()).toEqual(['ai', 'cx', 'rp', 'sc', 'se', 'sy']); }); // The slug is the English kebab-case of the name, the same in both languages. - it.each(files)('%s is named after its enjeu', (file) => { + it.each(files)('%s is named after its challenge', (file) => { expect(file).toBe(`${loadFixture(file).name.toLowerCase().replaceAll(' ', '-')}.md`); }); @@ -99,7 +99,7 @@ const CAPS = [ [['diagnostic', 'subtitle'], 125], ]; -describe('enjeu caps', () => { +describe('challenge caps', () => { it.each(CAPS)('%j accepts %i characters', (path, cap) => { expect(issuesOf(withValue(path, 'a'.repeat(cap)))).toEqual([]); }); @@ -123,9 +123,9 @@ describe('enjeu caps', () => { // ── strings ─────────────────────────────────────────────────────────────────── -describe('enjeu strings', () => { +describe('challenge strings', () => { it('trims every string', () => { - const result = validateEnjeu(withValue(['hero', 'quote'], ' Une citation. ')); + const result = validateChallenge(withValue(['hero', 'quote'], ' Une citation. ')); expect(result.data.hero.quote).toBe('Une citation.'); }); @@ -158,7 +158,7 @@ const resize = (path, count) => { return Array.from({ length: count }, (_, i) => structuredClone(items[i % items.length])); }; -describe('enjeu array counts', () => { +describe('challenge array counts', () => { it.each(COUNTS)('%j accepts %i to %i items', (path, min, max) => { expect(issuesOf(withValue(path, resize(path, min)))).toEqual([]); expect(issuesOf(withValue(path, resize(path, max)))).toEqual([]); @@ -175,7 +175,7 @@ describe('enjeu array counts', () => { // ── Stage ids ───────────────────────────────────────────────────────────────── -describe('enjeu Stage ids', () => { +describe('challenge Stage ids', () => { it('lists the seven Stages in bow tie order', () => { expect(STAGE_IDS).toEqual([ 'demand-generation', @@ -218,7 +218,7 @@ describe('enjeu Stage ids', () => { // ── key, order, step ────────────────────────────────────────────────────────── -describe('enjeu key, order and step', () => { +describe('challenge key, order and step', () => { it.each(['rp', 'se', 'sc', 'cx', 'sy', 'ai'])('accepts key %s', (key) => { expect(issuesOf(withValue(['key'], key))).toEqual([]); }); @@ -248,7 +248,7 @@ describe('enjeu key, order and step', () => { // ── optional and required fields ────────────────────────────────────────────── -describe('enjeu optional fields', () => { +describe('challenge optional fields', () => { it('accepts a case without a Story', () => { expect(base.case.story).toBeUndefined(); expect(issuesOf(base)).toEqual([]); @@ -271,7 +271,7 @@ describe('enjeu optional fields', () => { // ── unknown keys ────────────────────────────────────────────────────────────── -describe('enjeu unknown keys', () => { +describe('challenge unknown keys', () => { it('rejects extraSections', () => { expect(issuesOf({ ...base, extraSections: [{ type: 'stack' }] })).toEqual([ { path: [], message: 'Unrecognized key: "extraSections"' }, diff --git a/scripts/__tests__/fixtures/enjeux/customer-experience.md b/scripts/__tests__/fixtures/challenges/customer-experience.md similarity index 100% rename from scripts/__tests__/fixtures/enjeux/customer-experience.md rename to scripts/__tests__/fixtures/challenges/customer-experience.md diff --git a/scripts/__tests__/fixtures/enjeux/revenue-ai.md b/scripts/__tests__/fixtures/challenges/revenue-ai.md similarity index 100% rename from scripts/__tests__/fixtures/enjeux/revenue-ai.md rename to scripts/__tests__/fixtures/challenges/revenue-ai.md diff --git a/scripts/__tests__/fixtures/enjeux/revenue-performance.md b/scripts/__tests__/fixtures/challenges/revenue-performance.md similarity index 100% rename from scripts/__tests__/fixtures/enjeux/revenue-performance.md rename to scripts/__tests__/fixtures/challenges/revenue-performance.md diff --git a/scripts/__tests__/fixtures/enjeux/revenue-systems.md b/scripts/__tests__/fixtures/challenges/revenue-systems.md similarity index 100% rename from scripts/__tests__/fixtures/enjeux/revenue-systems.md rename to scripts/__tests__/fixtures/challenges/revenue-systems.md diff --git a/scripts/__tests__/fixtures/enjeux/sales-compensation.md b/scripts/__tests__/fixtures/challenges/sales-compensation.md similarity index 100% rename from scripts/__tests__/fixtures/enjeux/sales-compensation.md rename to scripts/__tests__/fixtures/challenges/sales-compensation.md diff --git a/scripts/__tests__/fixtures/enjeux/sales-experience.md b/scripts/__tests__/fixtures/challenges/sales-experience.md similarity index 100% rename from scripts/__tests__/fixtures/enjeux/sales-experience.md rename to scripts/__tests__/fixtures/challenges/sales-experience.md diff --git a/scripts/__tests__/validate-repository.test.js b/scripts/__tests__/validate-repository.test.js index 0c576d2..488eb45 100644 --- a/scripts/__tests__/validate-repository.test.js +++ b/scripts/__tests__/validate-repository.test.js @@ -100,25 +100,25 @@ describe('validateRepository', () => { }); }); -describe('validateRepository with enjeu files', () => { - const ENJEU = readFileSync( - join(dirname(fileURLToPath(import.meta.url)), 'fixtures/enjeux/revenue-performance.md'), +describe('validateRepository with challenge files', () => { + const CHALLENGE = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), 'fixtures/challenges/revenue-performance.md'), 'utf8', ); - it('accepts a French enjeu file', () => { + it('accepts a French challenge file', () => { ['salesforce', 'modjo', 'notion'].forEach((tool) => write(`tools/${tool}.md`, TOOL)); - write('enjeux/fr/revenue-performance.md', ENJEU); + write('challenges/fr/revenue-performance.md', CHALLENGE); expect(validateRepository(repoRoot).failures).toEqual([]); }); - it('rejects an enjeu file with a markdown body', () => { - write('enjeux/fr/revenue-performance.md', `${ENJEU}\nUn paragraphe.\n`); + it('rejects a challenge file with a markdown body', () => { + write('challenges/fr/revenue-performance.md', `${CHALLENGE}\nUn paragraphe.\n`); expect(validateRepository(repoRoot).failures).toEqual([ { - file: 'enjeux/fr/revenue-performance.md', + file: 'challenges/fr/revenue-performance.md', schemaErrors: [{ path: [], message: expect.stringContaining('body must be empty') }], crossRefErrors: [], }, @@ -126,11 +126,11 @@ describe('validateRepository with enjeu files', () => { }); it('reports a cap error with the field, its length, the cap and the overage', () => { - write('enjeux/fr/revenue-performance.md', ENJEU.replace(/^ {2}lead: .*$/m, ` lead: ${'a'.repeat(263)}`)); + write('challenges/fr/revenue-performance.md', CHALLENGE.replace(/^ {2}lead: .*$/m, ` lead: ${'a'.repeat(263)}`)); expect(validateRepository(repoRoot).failures).toEqual([ { - file: 'enjeux/fr/revenue-performance.md', + file: 'challenges/fr/revenue-performance.md', schemaErrors: [{ path: ['hero', 'lead'], message: '263 characters, cap 240 (23 over)' }], crossRefErrors: [], }, diff --git a/scripts/schemas/enjeu.schema.js b/scripts/schemas/challenge.schema.js similarity index 93% rename from scripts/schemas/enjeu.schema.js rename to scripts/schemas/challenge.schema.js index 83c2586..e8340ba 100644 --- a/scripts/schemas/enjeu.schema.js +++ b/scripts/schemas/challenge.schema.js @@ -20,7 +20,7 @@ const cappedText = (cap) => // Slugs carry no cap: they never render as text. const slug = z.string().trim().min(1, { error: 'must not be empty' }); -export const ENJEU_KEYS = ['rp', 'se', 'sc', 'cx', 'sy', 'ai']; +export const CHALLENGE_KEYS = ['rp', 'se', 'sc', 'cx', 'sy', 'ai']; const activeStages = z .array(z.enum(STAGE_IDS)) @@ -39,8 +39,8 @@ const workstream = z.strictObject({ note: cappedText(100).optional(), }); -export const enjeuSchema = z.strictObject({ - key: z.enum(ENJEU_KEYS), +export const challengeSchema = z.strictObject({ + key: z.enum(CHALLENGE_KEYS), order: z.number().int().min(1).max(6), name: cappedText(30), tagline: cappedText(60), @@ -117,4 +117,4 @@ export const enjeuSchema = z.strictObject({ }), }); -export const validateEnjeu = (data) => enjeuSchema.safeParse(data); +export const validateChallenge = (data) => challengeSchema.safeParse(data); diff --git a/scripts/validate-repository.js b/scripts/validate-repository.js index 8a8d608..3c70353 100644 --- a/scripts/validate-repository.js +++ b/scripts/validate-repository.js @@ -3,7 +3,7 @@ import { join } from 'node:path'; import { glob } from 'glob'; import matter from 'gray-matter'; import { validateBlogPost } from './schemas/blog-post.schema.js'; -import { validateEnjeu } from './schemas/enjeu.schema.js'; +import { validateChallenge } from './schemas/challenge.schema.js'; import { validateJob } from './schemas/job.schema.js'; import { validateStory } from './schemas/story.schema.js'; import { validateTeamMember } from './schemas/team-member.schema.js'; @@ -17,10 +17,10 @@ const VALIDATORS = { 'blog-post': { patterns: ['blog/fr/*.md', 'blog/en/*.md'], validate: validateBlogPost }, story: { patterns: ['stories/fr/*.md', 'stories/en/*.md'], validate: validateStory }, job: { patterns: ['jobs/fr/*.md', 'jobs/en/*.md'], validate: validateJob }, - // Every enjeu section is a frontmatter field, so a body would never render. - enjeu: { - patterns: ['enjeux/fr/*.md', 'enjeux/en/*.md'], - validate: validateEnjeu, + // Every challenge section is a frontmatter field, so a body would never render. + challenge: { + patterns: ['challenges/fr/*.md', 'challenges/en/*.md'], + validate: validateChallenge, frontmatterOnly: true, }, };