From 877ee322b77f6a9022f8663b7533c9186434c272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?je=CC=81ro=CC=82me=20boileux?= Date: Wed, 21 Oct 2026 12:51:51 +0200 Subject: [PATCH 1/4] feat(team): add optional expert fields (enjeu, contribution, tools, previousCompanies) Refs #95 --- scripts/__tests__/schemas.test.js | 87 +++++++++++++++++++ scripts/__tests__/validate-repository.test.js | 37 ++++++++ scripts/schemas/team-member.schema.js | 27 ++++++ scripts/validate-repository.js | 7 ++ 4 files changed, 158 insertions(+) diff --git a/scripts/__tests__/schemas.test.js b/scripts/__tests__/schemas.test.js index eeb965f..223c75a 100644 --- a/scripts/__tests__/schemas.test.js +++ b/scripts/__tests__/schemas.test.js @@ -75,6 +75,93 @@ describe('team-member schema', () => { }); }); +describe('team-member expert fields', () => { + const expert = { + ...validMember, + enjeu: 'sc', + contribution: { fr: 'Contribution', en: 'Contribution EN' }, + tools: ['hubspot', 'qobra'], + previousCompanies: ['Qonto'], + }; + const fails = (data) => validateTeamMember(data).success === false; + const messages = (data) => validateTeamMember(data).error.issues.map((i) => i.message); + + it('accepts a member without any expert field', () => { + expect(validateTeamMember(validMember).success).toBe(true); + }); + + it('accepts a member with all four expert fields', () => { + expect(validateTeamMember(expert).success).toBe(true); + }); + + it('accepts every enjeu key', () => { + for (const enjeu of ['rp', 'se', 'sc', 'cx', 'sy', 'ai']) { + expect(validateTeamMember({ ...expert, enjeu }).success).toBe(true); + } + }); + + it('rejects an unknown enjeu key', () => { + expect(fails({ ...expert, enjeu: 'xx' })).toBe(true); + }); + + it('rejects enjeu without contribution', () => { + const { contribution, ...rest } = expert; + expect(fails(rest)).toBe(true); + expect(messages(rest)).toContain('contribution is required when enjeu is set'); + }); + + it('rejects enjeu without tools', () => { + const { tools, ...rest } = expert; + expect(fails(rest)).toBe(true); + expect(messages(rest)).toContain('tools is required when enjeu is set'); + }); + + it('accepts enjeu without previousCompanies', () => { + const { previousCompanies, ...rest } = expert; + expect(validateTeamMember(rest).success).toBe(true); + }); + + it('accepts contribution, tools and previousCompanies without enjeu', () => { + const { enjeu, ...rest } = expert; + expect(validateTeamMember(rest).success).toBe(true); + }); + + it('caps contribution at 100 characters per language', () => { + const ok = 'x'.repeat(100); + const tooLong = 'x'.repeat(101); + expect(validateTeamMember({ ...expert, contribution: { fr: ok, en: ok } }).success).toBe(true); + expect(fails({ ...expert, contribution: { fr: tooLong, en: ok } })).toBe(true); + expect(fails({ ...expert, contribution: { fr: ok, en: tooLong } })).toBe(true); + }); + + it('rejects a contribution missing a language', () => { + expect(fails({ ...expert, contribution: { fr: 'Contribution' } })).toBe(true); + }); + + it('accepts 1 to 8 tools and rejects 0 or 9', () => { + const slugs = (n) => Array.from({ length: n }, (_, i) => `tool-${i}`); + expect(validateTeamMember({ ...expert, tools: slugs(1) }).success).toBe(true); + expect(validateTeamMember({ ...expert, tools: slugs(8) }).success).toBe(true); + expect(fails({ ...expert, tools: [] })).toBe(true); + expect(fails({ ...expert, tools: slugs(9) })).toBe(true); + }); + + it('rejects duplicate tools', () => { + expect(fails({ ...expert, tools: ['hubspot', 'hubspot'] })).toBe(true); + }); + + it('accepts 1 to 3 previous companies and rejects 0 or 4', () => { + expect(validateTeamMember({ ...expert, previousCompanies: ['A', 'B', 'C'] }).success).toBe(true); + expect(fails({ ...expert, previousCompanies: [] })).toBe(true); + expect(fails({ ...expert, previousCompanies: ['A', 'B', 'C', 'D'] })).toBe(true); + }); + + it('caps a previous company name at 30 characters', () => { + expect(validateTeamMember({ ...expert, previousCompanies: ['x'.repeat(30)] }).success).toBe(true); + expect(fails({ ...expert, previousCompanies: ['x'.repeat(31)] })).toBe(true); + }); +}); + // ── blog post ───────────────────────────────────────────────────────────────── const validPost = { diff --git a/scripts/__tests__/validate-repository.test.js b/scripts/__tests__/validate-repository.test.js index 02d8acf..a78f2e3 100644 --- a/scripts/__tests__/validate-repository.test.js +++ b/scripts/__tests__/validate-repository.test.js @@ -97,4 +97,41 @@ describe('validateRepository', () => { expect(assetErrors).toEqual([expect.stringContaining('assets/clients/acme-avatar.jpeg')]); expect(assetWarnings).toEqual([]); }); + + it('reports a team member citing a missing tool slug', () => { + write('team/bob.md', `${TEAM_MEMBER.replace(/---\n$/, '')}enjeu: sc +contribution: + fr: Contribution + en: Contribution +tools: + - hubspot + - ghost-tool +--- +`); + + const { failures } = validateRepository(repoRoot); + + expect(failures).toEqual([ + { + file: 'team/bob.md', + schemaErrors: [], + crossRefErrors: [ + { path: ['tools'], message: '"ghost-tool" does not match any tool slug' }, + ], + }, + ]); + }); + + it('accepts a team member whose tools are all existing tool slugs', () => { + write('team/bob.md', `${TEAM_MEMBER.replace(/---\n$/, '')}enjeu: sc +contribution: + fr: Contribution + en: Contribution +tools: + - hubspot +--- +`); + + expect(validateRepository(repoRoot).failures).toEqual([]); + }); }); diff --git a/scripts/schemas/team-member.schema.js b/scripts/schemas/team-member.schema.js index 44d9649..6f3485f 100644 --- a/scripts/schemas/team-member.schema.js +++ b/scripts/schemas/team-member.schema.js @@ -2,6 +2,11 @@ import { z } from 'zod'; const bilingualString = z.object({ fr: z.string().min(1), en: z.string().min(1) }); +// The six enjeu keys, as defined by the enjeu spec (ADR 0002). Kept inline so +// this type does not depend on the enjeu schema; whether the key names an +// existing enjeu file is a cross-reference check, not a schema rule. +export const ENJEU_KEYS = ['rp', 'se', 'sc', 'cx', 'sy', 'ai']; + export const teamMemberSchema = z.object({ name: z.string().min(1), role: bilingualString, @@ -17,6 +22,28 @@ export const teamMemberSchema = z.object({ active: z.boolean().optional(), featuredOnAboutUs: z.boolean().optional(), color: z.enum(['yellow', 'coral', 'sky']).optional(), + // Expert fields: all optional, absent for people who are not enjeu experts. + enjeu: z.enum(ENJEU_KEYS).optional(), + contribution: z.object({ + fr: z.string().trim().min(1).max(100), + en: z.string().trim().min(1).max(100), + }).optional(), + tools: z.array(z.string().min(1)).min(1).max(8).refine( + (slugs) => new Set(slugs).size === slugs.length, + { message: 'tools must not contain duplicates' }, + ).optional(), + previousCompanies: z.array(z.string().trim().min(1).max(30)).min(1).max(3).optional(), +}).superRefine((member, ctx) => { + if (member.enjeu === undefined) return; + for (const field of ['contribution', 'tools']) { + if (member[field] === undefined) { + ctx.addIssue({ + code: 'custom', + path: [field], + message: `${field} is required when enjeu is set`, + }); + } + } }); export const validateTeamMember = (data) => teamMemberSchema.safeParse(data); diff --git a/scripts/validate-repository.js b/scripts/validate-repository.js index 046c4b3..a0333e1 100644 --- a/scripts/validate-repository.js +++ b/scripts/validate-repository.js @@ -38,6 +38,13 @@ const validateCrossRefs = (type, data, resolver) => { }); } } + if (type === 'team-member') { + (data.tools ?? []).forEach((t) => { + if (!resolver.isValidToolSlug(t)) { + errors.push({ path: ['tools'], message: `"${t}" does not match any tool slug` }); + } + }); + } if (type === 'job' && data.hiringContact && !resolver.isValidTeamSlug(data.hiringContact)) { errors.push({ path: ['hiringContact'], From 873ed2e1639092e18bdfcba1532e9fbc5548fdf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?je=CC=81ro=CC=82me=20boileux?= Date: Wed, 21 Oct 2026 12:53:45 +0200 Subject: [PATCH 2/4] fix(team): guard non-array tools and cap raw text lengths Refs #95 --- scripts/__tests__/schemas.test.js | 13 ++++++++++++- scripts/__tests__/validate-repository.test.js | 13 +++++++++++++ scripts/schemas/team-member.schema.js | 7 ++++--- scripts/validate-repository.js | 4 +++- 4 files changed, 32 insertions(+), 5 deletions(-) diff --git a/scripts/__tests__/schemas.test.js b/scripts/__tests__/schemas.test.js index 223c75a..ef65d65 100644 --- a/scripts/__tests__/schemas.test.js +++ b/scripts/__tests__/schemas.test.js @@ -147,7 +147,18 @@ describe('team-member expert fields', () => { }); it('rejects duplicate tools', () => { - expect(fails({ ...expert, tools: ['hubspot', 'hubspot'] })).toBe(true); + const data = { ...expert, tools: ['hubspot', 'hubspot'] }; + expect(fails(data)).toBe(true); + expect(messages(data)).toContain('tools must not contain duplicates'); + }); + + it('rejects a blank contribution or previous company', () => { + expect(fails({ ...expert, contribution: { fr: ' ', en: 'Contribution' } })).toBe(true); + expect(fails({ ...expert, previousCompanies: [' '] })).toBe(true); + }); + + it('applies the contribution cap to the raw text', () => { + expect(fails({ ...expert, contribution: { fr: `${'x'.repeat(100)} `, en: 'ok' } })).toBe(true); }); it('accepts 1 to 3 previous companies and rejects 0 or 4', () => { diff --git a/scripts/__tests__/validate-repository.test.js b/scripts/__tests__/validate-repository.test.js index a78f2e3..6e816e7 100644 --- a/scripts/__tests__/validate-repository.test.js +++ b/scripts/__tests__/validate-repository.test.js @@ -134,4 +134,17 @@ tools: expect(validateRepository(repoRoot).failures).toEqual([]); }); + + it('reports a schema error, not a crash, when tools is not an array', () => { + write('team/bob.md', `${TEAM_MEMBER.replace(/---\n$/, '')}tools: hubspot +--- +`); + + const { failures } = validateRepository(repoRoot); + + expect(failures).toHaveLength(1); + expect(failures[0].file).toBe('team/bob.md'); + expect(failures[0].schemaErrors).toEqual([{ path: ['tools'], message: expect.any(String) }]); + expect(failures[0].crossRefErrors).toEqual([]); + }); }); diff --git a/scripts/schemas/team-member.schema.js b/scripts/schemas/team-member.schema.js index 6f3485f..db9204b 100644 --- a/scripts/schemas/team-member.schema.js +++ b/scripts/schemas/team-member.schema.js @@ -24,15 +24,16 @@ export const teamMemberSchema = z.object({ color: z.enum(['yellow', 'coral', 'sky']).optional(), // Expert fields: all optional, absent for people who are not enjeu experts. enjeu: z.enum(ENJEU_KEYS).optional(), + // Caps apply to the raw text, as published, so no .trim(); \S rejects blanks. contribution: z.object({ - fr: z.string().trim().min(1).max(100), - en: z.string().trim().min(1).max(100), + fr: z.string().regex(/\S/).max(100), + en: z.string().regex(/\S/).max(100), }).optional(), tools: z.array(z.string().min(1)).min(1).max(8).refine( (slugs) => new Set(slugs).size === slugs.length, { message: 'tools must not contain duplicates' }, ).optional(), - previousCompanies: z.array(z.string().trim().min(1).max(30)).min(1).max(3).optional(), + previousCompanies: z.array(z.string().regex(/\S/).max(30)).min(1).max(3).optional(), }).superRefine((member, ctx) => { if (member.enjeu === undefined) return; for (const field of ['contribution', 'tools']) { diff --git a/scripts/validate-repository.js b/scripts/validate-repository.js index a0333e1..09b8da9 100644 --- a/scripts/validate-repository.js +++ b/scripts/validate-repository.js @@ -39,7 +39,9 @@ const validateCrossRefs = (type, data, resolver) => { } } if (type === 'team-member') { - (data.tools ?? []).forEach((t) => { + // A non-array `tools` is already a schema error; skip the cross-reference. + const tools = Array.isArray(data.tools) ? data.tools.filter((t) => typeof t === 'string') : []; + tools.forEach((t) => { if (!resolver.isValidToolSlug(t)) { errors.push({ path: ['tools'], message: `"${t}" does not match any tool slug` }); } From 267e5701b53da97f68b2ff5ec9006b70fa93b0d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?je=CC=81ro=CC=82me=20boileux?= Date: Wed, 21 Oct 2026 16:16:56 +0200 Subject: [PATCH 3/4] refactor(team): rename enjeu field to challenge Refs #95, #110 --- scripts/__tests__/schemas.test.js | 26 +++++++++---------- scripts/__tests__/validate-repository.test.js | 4 +-- scripts/schemas/team-member.schema.js | 16 ++++++------ 3 files changed, 23 insertions(+), 23 deletions(-) diff --git a/scripts/__tests__/schemas.test.js b/scripts/__tests__/schemas.test.js index ef65d65..47a62c1 100644 --- a/scripts/__tests__/schemas.test.js +++ b/scripts/__tests__/schemas.test.js @@ -78,7 +78,7 @@ describe('team-member schema', () => { describe('team-member expert fields', () => { const expert = { ...validMember, - enjeu: 'sc', + challenge: 'sc', contribution: { fr: 'Contribution', en: 'Contribution EN' }, tools: ['hubspot', 'qobra'], previousCompanies: ['Qonto'], @@ -94,35 +94,35 @@ describe('team-member expert fields', () => { expect(validateTeamMember(expert).success).toBe(true); }); - it('accepts every enjeu key', () => { - for (const enjeu of ['rp', 'se', 'sc', 'cx', 'sy', 'ai']) { - expect(validateTeamMember({ ...expert, enjeu }).success).toBe(true); + it('accepts every challenge key', () => { + for (const challenge of ['rp', 'se', 'sc', 'cx', 'sy', 'ai']) { + expect(validateTeamMember({ ...expert, challenge }).success).toBe(true); } }); - it('rejects an unknown enjeu key', () => { - expect(fails({ ...expert, enjeu: 'xx' })).toBe(true); + it('rejects an unknown challenge key', () => { + expect(fails({ ...expert, challenge: 'xx' })).toBe(true); }); - it('rejects enjeu without contribution', () => { + it('rejects challenge without contribution', () => { const { contribution, ...rest } = expert; expect(fails(rest)).toBe(true); - expect(messages(rest)).toContain('contribution is required when enjeu is set'); + expect(messages(rest)).toContain('contribution is required when challenge is set'); }); - it('rejects enjeu without tools', () => { + it('rejects challenge without tools', () => { const { tools, ...rest } = expert; expect(fails(rest)).toBe(true); - expect(messages(rest)).toContain('tools is required when enjeu is set'); + expect(messages(rest)).toContain('tools is required when challenge is set'); }); - it('accepts enjeu without previousCompanies', () => { + it('accepts challenge without previousCompanies', () => { const { previousCompanies, ...rest } = expert; expect(validateTeamMember(rest).success).toBe(true); }); - it('accepts contribution, tools and previousCompanies without enjeu', () => { - const { enjeu, ...rest } = expert; + it('accepts contribution, tools and previousCompanies without challenge', () => { + const { challenge, ...rest } = expert; expect(validateTeamMember(rest).success).toBe(true); }); diff --git a/scripts/__tests__/validate-repository.test.js b/scripts/__tests__/validate-repository.test.js index 6e816e7..ce7b5d3 100644 --- a/scripts/__tests__/validate-repository.test.js +++ b/scripts/__tests__/validate-repository.test.js @@ -99,7 +99,7 @@ describe('validateRepository', () => { }); it('reports a team member citing a missing tool slug', () => { - write('team/bob.md', `${TEAM_MEMBER.replace(/---\n$/, '')}enjeu: sc + write('team/bob.md', `${TEAM_MEMBER.replace(/---\n$/, '')}challenge: sc contribution: fr: Contribution en: Contribution @@ -123,7 +123,7 @@ tools: }); it('accepts a team member whose tools are all existing tool slugs', () => { - write('team/bob.md', `${TEAM_MEMBER.replace(/---\n$/, '')}enjeu: sc + write('team/bob.md', `${TEAM_MEMBER.replace(/---\n$/, '')}challenge: sc contribution: fr: Contribution en: Contribution diff --git a/scripts/schemas/team-member.schema.js b/scripts/schemas/team-member.schema.js index db9204b..000f52d 100644 --- a/scripts/schemas/team-member.schema.js +++ b/scripts/schemas/team-member.schema.js @@ -2,10 +2,10 @@ import { z } from 'zod'; const bilingualString = z.object({ fr: z.string().min(1), en: z.string().min(1) }); -// The six enjeu keys, as defined by the enjeu spec (ADR 0002). Kept inline so -// this type does not depend on the enjeu schema; whether the key names an -// existing enjeu file is a cross-reference check, not a schema rule. -export const ENJEU_KEYS = ['rp', 'se', 'sc', 'cx', 'sy', 'ai']; +// The six challenge keys, as defined by the challenge spec (ADR 0002). Kept +// inline so this type does not depend on the challenge schema; whether the key +// names an existing challenge file is a cross-reference check, not a schema rule. +export const CHALLENGE_KEYS = ['rp', 'se', 'sc', 'cx', 'sy', 'ai']; export const teamMemberSchema = z.object({ name: z.string().min(1), @@ -22,8 +22,8 @@ export const teamMemberSchema = z.object({ active: z.boolean().optional(), featuredOnAboutUs: z.boolean().optional(), color: z.enum(['yellow', 'coral', 'sky']).optional(), - // Expert fields: all optional, absent for people who are not enjeu experts. - enjeu: z.enum(ENJEU_KEYS).optional(), + // Expert fields: all optional, absent for people who are not challenge experts. + challenge: z.enum(CHALLENGE_KEYS).optional(), // Caps apply to the raw text, as published, so no .trim(); \S rejects blanks. contribution: z.object({ fr: z.string().regex(/\S/).max(100), @@ -35,13 +35,13 @@ export const teamMemberSchema = z.object({ ).optional(), previousCompanies: z.array(z.string().regex(/\S/).max(30)).min(1).max(3).optional(), }).superRefine((member, ctx) => { - if (member.enjeu === undefined) return; + if (member.challenge === undefined) return; for (const field of ['contribution', 'tools']) { if (member[field] === undefined) { ctx.addIssue({ code: 'custom', path: [field], - message: `${field} is required when enjeu is set`, + message: `${field} is required when challenge is set`, }); } } From 0c8652329fa45265cea0377495ad72ec9d68deb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?je=CC=81ro=CC=82me=20boileux?= Date: Wed, 21 Oct 2026 16:46:41 +0200 Subject: [PATCH 4/4] refactor(team): import CHALLENGE_KEYS from the challenge schema Refs #95 --- scripts/schemas/team-member.schema.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/scripts/schemas/team-member.schema.js b/scripts/schemas/team-member.schema.js index 000f52d..9907b9e 100644 --- a/scripts/schemas/team-member.schema.js +++ b/scripts/schemas/team-member.schema.js @@ -1,12 +1,8 @@ import { z } from 'zod'; +import { CHALLENGE_KEYS } from './challenge.schema.js'; const bilingualString = z.object({ fr: z.string().min(1), en: z.string().min(1) }); -// The six challenge keys, as defined by the challenge spec (ADR 0002). Kept -// inline so this type does not depend on the challenge schema; whether the key -// names an existing challenge file is a cross-reference check, not a schema rule. -export const CHALLENGE_KEYS = ['rp', 'se', 'sc', 'cx', 'sy', 'ai']; - export const teamMemberSchema = z.object({ name: z.string().min(1), role: bilingualString,