// © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) // Gardes des sources de l'interface, lues dans l'arbre d'analyse et non dans // le texte : chaque composant passe par parse de svelte/compiler, chaque // module JavaScript par le même analyseur, enveloppé dans un `; try { return { texte, ast: parse(texte, { modern: true }).module.content }; } catch (erreur) { throw new Error(`${chemin} : analyse impossible (${erreur.code ?? erreur.name})`, { cause: erreur }); } } // Parcourt un arbre d'analyse en profondeur, dans l'ordre des champs : visite // chaque nœud typé avec la liste de ses ancêtres typés, du plus lointain au // plus proche. Les champs de service des analyseurs ne sont pas descendus. const CHAMPS_IGNORES = new Set(['parent', 'loc', 'metadata', 'name_loc', 'range']); function parcourir(noeud, visiter, ancetres = []) { if (noeud === null || typeof noeud !== 'object') return; if (Array.isArray(noeud)) { for (const enfant of noeud) parcourir(enfant, visiter, ancetres); return; } const type = typeof noeud.type === 'string'; if (type) visiter(noeud, ancetres); const suivants = type ? [...ancetres, noeud] : ancetres; for (const [champ, valeur] of Object.entries(noeud)) { if (!CHAMPS_IGNORES.has(champ) && valeur !== null && typeof valeur === 'object') { parcourir(valeur, visiter, suivants); } } } // Les parties JavaScript et le gabarit d'un composant, sans sa feuille. const partiesCode = (ast) => [ast.module, ast.instance, ast.fragment]; // Les chaînes littérales qu'une expression peut rendre telle quelle : un // littéral, les morceaux fixes d'un gabarit de chaîne, les deux branches // d'une condition, les opérandes d'une concaténation et d'un « || » ou « ?? », // le second d'un « && », le dernier terme d'une séquence. Un appel, un // membre, un identifiant ne rendent aucun littéral : t('cle') n'affiche pas // sa clé. Rend [{ valeur, debut }]. function litterauxRendus(expression) { if (expression === null || typeof expression !== 'object') return []; switch (expression.type) { case 'Literal': return typeof expression.value === 'string' ? [{ valeur: expression.value, debut: expression.start }] : []; case 'TemplateLiteral': return [ { valeur: expression.quasis.map((q) => q.value.cooked ?? q.value.raw).join(''), debut: expression.start }, ...expression.expressions.flatMap(litterauxRendus), ]; case 'ConditionalExpression': return [...litterauxRendus(expression.consequent), ...litterauxRendus(expression.alternate)]; case 'LogicalExpression': return expression.operator === '&&' ? litterauxRendus(expression.right) : [...litterauxRendus(expression.left), ...litterauxRendus(expression.right)]; case 'BinaryExpression': return expression.operator === '+' ? [...litterauxRendus(expression.left), ...litterauxRendus(expression.right)] : []; case 'SequenceExpression': return litterauxRendus(expression.expressions.at(-1)); default: return []; } } // Les morceaux de la valeur d'un attribut du gabarit : { statique, debut } // pour un texte, { expression } pour une expression. Un attribut sans valeur // n'en a aucun. function morceauxAttribut(attribut) { const valeur = attribut.value; if (valeur === true || valeur === undefined || valeur === null) return []; const morceaux = Array.isArray(valeur) ? valeur : [valeur]; return morceaux.map((m) => m.type === 'Text' ? { statique: m.data, debut: m.start } : { expression: m.expression, debut: m.start }, ); } // Le plus proche ancêtre élément d'un nœud du gabarit, ou null. const ELEMENTS = new Set(['RegularElement', 'SvelteElement', 'Component', 'SvelteComponent', 'SlotElement']); function elementParent(ancetres) { for (let i = ancetres.length - 1; i >= 0; i -= 1) if (ELEMENTS.has(ancetres[i].type)) return ancetres[i]; return null; } // Vrai quand le nœud est dans la valeur d'un attribut ou d'une directive. const DANS_UN_ATTRIBUT = new Set(['Attribute', 'StyleDirective', 'SpreadAttribute', 'BindDirective', 'ClassDirective']); const dansUnAttribut = (ancetres) => ancetres.some((a) => DANS_UN_ATTRIBUT.has(a.type)); // --- Garde 1 : les chaînes visibles en dur (§ 14.6) --------------------------- const LETTRE = /\p{L}/u; // Attributs qu'une technologie d'assistance annonce, ou que la page montre. const ATTRIBUTS_ANNONCES = new Set(['aria-label', 'aria-roledescription', 'title', 'placeholder', 'alt']); // Éléments du SVG dont le contenu est un texte : tout contenu statique non // blanc y est une chaîne visible, ponctuation comprise. const TEXTES_SVG = new Set(['title', 'desc', 'text', 'tspan']); // Une chaîne entre guillemets d'une valeur CSS. const CHAINE_CSS = /(["'])((?:\\.|(?!\1).)*)\1/g; // Vrai quand un texte placé dans cet élément est une chaîne visible : une // lettre partout, tout caractère non blanc dans un texte du SVG. function texteVisible(texte, element) { if (element !== null && element.type === 'RegularElement' && TEXTES_SVG.has(element.name)) { return texte.trim() !== ''; } return LETTRE.test(texte); } // Vrai quand une valeur de « content » porte une lettre dans une chaîne. const contenuLettre = (valeur) => [...valeur.matchAll(CHAINE_CSS)].some(([, , chaine]) => LETTRE.test(chaine)); // Relevé des chaînes visibles écrites dans les composants de src/interface : // texte statique, attribut annoncé, contenu d'un texte du SVG, déclaration // « content » lettrée, littéral que rend une expression du gabarit. function releverChainesEnDur(racine) { const chemins = exigerNonVide(sources(racine, ['src/interface'], ['.svelte']), 'chaînes en dur'); const releves = []; for (const chemin of chemins) { const { texte, ast } = analyserComposant(racine, chemin); const relever = (debut, motif, valeur) => releves.push({ chemin, ligne: ligneDe(texte, debut), texte: `${motif} ${extrait(valeur)}` }); parcourir(ast.fragment, (noeud, ancetres) => { if (noeud.type === 'Text' && !dansUnAttribut(ancetres)) { const element = elementParent(ancetres); if (texteVisible(noeud.data, element)) relever(noeud.start, 'texte', noeud.data); } else if (noeud.type === 'ExpressionTag' && !dansUnAttribut(ancetres)) { const element = elementParent(ancetres); for (const { valeur, debut } of litterauxRendus(noeud.expression)) { if (texteVisible(valeur, element)) relever(debut, 'expression littérale', valeur); } } else if (noeud.type === 'Attribute' && ATTRIBUTS_ANNONCES.has(noeud.name)) { for (const morceau of morceauxAttribut(noeud)) { const valeurs = 'statique' in morceau ? [{ valeur: morceau.statique, debut: morceau.debut }] : litterauxRendus(morceau.expression); for (const { valeur, debut } of valeurs) { if (LETTRE.test(valeur)) relever(debut, `attribut ${noeud.name}`, valeur); } } } else if (noeud.type === 'Attribute' && noeud.name === 'style') { for (const morceau of morceauxAttribut(noeud)) { if ('statique' in morceau && /content\s*:/.test(morceau.statique) && contenuLettre(morceau.statique)) { relever(morceau.debut, 'content', morceau.statique); } } } }); parcourir(ast.css, (noeud) => { if (noeud.type === 'Declaration' && noeud.property === 'content' && contenuLettre(noeud.value)) { relever(noeud.start, 'content', noeud.value); } }); } return trier(releves); } // Relevé trié par chemin, puis par ligne, puis par texte ; rendu en chaînes. function trier(releves) { return releves .sort((a, b) => a.chemin < b.chemin ? -1 : a.chemin > b.chemin ? 1 : a.ligne - b.ligne || (a.texte < b.texte ? -1 : a.texte > b.texte ? 1 : 0), ) .map(({ chemin, ligne, texte }) => `${chemin}:${ligne} ${texte}`); } // --- Garde 2 : les clés littérales de t existent (§ 14.6, § 19.11) ------------ // Les clés de toutes les tables assemblées, celles qu'App donne à t. const CLES = new Set(creerTraduction([...TABLES_APPLICATION, ...TABLES_INTERFACE]).cles()); // La clé littérale du premier argument d'un appel de t — t(…) ou x.t(…) —, ou // null : une clé composée à l'exécution échappe à cette garde. function cleLitterale(appel) { const appele = appel.callee; const estT = (appele.type === 'Identifier' && appele.name === 't') || (appele.type === 'MemberExpression' && !appele.computed && appele.property.name === 't'); if (!estT) return null; const [premier] = appel.arguments; if (premier?.type === 'Literal' && typeof premier.value === 'string') return premier.value; if (premier?.type === 'TemplateLiteral' && premier.expressions.length === 0) return premier.quasis[0].value.cooked; return null; } // Relevé des clés littérales passées à t, dans les composants et les modules // de src/interface, qu'aucune table de cles ne porte. Lève quand aucun fichier // n'est examiné, et quand aucune clé littérale ne l'est : un accesseur renommé // ou une forme d'appel que la garde ne reconnaît plus la viderait sans bruit. function releverClesAbsentes(racine, cles) { const chemins = exigerNonVide(sources(racine, ['src/interface'], ['.svelte', '.js']), 'clés de t'); const releves = []; let nbCles = 0; for (const chemin of chemins) { const { texte, ast } = chemin.endsWith('.svelte') ? analyserComposant(racine, chemin) : analyserModule(racine, chemin); const parties = chemin.endsWith('.svelte') ? partiesCode(ast) : [ast]; parcourir(parties, (noeud) => { if (noeud.type !== 'CallExpression') return; const cle = cleLitterale(noeud); if (cle === null) return; nbCles += 1; if (!cles.has(cle)) releves.push({ chemin, ligne: ligneDe(texte, noeud.start), texte: `clé absente ${extrait(cle)}` }); }); } assert.ok(nbCles > 0, 'clés de t : aucune clé examinée'); return trier(releves); } // --- Garde 3 : le contenu des tables de traduction ---------------------------- // Un texte comparé sans égard à la casse ni aux accents ; l'apostrophe // typographique se lit comme la droite. const normaliser = (texte) => texte.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(/[\u{2019}\u{02BC}]/gu, "'"); // Mots que le logiciel n'écrit pas, cherchés dans le texte normalisé. Un // « écart » nu n'existe pas au glossaire (§ 5.5) : seuls l'écart // d'itinéraire et l'écart au plafond a priori y figurent ; « écarté », du // verbe, n'en est pas un. const MOTS_INTERDITS = [ ['conforme', /conform/], ['écart hors du glossaire', /\becarts?\b(?!\s+(?:d'itineraire|au plafond a priori))/], ['satisfaction', /satisfaction/], ['écart-type', /\becarts?[\s-]+types?\b/], ['variance', /\bvariances?\b/], ['équité', /\bequite\b/], ['score', /\bscores?\b/], ['peut mieux faire', /\bpeut mieux faire\b/], ['manque minimal', /\bmanques?\s+minima/], ['récupérable', /recuperable/], ['évitable', /(?(${source});`, { modern: true }).module.content; } catch { return source; } const morceaux = []; parcourir(programme, (noeud) => { if (noeud.type === 'Literal' && typeof noeud.value === 'string' && !FORME_CLE.test(noeud.value)) morceaux.push(noeud.value); if (noeud.type === 'TemplateElement') morceaux.push(noeud.value.cooked ?? noeud.value.raw); }); return morceaux.join(' '); } // Relevé « clé : motif « extrait » » des valeurs des tables : chaque chaîne, // et le texte de chaque fonction de détails (texteDeFonction). localiser(cle), // quand il est donné, rend { chemin, ligne } de la clé, qui préfixe le relevé // et l'ordonne ; sans lieu, le relevé s'ordonne par clé. Une forme qu'une // exception admet est effacée du texte avant que son mot y soit cherché ; suit // une ligne « exception sans objet : nom » par exception qui n'a rien effacé, // dans l'ordre de la liste. function releverContenuTables(tables, localiser = () => null, exceptions = EXCEPTIONS_DE_CONTENU) { const entrees = tables.flatMap((table) => [...table]); assert.ok(entrees.length > 0, 'contenu des tables : aucune entrée examinée'); const releves = []; const servies = new Set(); // Le texte normalisé d'une valeur, privé des formes que les exceptions du // mot admettent pour cette clé. const sansFormesAdmises = (normale, mot, cle) => exceptions .filter((exception) => exception.mot === mot && exception.cles.test(cle)) .reduce((texte, exception) => { const reste = texte.replace(exception.forme, ' '); if (reste !== texte) servies.add(exception); return reste; }, normale); for (const [cle, entree] of entrees) { const valeur = typeof entree === 'function' ? texteDeFonction(entree) : entree; const normale = normaliser(valeur); const { chemin, ligne } = localiser(cle) ?? { chemin: '', ligne: 0 }; const relever = (motif) => releves.push({ chemin, ligne, texte: `${cle} : ${motif} ${extrait(valeur)}` }); for (const [mot, motif] of MOTS_INTERDITS) if (motif.test(sansFormesAdmises(normale, mot, cle))) relever(mot); const pictogramme = valeur.match(PICTOGRAMME); if (pictogramme !== null) { relever(`pictogramme U+${pictogramme[0].codePointAt(0).toString(16).toUpperCase().padStart(4, '0')}`); } } const sansObjet = exceptions.filter((exception) => !servies.has(exception)).map(({ nom }) => `exception sans objet : ${nom}`); return [...trier(releves).map((releve) => releve.replace(/^:0 /, '')), ...sansObjet]; } // { chemin, ligne } de l'entrée [cle, …] dans les tables de libelles/ de // l'application et des écrans, ou null. function localisateur(racine) { const fichiers = sources(racine, ['src/application/libelles', 'src/interface/libelles'], ['.js']).map((chemin) => ({ chemin, texte: readFileSync(join(racine, chemin), 'utf8'), })); return (cle) => { const motif = new RegExp(`\\[\\s*(['"\`])${RegExp.escape(cle)}\\1\\s*,`); for (const { chemin, texte } of fichiers) { const trouve = motif.exec(texte); if (trouve !== null) return { chemin, ligne: ligneDe(texte, trouve.index) }; } return null; }; } // --- Garde 4 : les couleurs (§ 12.7, § 13.3, § 13.5) -------------------------- const FEUILLE_DES_JETONS = 'src/interface/jetons.css'; const JETON_CONFLIT = '--couleur-conflit'; // Les couleurs nommées de CSS, et les couleurs du système : chacune est un // littéral. transparent et currentColor ne portent aucune teinte et restent // admis. const COULEURS_NOMMEES = ( 'aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown ' + 'burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan ' + 'darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid ' + 'darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet ' + 'deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ' + 'ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki ' + 'lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow ' + 'lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen lightskyblue lightslategray ' + 'lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine ' + 'mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise ' + 'mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab ' + 'orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru ' + 'pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown ' + 'seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan ' + 'teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen ' + 'accentcolor accentcolortext activetext buttonborder buttonface buttontext canvas canvastext field ' + 'fieldtext graytext highlight highlighttext linktext mark marktext selecteditem selecteditemtext visitedtext' ).split(' '); const COULEUR_NOMMEE = new RegExp(`(? { if (noeud.type !== 'Declaration') return; const regle = ancetres.findLast((a) => a.type === 'Rule'); const selecteur = regle === undefined ? '' : texte.slice(regle.prelude.start, regle.prelude.end); const prelude = regle === undefined ? null : regle.prelude; trouvees.push({ propriete: noeud.property, valeur: noeud.value, debut: noeud.start, selecteur, prelude }); }); return trouvees; } // Les déclarations d'un attribut style statique : « propriété: valeur » par // point-virgule, positionnées au début du morceau. const declarationsEnLigne = (statique, debut) => statique .split(';') .map((morceau) => /^\s*([\w-]+)\s*:(.*)$/s.exec(morceau)) .filter((d) => d !== null) .map(([, propriete, valeur]) => ({ propriete, valeur, debut })); // Une classe qui nomme l'état de conflit : « conflit », ou un nom en // « -conflit » dont aucun segment ne le nie (« sans-conflit », « non-conflit »). // « conflit-leve » ne finit pas par « -conflit » et ne le nomme pas. const NEGATIONS = new Set(['sans', 'non', 'hors', 'pas', 'aucun']); function classeDeConflit(nom) { const segments = nom.toLowerCase().split('-'); return segments.at(-1) === 'conflit' && !segments.some((segment) => NEGATIONS.has(segment)); } // Vrai quand une déclaration du jeton de conflit est l'une des règles de // conflit du plan : dans un composant de src/interface/plan/, sous une règle // dont CHAQUE sélecteur de la liste porte, dans l'un de ses sélecteurs // composés, une classe de conflit. Seules les classes posées directement sur // un composé comptent : celles qu'un :not(…), un :is(…) ou un :global(…) // enveloppe sont des arguments et ne qualifient pas la règle. function regleDeConflit(chemin, prelude) { if (!chemin.startsWith('src/interface/plan/') || prelude?.type !== 'SelectorList') return false; return prelude.children.every((complexe) => complexe.children.some((relatif) => relatif.selectors.some((simple) => simple.type === 'ClassSelector' && classeDeConflit(simple.name)), ), ); } // Relevé des couleurs : littéral de couleur dans une feuille, un attribut // style ou de couleur, une directive style:, ou une chaîne du script d'un // composant ; déclaration de jeton hors de la feuille des jetons ; lecture du // jeton de conflit hors des règles de conflit du plan. Les composants de // src/interface et les feuilles .css de src/ sont lus, la feuille des jetons // exceptée. function releverCouleurs(racine) { const chemins = exigerNonVide( sources(racine, ['src'], ['.svelte', '.css']).filter((c) => c !== FEUILLE_DES_JETONS && (c.endsWith('.css') || c.startsWith('src/interface/'))), 'couleurs', ); const releves = []; for (const chemin of chemins) { let texte; let decls; const relever = (debut, motif) => releves.push({ chemin, ligne: ligneDe(texte, debut), texte: motif }); // Une déclaration, d'une feuille ou en ligne ; enLigne : hors de toute règle. const examiner = ({ propriete, valeur, debut, selecteur, prelude }, enLigne) => { const couleur = couleurLitterale(valeur); if (couleur !== null) relever(debut, `couleur ${extrait(couleur)} dans ${propriete}`); if (propriete.startsWith('--')) relever(debut, `jeton déclaré ${propriete}`); if (valeur.includes(JETON_CONFLIT) && (enLigne || !regleDeConflit(chemin, prelude))) { relever(debut, `jeton de conflit hors d'une règle de conflit du plan, ${enLigne ? 'en ligne' : extrait(selecteur)}`); } }; if (chemin.endsWith('.css')) { texte = readFileSync(join(racine, chemin), 'utf8'); decls = declarations(parseCss(texte), texte); for (const d of decls) examiner(d, false); } else { const analyse = analyserComposant(racine, chemin); texte = analyse.texte; for (const d of declarations(analyse.ast.css, texte)) examiner(d, false); parcourir(analyse.ast.fragment, (noeud) => { if (noeud.type === 'Attribute' && noeud.name.startsWith('--')) relever(noeud.start, `jeton déclaré ${noeud.name}`); if (noeud.type === 'StyleDirective') { if (noeud.name.startsWith('--')) relever(noeud.start, `jeton déclaré ${noeud.name}`); for (const morceau of morceauxAttribut(noeud)) { const valeurs = 'statique' in morceau ? [morceau.statique] : litterauxRendus(morceau.expression).map((l) => l.valeur); for (const valeur of valeurs) examiner({ propriete: noeud.name.replace(/^--.*/, 'style:'), valeur, debut: noeud.start, selecteur: '' }, true); } } if (noeud.type !== 'Attribute') return; for (const morceau of morceauxAttribut(noeud)) { if (noeud.name === 'style' && 'statique' in morceau) { for (const d of declarationsEnLigne(morceau.statique, morceau.debut)) examiner(d, true); } else if (noeud.name === 'style' || ATTRIBUTS_COULEUR.has(noeud.name)) { const valeurs = 'statique' in morceau ? [{ valeur: morceau.statique, debut: morceau.debut }] : litterauxRendus(morceau.expression); for (const { valeur, debut } of valeurs) { const couleur = couleurLitterale(valeur); if (couleur !== null) relever(debut, `couleur ${extrait(couleur)} dans ${noeud.name}`); if (valeur.includes(JETON_CONFLIT)) relever(debut, "jeton de conflit hors d'une règle de conflit du plan, en ligne"); } } } }); // Une chaîne du script qui est en entier un littéral de couleur, ou qui // nomme le jeton de conflit. parcourir([analyse.ast.module, analyse.ast.instance], (noeud) => { if (noeud.type !== 'Literal' || typeof noeud.value !== 'string') return; const valeur = noeud.value.trim(); if (COULEUR_ENTIERE.test(valeur)) { relever(noeud.start, `couleur ${extrait(valeur)} dans le script`); } if (valeur.includes(JETON_CONFLIT)) relever(noeud.start, "jeton de conflit hors d'une règle de conflit du plan, dans le script"); }); } } return trier(releves); } // Relevé des jetons de couleur de la feuille des jetons qu'un des deux thèmes // ne définit pas : chaque --couleur-…, où qu'il soit déclaré, l'est dans le // bloc clair et dans le bloc sombre. function releverThemes(racine) { const texte = readFileSync(join(racine, FEUILLE_DES_JETONS), 'utf8'); const decls = declarations(parseCss(texte), texte).filter((d) => d.propriete.startsWith('--couleur-')); assert.ok(decls.length > 0, 'thèmes : aucun jeton de couleur examiné'); const theme = (nom) => new RegExp(`\\[\\s*data-theme\\s*=\\s*(["'])${nom}\\1\\s*\\]`); const blocs = { clair: theme('clair'), sombre: theme('sombre') }; const releves = []; const premiere = new Map(); for (const d of decls) if (!premiere.has(d.propriete)) premiere.set(d.propriete, d.debut); for (const [jeton, debut] of premiere) { for (const [nom, motif] of Object.entries(blocs)) { if (!decls.some((d) => d.propriete === jeton && motif.test(d.selecteur))) { releves.push({ chemin: FEUILLE_DES_JETONS, ligne: ligneDe(texte, debut), texte: `${jeton} absent du thème ${nom}` }); } } } return trier(releves); } // --- Garde 5 : balisage et fenêtres du navigateur (§ 13.1, § 2.1) ------------- const PROPRIETES_BALISAGE = new Set(['innerHTML', 'outerHTML', 'insertAdjacentHTML']); const FENETRES = new Set(['alert', 'confirm', 'prompt']); const GLOBAUX = new Set(['window', 'globalThis', 'self']); // Le nom d'un membre non calculé, ou d'un membre calculé par un littéral. const nomDeMembre = (membre) => membre.computed ? (membre.property.type === 'Literal' ? String(membre.property.value) : null) : membre.property.name; // Relevé du balisage injecté, des fenêtres du navigateur, de beforeunload, et, // sous src/interface, de la lecture de .message d'une erreur. Les composants // et les modules de src/ sont lus. function releverBalisageEtFenetres(racine) { const chemins = exigerNonVide(sources(racine, ['src'], ['.svelte', '.js']), 'balisage et fenêtres'); const releves = []; for (const chemin of chemins) { const composant = chemin.endsWith('.svelte'); const { texte, ast } = composant ? analyserComposant(racine, chemin) : analyserModule(racine, chemin); const relever = (debut, motif) => releves.push({ chemin, ligne: ligneDe(texte, debut), texte: motif }); const interface_ = chemin.startsWith('src/interface/'); parcourir(composant ? partiesCode(ast) : [ast], (noeud) => { switch (noeud.type) { case 'HtmlTag': relever(noeud.start, '{@html}'); break; case 'MemberExpression': { const nom = nomDeMembre(noeud); if (PROPRIETES_BALISAGE.has(nom)) relever(noeud.start, nom); if (noeud.object.type === 'Identifier' && noeud.object.name === 'document' && (nom === 'write' || nom === 'writeln')) { relever(noeud.start, `document.${nom}`); } if (interface_ && nom === 'message') relever(noeud.start, '.message'); break; } case 'CallExpression': { const appele = noeud.callee; const nom = appele.type === 'Identifier' ? appele.name : appele.type === 'MemberExpression' && appele.object.type === 'Identifier' && GLOBAUX.has(appele.object.name) ? nomDeMembre(appele) : null; if (FENETRES.has(nom)) relever(noeud.start, `${nom}(`); break; } case 'Literal': if (typeof noeud.value === 'string' && /beforeunload/i.test(noeud.value)) relever(noeud.start, 'beforeunload'); break; case 'TemplateElement': if (/beforeunload/i.test(noeud.value.raw)) relever(noeud.start, 'beforeunload'); break; case 'Identifier': if (/beforeunload/i.test(noeud.name)) relever(noeud.start, 'beforeunload'); break; case 'Attribute': case 'OnDirective': if (/beforeunload/i.test(noeud.name)) relever(noeud.start, 'beforeunload'); break; default: } }); } return trier(releves); } // --- Garde 6 : texte comprimé et animations (§ 7.4, § 19.2) ------------------ // Le motif qu'un nom ou une chaîne nomme, ou null : un mot entier, que ni // lettre ni chiffre ne prolonge, sans égard à la casse — getComputedTextLength, // la mesure du § 7.4, ne nomme pas textLength — ; un nom d'écouteur, // ontransitionend, nomme son événement. const nomme = (texte, motifs) => motifs.find((motif) => new RegExp(`(? { let nom = null; if (noeud.type === 'Identifier' || noeud.type === 'Attribute' || noeud.type === 'OnDirective') nom = noeud.name; else if (noeud.type === 'Literal' && typeof noeud.value === 'string') nom = noeud.value; else if (noeud.type === 'TemplateElement') nom = noeud.value.raw; const motif = nom === null ? null : nomme(nom, motifs); if (motif !== null) trouves.push({ debut: noeud.start, motif }); }); return trouves; } // Le mot qui ouvre une directive d'animation d'un composant — animate, // transition, in, out —, ou null pour tout autre nœud. function nomDeDirective(noeud) { if (noeud.type === 'AnimateDirective') return 'animate'; if (noeud.type !== 'TransitionDirective') return null; if (noeud.intro && !noeud.outro) return 'in'; if (noeud.outro && !noeud.intro) return 'out'; return 'transition'; } // Déclarations d'animation admises : celles qui l'éteignent. const ANIMATION_ETEINTE = /^\s*(?:none|0m?s)\s*$/i; // Relevé de textLength et lengthAdjust sous src/interface et src/geometrie, de // transitionend et animationend sous src/interface, et de toute transition // ou animation de l'interface : déclaration transition…, animation… ou // @keyframes d'une feuille, directive transition:, in:, out: ou animate: // d'un composant. Un état qui attendrait la fin d'une transition ne // s'atteindrait plus sous la feuille qui les annule (§ 19.2). function releverTexteEtAnimation(racine) { const chemins = exigerNonVide( sources(racine, ['src/interface', 'src/geometrie'], ['.svelte', '.js', '.css']), 'texte et animation', ); const releves = []; for (const chemin of chemins) { const interface_ = chemin.startsWith('src/interface/'); const motifs = interface_ ? ['textLength', 'lengthAdjust', 'transitionend', 'animationend'] : ['textLength', 'lengthAdjust']; let texte; let parties = []; let css = null; if (chemin.endsWith('.css')) { texte = readFileSync(join(racine, chemin), 'utf8'); css = parseCss(texte); } else if (chemin.endsWith('.svelte')) { const analyse = analyserComposant(racine, chemin); texte = analyse.texte; parties = partiesCode(analyse.ast); css = analyse.ast.css; } else { const analyse = analyserModule(racine, chemin); texte = analyse.texte; parties = [analyse.ast]; } const relever = (debut, motif) => releves.push({ chemin, ligne: ligneDe(texte, debut), texte: motif }); for (const { debut, motif } of nomsReleves(parties, motifs)) relever(debut, motif); if (!interface_) continue; parcourir(parties, (noeud) => { const directive = nomDeDirective(noeud); if (directive !== null) relever(noeud.start, `directive ${directive}:${noeud.name}`); }); parcourir(css, (noeud) => { const anime = noeud.type === 'Declaration' && /^(?:transition|animation)(?:-|$)/.test(noeud.property); if (anime && !ANIMATION_ETEINTE.test(noeud.value)) { relever(noeud.start, `${noeud.property}: ${noeud.value}`); } if (noeud.type === 'Atrule' && /keyframes$/i.test(noeud.name)) relever(noeud.start, `@${noeud.name}`); }); } return trier(releves); } // --- Garde 7 : le câblage (§ 12.9, § 13.3) ------------------------------------ const TRIS_ET_REDUCTIONS = new Set(['sort', 'toSorted', 'reduce', 'reduceRight']); // Les mesures de la table d'évaluation, que src/application/evaluation.js // seul importe (§ 12.9). const MESURES = new Set([ 'mesurer', 'plafondsRealises', 'plafondsAPriori', 'troisChiffres', 'manques', 'ecartsAuPlafondAPriori', 'minimumAtteint', ]); const EVALUATION = 'src/application/evaluation.js'; // Relevé des calculs écrits dans un composant de src/interface — Math., un tri, // une réduction —, et des imports d'une mesure dans un module de // src/application autre qu'evaluation.js : spécificateur d'import ou de // réexportation, ou membre d'un espace de noms importé. function releverCablage(racine) { const composants = exigerNonVide(sources(racine, ['src/interface'], ['.svelte']), 'câblage des composants'); const modules = exigerNonVide(sources(racine, ['src/application'], ['.js']), 'imports des mesures'); const releves = []; for (const chemin of composants) { const { texte, ast } = analyserComposant(racine, chemin); parcourir(partiesCode(ast), (noeud) => { if (noeud.type === 'MemberExpression' && noeud.object.type === 'Identifier' && noeud.object.name === 'Math') { releves.push({ chemin, ligne: ligneDe(texte, noeud.start), texte: `Math.${nomDeMembre(noeud) ?? '[…]'}` }); } if (noeud.type === 'CallExpression' && noeud.callee.type === 'MemberExpression') { const nom = nomDeMembre(noeud.callee); if (TRIS_ET_REDUCTIONS.has(nom)) releves.push({ chemin, ligne: ligneDe(texte, noeud.start), texte: `.${nom}(` }); } }); } for (const chemin of modules) { if (chemin === EVALUATION) continue; const { texte, ast } = analyserModule(racine, chemin); const espaces = new Set(); parcourir(ast, (noeud, ancetres) => { if (noeud.type === 'ImportNamespaceSpecifier') espaces.add(noeud.local.name); // Une réexportation porte la source de sa déclaration ; un // « export { nom } » d'une valeur locale n'importe rien. const reexporte = noeud.type === 'ExportSpecifier' && (ancetres.at(-1)?.source ?? null) !== null; const importe = noeud.type === 'ImportSpecifier' ? noeud.imported : reexporte ? noeud.local : null; const nom = importe === null ? null : (importe.name ?? importe.value); if (MESURES.has(nom)) releves.push({ chemin, ligne: ligneDe(texte, noeud.start), texte: `import de ${nom}` }); }); parcourir(ast, (noeud) => { if ( noeud.type === 'MemberExpression' && noeud.object.type === 'Identifier' && espaces.has(noeud.object.name) && MESURES.has(nomDeMembre(noeud)) ) { releves.push({ chemin, ligne: ligneDe(texte, noeud.start), texte: `import de ${nomDeMembre(noeud)}` }); } }); } return trier(releves); } // --- Garde 8 : les sélecteurs élagués (§ 13.3, § 14.2) ------------------------ // Relevé des avertissements css_unused_selector de chaque composant de // src/interface, compilé par compile de svelte/compiler sans greffon : une // règle que le compilateur élague ne peindrait jamais l'état qu'elle vise. function releverSelecteursElagues(racine) { const chemins = exigerNonVide(sources(racine, ['src/interface'], ['.svelte']), 'sélecteurs élagués'); const releves = []; for (const chemin of chemins) { const texte = readFileSync(join(racine, chemin), 'utf8'); const { warnings } = compile(texte, { filename: chemin, generate: false }); for (const avertissement of warnings) { if (avertissement.code !== 'css_unused_selector') continue; const debut = avertissement.start?.character ?? 0; releves.push({ chemin, ligne: avertissement.start?.line ?? ligneDe(texte, debut), texte: `sélecteur élagué ${extrait(texte.slice(debut, avertissement.end?.character ?? debut))}`, }); } } return trier(releves); } // --- Arbres d'épreuve --------------------------------------------------------- // Écrit dans un répertoire temporaire l'arbre { chemin relatif: contenu }, le // passe à examiner, puis l'efface ; rend ce que rend examiner. function avecArbre(fichiers, examiner) { const racine = mkdtempSync(join(tmpdir(), 'sources-interface-')); try { for (const [chemin, contenu] of Object.entries(fichiers)) { mkdirSync(dirname(join(racine, chemin)), { recursive: true }); writeFileSync(join(racine, chemin), contenu); } return examiner(racine); } finally { rmSync(racine, { recursive: true, force: true }); } } // Exige un relevé vide ; le message d'échec en donne chaque ligne. function exigerVide(releves, garde) { assert.ok(releves.length === 0, `${garde} : ${releves.length} relevé(s)\n${releves.join('\n')}`); } // Un composant d'épreuve : son script, son gabarit, sa feuille. const composant = ({ script = '', gabarit = '', style = '' }) => `${script === '' ? '' : `\n`}${gabarit}\n${style === '' ? '' : `\n`}`; // Une feuille des jetons minimale, ses deux thèmes complets. const JETONS_EPREUVE = [ ':root, :root[data-theme="clair"] { --couleur-a: #ffffff; --couleur-conflit: #b3261e; }', ":root[data-theme='sombre'] { --couleur-a: #000000; --couleur-conflit: #ff8a80; }", '', ].join('\n'); describe('sources de l’interface : les chaînes visibles en dur (§ 14.6)', () => { test('aucun composant de src/interface n’écrit de chaîne visible', () => { exigerVide(releverChainesEnDur(RACINE), 'chaînes en dur'); }); test('la garde relève un bouton « Modifier » et chaque forme refusée, à toute profondeur, et pas leurs voisines', () => { const fichiers = { 'src/interface/A.svelte': '\n', 'src/interface/plan/sous/B.svelte': composant({ script: " const t = (cle) => cle;\n let x = $state(true);", gabarit: [ '

{t(\'k.titre\')} — {42}

', '…{\'·\'} {t(\'k.svg\')}', '', '{`${x} places`}{x ?? \'Aucun\'}{x && \'Plein\'}', '{x} / {x}', '.', '', ].join('\n'), style: ' .vide::after { content: "Fin"; }\n .vide::before { content: counter(rang) ". "; }', }), 'src/interface/C.test.js': "export const a = '';\n", 'src/interface/D.navigateur.test.js': "export const a = '';\n", }; assert.deepEqual(avecArbre(fichiers, releverChainesEnDur), [ 'src/interface/A.svelte:1 texte « Modifier »', 'src/interface/plan/sous/B.svelte:5 attribut aria-label « Oui »', 'src/interface/plan/sous/B.svelte:5 attribut title « Titre du plan »', 'src/interface/plan/sous/B.svelte:6 expression littérale « · »', 'src/interface/plan/sous/B.svelte:6 texte « … »', 'src/interface/plan/sous/B.svelte:7 attribut alt « Image »', 'src/interface/plan/sous/B.svelte:7 attribut aria-roledescription « table »', 'src/interface/plan/sous/B.svelte:7 attribut placeholder « places »', 'src/interface/plan/sous/B.svelte:8 expression littérale « Aucun »', 'src/interface/plan/sous/B.svelte:8 expression littérale « Plein »', 'src/interface/plan/sous/B.svelte:8 expression littérale « places »', 'src/interface/plan/sous/B.svelte:10 content « content: \'Note\' »', 'src/interface/plan/sous/B.svelte:13 content « "Fin" »', ]); }); test('un arbre sans composant fait échouer la garde', () => { assert.throws( () => avecArbre({ 'src/interface/a.js': 'export const a = 1;\n' }, releverChainesEnDur), /chaînes en dur : aucun fichier examiné/, ); }); }); describe('sources de l’interface : les clés littérales de t existent (§ 14.6, § 19.11)', () => { test('chaque clé littérale passée à t dans src/interface existe dans les tables assemblées', () => { exigerVide(releverClesAbsentes(RACINE, CLES), 'clés de t'); }); test('la garde relève une clé absente, dans un composant comme dans un module, et pas une clé présente ni composée', () => { const cles = new Set(['k.present']); const fichiers = { 'src/interface/A.svelte': composant({ script: " import { getContext } from 'svelte';\n const t = getContext('t');\n const a = t('k.absente');", gabarit: "

{t('k.present')}

\n

{t(`k.gabarit`)}

\n

{t(`k.${a}`)}

\n

{traduction.t(\"k.membre\")}

", }), 'src/interface/plan/modele.js': [ '// t(\'k.commentaire\') ne compte pas.', "export const cle = (t) => t('k.module', { n: 1 });", "export const autre = (texte) => texte('k.texte');", '', ].join('\n'), 'src/interface/plan/modele.test.js': "t('k.epreuve');\n", }; assert.deepEqual(avecArbre(fichiers, (racine) => releverClesAbsentes(racine, cles)), [ 'src/interface/A.svelte:4 clé absente « k.absente »', 'src/interface/A.svelte:7 clé absente « k.gabarit »', 'src/interface/A.svelte:9 clé absente « k.membre »', 'src/interface/plan/modele.js:2 clé absente « k.module »', ]); }); test('un arbre sans source d’interface fait échouer la garde', () => { assert.throws(() => avecArbre({ 'src/application/a.js': '' }, (r) => releverClesAbsentes(r, CLES)), /aucun fichier examiné/); }); test('des sources d’interface sans aucune clé littérale passée à t font échouer la garde', () => { const fichiers = { 'src/interface/A.svelte': "

{tr('k.absente')}

\n", 'src/interface/B.svelte': composant({ script: " import { getContext } from 'svelte';\n const traduire = getContext('t');", gabarit: "

{traduire('plan.x')}

\n

{t(cle)}

", }), 'src/interface/c.js': "export const d = (t, n) => t(`k.${n}`);\n", }; assert.throws(() => avecArbre(fichiers, (r) => releverClesAbsentes(r, CLES)), /clés de t : aucune clé examinée/); }); }); describe('sources de l’interface : le contenu des tables de traduction (§ 5.5, § 7.6, § 7.7, § 12.8)', () => { test('aucune valeur des tables assemblées ne porte un mot refusé ni un pictogramme', () => { exigerVide(releverContenuTables([...TABLES_APPLICATION, ...TABLES_INTERFACE], localisateur(RACINE)), 'contenu des tables'); }); test('la garde relève chaque mot refusé, sans égard à la casse ni aux accents, dans une chaîne et dans une fonction', () => { const refusees = new Map([ ['a.conforme', 'Conforme'], ['a.nonConforme', 'Plan non conforme'], ['a.conformite', 'CONFORMITÉ'], ['a.ecart', ({ n }) => `Un écart de ${n}`], ['a.ecartSeul', (details, texte) => texte('a.ecart', details)], ['a.ecartType', 'Écart-type'], ['a.satisfaction', 'Taux de satisfaction'], ['a.variance', 'variance'], ['a.equite', 'Équité'], ['a.score', 'Score'], ['a.mieux', 'Peut mieux faire'], ['a.minimal', 'Manque minimal'], ['a.recuperable', 'récupérable'], ['a.evitable', 'Évitable'], ['a.emoji', ({ n }) => `${n} \u{1F512}`], ['a.copyright', '\u{00A9} Club des Merles'], ]); const admises = new Map([ ['b.itineraire', 'Écart d\u{2019}itinéraire'], ['b.itineraires', "écarts d'itinéraire maximal"], ['b.plafond', 'Écart au plafond a priori'], ['b.inevitable', 'Inévitable'], ['b.ecartee', 'Proposition écartée'], ['b.fleche', 'Tour 1 \u{2192} tour 2'], ['b.nom', 'Iris Ombrelle'], ['b.variable', ({ n }) => { const ecart = n + 1; return `Tour ${ecart}`; }], ]); assert.deepEqual(releverContenuTables([refusees, admises], () => null, []), [ "a.conforme : conforme « Conforme »", 'a.conformite : conforme « CONFORMITÉ »', 'a.copyright : pictogramme U+00A9 « © Club des Merles »', 'a.ecart : écart hors du glossaire « Un écart de »', 'a.ecartType : écart hors du glossaire « Écart-type »', 'a.ecartType : écart-type « Écart-type »', 'a.emoji : pictogramme U+1F512 « \u{1F512} »', 'a.equite : équité « Équité »', 'a.evitable : évitable « Évitable »', 'a.mieux : peut mieux faire « Peut mieux faire »', 'a.minimal : manque minimal « Manque minimal »', 'a.nonConforme : conforme « Plan non conforme »', 'a.recuperable : récupérable « récupérable »', 'a.satisfaction : satisfaction « Taux de satisfaction »', 'a.score : score « Score »', 'a.variance : variance « variance »', ]); }); test('la garde nomme le fichier et la ligne de la clé fautive, et lève sur des tables vides', () => { const fichiers = { 'src/interface/libelles/plan.js': "export const TABLE = new Map([\n ['plan.a', 'A'],\n [\n 'plan.b',\n 'Score',\n ],\n]);\n", }; const releves = avecArbre(fichiers, (racine) => releverContenuTables([new Map([['plan.a', 'A'], ['plan.b', 'Score']])], localisateur(racine), []), ); assert.deepEqual(releves, ['src/interface/libelles/plan.js:3 plan.b : score « Score »']); assert.throws(() => releverContenuTables([new Map()]), /aucune entrée examinée/); }); test('une exception nommée n’admet que sa forme, sous ses clés ; tout autre « évitable » se relève, et une exception que rien ne sert est sans objet', () => { const tables = new Map([ ['diagnostic.remede.un', '2 chaises de plus la rend évitable'], ['diagnostic.remede.deux', ({ n }) => (n === 1 ? 'une table la rend évitable' : `${n} tables la rendent évitable`)], ['diagnostic.remede.reste', 'la rend évitable, part évitable'], ['diagnostic.remede.autre', 'la place devient évitable'], ['diagnostic.titre', 'un tabouret la rend évitable'], ['plan.segment', 'la rend évitable'], ['diagnostic.remede.inevitable', 'inévitable'], ]); assert.deepEqual(releverContenuTables([tables]), [ 'diagnostic.remede.autre : évitable « la place devient évitable »', 'diagnostic.remede.reste : évitable « la rend évitable, part évitable »', 'diagnostic.titre : évitable « un tabouret la rend évitable »', 'plan.segment : évitable « la rend évitable »', ]); const sansRemede = new Map([['diagnostic.remede.un', 'ajouter une table']]); assert.deepEqual(releverContenuTables([sansRemede]), ['exception sans objet : diagnostic.remede.* « la rend(ent) évitable »']); }); }); describe('sources de l’interface : les couleurs (§ 12.7, § 13.3, § 13.5)', () => { test('aucun littéral de couleur, aucun jeton déclaré hors de jetons.css, le jeton de conflit lu par les seules règles de conflit du plan', () => { exigerVide(releverCouleurs(RACINE), 'couleurs'); }); test('chaque jeton de couleur est défini dans les deux thèmes', () => { exigerVide(releverThemes(RACINE), 'thèmes'); }); test('la garde relève stroke: #c00 et chaque littéral, et pas un jeton ni une adresse de motif', () => { const fichiers = { 'src/interface/jetons.css': JETONS_EPREUVE, 'src/interface/plan/A.svelte': composant({ gabarit: '', style: [ ' .table { stroke: #c00; }', ' .table circle { fill: url(#hachure); stroke: var(--couleur-a); }', ' circle { color: rgb(1 2 3); background: Tomato; border-color: transparent currentColor; }', ' .table { outline-color: var(--couleur-a, white); --rang: 2; }', ].join('\n'), }), 'src/interface/accueil/sous/B.svelte': composant({ script: " const couleur = '#ABCDEF';\n const code = 'orange-sanguine';", gabarit: '\n', }), 'src/interface/autre.css': '.x { color: #123456; }\n', 'src/interface/C.navigateur.test.js': "const c = 'stroke: #c00';\n", }; assert.deepEqual(avecArbre(fichiers, releverCouleurs), [ 'src/interface/accueil/sous/B.svelte:2 couleur « #ABCDEF » dans le script', 'src/interface/accueil/sous/B.svelte:5 couleur « #fff » dans color', 'src/interface/accueil/sous/B.svelte:5 couleur « Canvas » dans fill', 'src/interface/accueil/sous/B.svelte:5 couleur « hsl( » dans stroke', 'src/interface/accueil/sous/B.svelte:5 couleur « red » dans fill', 'src/interface/accueil/sous/B.svelte:5 jeton déclaré --ombre', 'src/interface/accueil/sous/B.svelte:6 jeton déclaré --teinte', 'src/interface/autre.css:1 couleur « #123456 » dans color', 'src/interface/plan/A.svelte:3 couleur « #c00 » dans stroke', 'src/interface/plan/A.svelte:5 couleur « Tomato » dans background', 'src/interface/plan/A.svelte:5 couleur « rgb( » dans color', 'src/interface/plan/A.svelte:6 couleur « white » dans outline-color', 'src/interface/plan/A.svelte:6 jeton déclaré --rang', ]); }); test('la garde relève un bouton peint du jeton de conflit, et l’admet dans une règle de conflit du plan', () => { const fichiers = { 'src/interface/jetons.css': JETONS_EPREUVE, 'src/interface/accueil/Bouton.svelte': composant({ gabarit: '', style: ' .danger { color: var(--couleur-conflit); }', }), 'src/interface/plan/Table.svelte': composant({ gabarit: '1', style: ' .table.conflit rect { stroke: var(--couleur-conflit); }\n .table rect { fill: var(--couleur-conflit); }', }), 'src/interface/plan/Trou.svelte': composant({ gabarit: '', style: [ ' .table:not(.conflit) rect { fill: var(--couleur-conflit); }', ' .sans-conflit { color: var(--couleur-conflit); }', ' .non-conflit { color: var(--couleur-conflit); }', ' .a, .b.conflit { fill: var(--couleur-conflit); }', ' .conflit-leve { fill: var(--couleur-conflit); }', ' .b.conflit, .b .trait-conflit, .plateau-en-conflit { stroke: var(--couleur-conflit); }', ].join('\n'), }), }; assert.deepEqual(avecArbre(fichiers, releverCouleurs), [ "src/interface/accueil/Bouton.svelte:3 jeton de conflit hors d'une règle de conflit du plan, « .danger »", "src/interface/plan/Table.svelte:1 jeton de conflit hors d'une règle de conflit du plan, en ligne", "src/interface/plan/Table.svelte:4 jeton de conflit hors d'une règle de conflit du plan, « .table rect »", "src/interface/plan/Trou.svelte:3 jeton de conflit hors d'une règle de conflit du plan, « .table:not(.conflit) rect »", "src/interface/plan/Trou.svelte:4 jeton de conflit hors d'une règle de conflit du plan, « .sans-conflit »", "src/interface/plan/Trou.svelte:5 jeton de conflit hors d'une règle de conflit du plan, « .non-conflit »", "src/interface/plan/Trou.svelte:6 jeton de conflit hors d'une règle de conflit du plan, « .a, .b.conflit »", "src/interface/plan/Trou.svelte:7 jeton de conflit hors d'une règle de conflit du plan, « .conflit-leve »", ]); }); test('la garde des thèmes relève un jeton qu’un thème ne redéfinit pas', () => { const fichiers = { 'src/interface/jetons.css': [ ':root { --espace-1: 1px; }', ':root, :root[data-theme="clair"] { --couleur-sol: #fff; --couleur-gel: #00f; }', ":root[data-theme='sombre'] { --couleur-sol: #000; --couleur-fantome: #0f0; }", '', ].join('\n'), }; assert.deepEqual(avecArbre(fichiers, releverThemes), [ 'src/interface/jetons.css:2 --couleur-gel absent du thème sombre', 'src/interface/jetons.css:3 --couleur-fantome absent du thème clair', ]); }); test('un arbre sans composant ni feuille fait échouer la garde', () => { assert.throws(() => avecArbre({ 'src/interface/jetons.css': JETONS_EPREUVE }, releverCouleurs), /couleurs : aucun fichier examiné/); }); test('une feuille des jetons sans jeton de couleur fait échouer la garde des thèmes', () => { const fichiers = { 'src/interface/jetons.css': ':root, :root[data-theme="clair"] { --espace-1: 1px; }\n' }; assert.throws(() => avecArbre(fichiers, releverThemes), /thèmes : aucun jeton de couleur examiné/); }); }); describe('sources de l’interface : balisage et fenêtres du navigateur (§ 13.1, § 2.1)', () => { test('aucun balisage injecté, aucune fenêtre du navigateur, aucun beforeunload, aucun .message affiché', () => { exigerVide(releverBalisageEtFenetres(RACINE), 'balisage et fenêtres'); }); test('la garde relève {@html nom} et chaque forme refusée, et pas leurs voisines ni un commentaire', () => { const fichiers = { 'src/interface/A.svelte': composant({ script: " let nom = $state('');\n // aucun beforeunload, jamais alert(…) ni err.message", gabarit: '

{@html nom}

\n {}} />', }), 'src/interface/plan/sous/b.js': [ 'export function f(el, erreur, seance) {', " el.innerHTML = '';", " el['outerHTML'] = '';", " el.insertAdjacentHTML('beforeend', '');", " document.write('x');", " window.alert('x'); confirm('x'); globalThis.prompt('x');", " addEventListener('beforeunload', f);", ' return erreur.message ?? erreur?.message;', '}', 'export const voisins = (s) => [s.confirmer(), s.alert(), s.textContent, s.code, s.messages];', '', ].join('\n'), 'src/application/c.js': "export const m = (e) => e.message;\nexport const h = (el) => { el.innerHTML = ''; };\n", 'src/interface/A.test.js': "window.confirm('x'); el.innerHTML = '';\n", }; assert.deepEqual(avecArbre(fichiers, releverBalisageEtFenetres), [ 'src/application/c.js:2 innerHTML', 'src/interface/A.svelte:5 {@html}', 'src/interface/A.svelte:6 beforeunload', 'src/interface/plan/sous/b.js:2 innerHTML', 'src/interface/plan/sous/b.js:3 outerHTML', 'src/interface/plan/sous/b.js:4 insertAdjacentHTML', 'src/interface/plan/sous/b.js:5 document.write', 'src/interface/plan/sous/b.js:6 alert(', 'src/interface/plan/sous/b.js:6 confirm(', 'src/interface/plan/sous/b.js:6 prompt(', 'src/interface/plan/sous/b.js:7 beforeunload', 'src/interface/plan/sous/b.js:8 .message', 'src/interface/plan/sous/b.js:8 .message', ]); }); test('un arbre sans source fait échouer la garde', () => { assert.throws(() => avecArbre({ 'src/interface/a.css': '' }, releverBalisageEtFenetres), /aucun fichier examiné/); }); }); describe('sources de l’interface : texte comprimé et animations (§ 7.4, § 19.2)', () => { test('ni textLength ni lengthAdjust, ni transitionend ni animationend, ni transition ni animation', () => { exigerVide(releverTexteEtAnimation(RACINE), 'texte et animation'); }); test('la garde relève chaque forme dans son périmètre, et pas hors de lui ni dans un commentaire', () => { const fichiers = { 'src/interface/plan/A.svelte': composant({ script: " import { fade } from 'svelte/transition';\n // ni textLength ni transitionend", gabarit: [ '{x}', '

', ].join('\n'), style: ' div { transition: opacity 1s; animation: none; transition-duration: 0s; }\n @keyframes pulse { to { opacity: 1; } }', }), 'src/geometrie/texte.js': [ "export const f = (el) => el.setAttribute('textLength', 1);", "export const g = 'transitionend';", 'export const h = (el) => el.getComputedTextLength();', "export const i = '[textLength], [lengthAdjust]';", '', ].join('\n'), 'src/interface/b.js': "export const f = (el) => el.addEventListener('animationend', f);\n", 'src/application/c.js': "export const f = 'textLength';\n", 'src/interface/A.navigateur.test.js': "export const f = '[textLength], [lengthAdjust]';\n", }; assert.deepEqual(avecArbre(fichiers, releverTexteEtAnimation), [ 'src/geometrie/texte.js:1 textLength', 'src/geometrie/texte.js:4 textLength', 'src/interface/b.js:1 animationend', 'src/interface/plan/A.svelte:5 lengthAdjust', 'src/interface/plan/A.svelte:5 textLength', 'src/interface/plan/A.svelte:6 directive in:fade', 'src/interface/plan/A.svelte:6 directive out:fade', 'src/interface/plan/A.svelte:6 directive transition:fade', 'src/interface/plan/A.svelte:6 transitionend', 'src/interface/plan/A.svelte:8 transition: opacity 1s', 'src/interface/plan/A.svelte:9 @keyframes', ]); }); test('un arbre sans source fait échouer la garde', () => { assert.throws(() => avecArbre({ 'src/application/a.js': '' }, releverTexteEtAnimation), /aucun fichier examiné/); }); }); describe('sources de l’interface : le câblage (§ 12.9, § 13.3)', () => { test('aucun composant ne calcule, et seul evaluation.js importe les mesures', () => { exigerVide(releverCablage(RACINE), 'câblage'); }); test('la garde relève Math., .sort( et .reduce( dans un composant, et l’import d’une mesure hors d’evaluation.js', () => { const fichiers = { 'src/interface/plan/A.svelte': composant({ script: ' let liste = $state([]);\n const tries = $derived(liste.toSorted());\n // Math.max, liste.sort()', gabarit: '

{Math.round(liste.length)}

\n

{liste.reduce((s, x) => s + x, 0)}

\n

{liste.sort().join()}

\n

{liste.filter(Boolean).length}

', }), 'src/interface/b.js': 'export const f = (l) => Math.max(...l.sort());\n', 'src/application/evaluation.js': "import { mesurer, manques } from '../moteur/indicateurs.js';\nexport { mesurer };\n", 'src/application/generation.js': [ "import { troisChiffres as chiffres } from '../moteur/indicateurs.js';", "import * as indicateurs from '../moteur/indicateurs.js';", "export { minimumAtteint } from '../moteur/indicateurs.js';", 'export const f = (x) => indicateurs.plafondsRealises(x) + indicateurs.autre(x);', "import { evaluer } from './evaluation.js';", '', ].join('\n'), 'src/application/generation.test.js': "import { mesurer } from '../moteur/indicateurs.js';\n", }; assert.deepEqual(avecArbre(fichiers, releverCablage), [ 'src/application/generation.js:1 import de troisChiffres', 'src/application/generation.js:3 import de minimumAtteint', 'src/application/generation.js:4 import de plafondsRealises', 'src/interface/plan/A.svelte:3 .toSorted(', 'src/interface/plan/A.svelte:6 Math.round', 'src/interface/plan/A.svelte:7 .reduce(', 'src/interface/plan/A.svelte:8 .sort(', ]); }); test('un arbre sans composant fait échouer la garde', () => { assert.throws(() => avecArbre({ 'src/application/a.js': '' }, releverCablage), /câblage des composants : aucun fichier examiné/); }); }); describe('sources de l’interface : les sélecteurs élagués (§ 13.3, § 14.2)', () => { test('aucun composant ne porte une règle que le compilateur élague', () => { exigerVide(releverSelecteursElagues(RACINE), 'sélecteurs élagués'); }); test('la garde relève une règle .conflit qui vise un élément sans classe dynamique, et admet class:conflit', () => { const fichiers = { 'src/interface/plan/sous/Table.svelte': composant({ gabarit: '', style: ' .table { stroke-width: 1; }\n .table.conflit rect { stroke-width: 3; }', }), 'src/interface/plan/Table2.svelte': composant({ script: ' let { conflit } = $props();', gabarit: '', style: ' .table.conflit rect { stroke-width: 3; }', }), }; assert.deepEqual(avecArbre(fichiers, releverSelecteursElagues), [ 'src/interface/plan/sous/Table.svelte:4 sélecteur élagué « .table.conflit rect »', ]); }); test('un arbre sans composant fait échouer la garde', () => { assert.throws(() => avecArbre({ 'src/interface/a.js': '' }, releverSelecteursElagues), /aucun fichier examiné/); }); });