1341 lines
66 KiB
JavaScript
1341 lines
66 KiB
JavaScript
|
|
// © 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 <script module>,
|
|||
|
|
// chaque feuille .css par parseCss. Un commentaire n'est donc jamais relevé :
|
|||
|
|
// la règle porte sur ce que le code fait, et un commentaire qui nomme un
|
|||
|
|
// interdit le documente.
|
|||
|
|
//
|
|||
|
|
// Huit gardes : les chaînes visibles en dur (§ 14.6), les clés littérales de
|
|||
|
|
// t qui n'existent pas, le contenu des tables de traduction (§ 5.5, § 7.6,
|
|||
|
|
// § 7.7, § 12.8, § 12.10.8), les couleurs (§ 12.7, § 13.3, § 13.5), le
|
|||
|
|
// balisage et les fenêtres du navigateur (§ 13.1, § 2.1), le texte comprimé et
|
|||
|
|
// les animations (§ 7.4, § 19.2), le câblage des composants (§ 12.9, § 13.3),
|
|||
|
|
// les sélecteurs que le compilateur élague (§ 13.3). Chaque garde rend un
|
|||
|
|
// relevé « chemin:ligne motif « extrait » », trié, qui nomme d'un coup chaque
|
|||
|
|
// fichier et chaque ligne fautifs ; elle lève sur un balayage vide (§ 14.2).
|
|||
|
|
//
|
|||
|
|
// Le périmètre est le code livré : les épreuves — *.test.js, dont
|
|||
|
|
// *.navigateur.test.js — et test/ portent exprès ce que les gardes refusent,
|
|||
|
|
// et n'en font pas partie. D'autres épreuves font tourner chaque garde sur un
|
|||
|
|
// arbre temporaire qui porte, à plus d'un niveau, les formes refusées et des
|
|||
|
|
// formes voisines admises.
|
|||
|
|
import assert from 'node:assert/strict';
|
|||
|
|
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|||
|
|
import { tmpdir } from 'node:os';
|
|||
|
|
import { dirname, join, relative, sep } from 'node:path';
|
|||
|
|
import { fileURLToPath } from 'node:url';
|
|||
|
|
import { compile, parse, parseCss } from 'svelte/compiler';
|
|||
|
|
import { TABLES_APPLICATION } from '../src/application/libelles.js';
|
|||
|
|
import { creerTraduction } from '../src/application/traduction.js';
|
|||
|
|
import { TABLES_INTERFACE } from '../src/interface/libelles.js';
|
|||
|
|
import { describe, test } from './lanceur.js';
|
|||
|
|
|
|||
|
|
const RACINE = fileURLToPath(new URL('..', import.meta.url));
|
|||
|
|
|
|||
|
|
// --- Lecture des sources ------------------------------------------------------
|
|||
|
|
|
|||
|
|
// Une épreuve, node ou navigateur : hors du périmètre de chaque garde.
|
|||
|
|
const EPREUVE = /\.test\.[cm]?js$/;
|
|||
|
|
|
|||
|
|
// Chemins relatifs, en « / », des fichiers des arbres donnés dont le nom finit
|
|||
|
|
// par l'une des extensions, épreuves exclues, triés. Un arbre absent n'en
|
|||
|
|
// fournit aucun ; un répertoire node_modules n'est jamais retenu.
|
|||
|
|
function sources(racine, arbres, extensions) {
|
|||
|
|
const chemins = [];
|
|||
|
|
for (const arbre of arbres) {
|
|||
|
|
let entrees;
|
|||
|
|
try {
|
|||
|
|
entrees = readdirSync(join(racine, arbre), { recursive: true, withFileTypes: true });
|
|||
|
|
} catch (erreur) {
|
|||
|
|
if (erreur.code === 'ENOENT') continue;
|
|||
|
|
throw erreur;
|
|||
|
|
}
|
|||
|
|
for (const entree of entrees) {
|
|||
|
|
if (!entree.isFile()) continue;
|
|||
|
|
const chemin = relative(racine, join(entree.parentPath, entree.name)).split(sep).join('/');
|
|||
|
|
if (chemin.split('/').includes('node_modules') || EPREUVE.test(chemin)) continue;
|
|||
|
|
if (extensions.some((extension) => chemin.endsWith(extension))) chemins.push(chemin);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return chemins.sort();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Lève quand un balayage n'examine aucun fichier (§ 14.2).
|
|||
|
|
function exigerNonVide(chemins, garde) {
|
|||
|
|
assert.ok(chemins.length > 0, `${garde} : aucun fichier examiné`);
|
|||
|
|
return chemins;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Numéro, à partir de 1, de la ligne qui porte le caractère d'indice donné.
|
|||
|
|
const ligneDe = (texte, indice) => texte.slice(0, indice).split('\n').length;
|
|||
|
|
|
|||
|
|
// Un extrait lisible d'un texte relevé : blancs réduits, quarante caractères
|
|||
|
|
// au plus.
|
|||
|
|
function extrait(texte) {
|
|||
|
|
const court = String(texte).replace(/\s+/g, ' ').trim();
|
|||
|
|
return `« ${court.length > 40 ? `${court.slice(0, 39)}\u{2026}` : court} »`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Un composant : son arbre moderne, ses positions comptées dans son texte.
|
|||
|
|
function analyserComposant(racine, chemin) {
|
|||
|
|
const texte = readFileSync(join(racine, chemin), 'utf8');
|
|||
|
|
try {
|
|||
|
|
return { texte, ast: parse(texte, { modern: true }) };
|
|||
|
|
} catch (erreur) {
|
|||
|
|
throw new Error(`${chemin} : analyse impossible (${erreur.code ?? erreur.name})`, { cause: erreur });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Un module JavaScript, enveloppé dans un <script module> : le préfixe ne
|
|||
|
|
// porte aucun saut de ligne, les numéros de ligne restent ceux du fichier.
|
|||
|
|
function analyserModule(racine, chemin) {
|
|||
|
|
const texte = `<script module>${readFileSync(join(racine, chemin), 'utf8')}</script>`;
|
|||
|
|
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', /(?<!in)evitable/],
|
|||
|
|
];
|
|||
|
|
const PICTOGRAMME = /\p{Extended_Pictographic}/u;
|
|||
|
|
|
|||
|
|
// Les exceptions nommées aux mots refusés. Chacune admet une forme exacte du
|
|||
|
|
// texte normalisé, pour un mot de MOTS_INTERDITS, dans les seules valeurs dont
|
|||
|
|
// la clé répond à cles ; tout autre emploi du mot, dans la même valeur
|
|||
|
|
// comprise, reste relevé. Une exception qu'aucune valeur ne sert se relève
|
|||
|
|
// « sans objet ».
|
|||
|
|
//
|
|||
|
|
// Le diagnostic d'un manque énonce ce qui le rendrait évitable (§ 5.6) : ses
|
|||
|
|
// remèdes disent qu'un changement « la rend » ou « la rendent évitable ».
|
|||
|
|
// Ailleurs, « évitable » nommerait le second segment du manque (§ 12.4), que
|
|||
|
|
// le logiciel n'écrit pas.
|
|||
|
|
const EXCEPTIONS_DE_CONTENU = [
|
|||
|
|
{
|
|||
|
|
mot: 'évitable',
|
|||
|
|
cles: /^diagnostic\.remede\./,
|
|||
|
|
forme: /\bla rend(?:ent)? evitable\b/g,
|
|||
|
|
nom: 'diagnostic.remede.* « la rend(ent) évitable »',
|
|||
|
|
},
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
// Une chaîne de forme clé — un code, LECTURE_SEULE, ou un libellé,
|
|||
|
|
// plan.refus.fermer — : une fonction la passe à texte, ou la compare à un
|
|||
|
|
// détail, et ne l'affiche pas.
|
|||
|
|
const FORME_CLE = /^(?:[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*|[a-z][A-Za-z0-9]*(?:\.[A-Za-z0-9]+)+)$/;
|
|||
|
|
|
|||
|
|
// Le texte d'une fonction de détails qu'un écran peut lire : ses chaînes
|
|||
|
|
// littérales et les morceaux fixes de ses gabarits de chaîne, échappements
|
|||
|
|
// décodés, joints par un blanc ; une chaîne de forme clé n'en est pas. Le nom
|
|||
|
|
// d'une variable n'est pas un texte : une fonction qui nomme « ecart » une
|
|||
|
|
// valeur intermédiaire n'écrit pas le mot. Un texte que l'analyseur refuse se
|
|||
|
|
// lit en entier.
|
|||
|
|
function texteDeFonction(fonction) {
|
|||
|
|
const source = String(fonction);
|
|||
|
|
let programme;
|
|||
|
|
try {
|
|||
|
|
programme = parse(`<script module>(${source});</script>`, { 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(`(?<![\\w-])(?:${COULEURS_NOMMEES.join('|')})(?![\\w-])`, 'i');
|
|||
|
|
const COULEUR_HEX = /#(?:[0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3,4})(?![\w-])/i;
|
|||
|
|
const COULEUR_FONCTION = /(?<![\w-])(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color|color-mix|light-dark)\s*\(/i;
|
|||
|
|
|
|||
|
|
// Le littéral de couleur que porte une valeur CSS ou d'attribut, ou null. Les
|
|||
|
|
// noms de propriétés personnalisées, les adresses url(…) et les chaînes
|
|||
|
|
// entre guillemets sont retirés avant la recherche.
|
|||
|
|
function couleurLitterale(valeur) {
|
|||
|
|
const nette = valeur
|
|||
|
|
.replace(/url\([^)]*\)/gi, ' ')
|
|||
|
|
.replace(CHAINE_CSS, ' ')
|
|||
|
|
.replace(/--[\w-]+/g, ' ');
|
|||
|
|
for (const motif of [COULEUR_HEX, COULEUR_FONCTION, COULEUR_NOMMEE]) {
|
|||
|
|
const trouve = nette.match(motif);
|
|||
|
|
if (trouve !== null) return trouve[0];
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Une chaîne qui est en entier un littéral de couleur : un code hexadécimal,
|
|||
|
|
// une fonction de couleur, un nom de couleur.
|
|||
|
|
const COULEUR_ENTIERE = new RegExp(
|
|||
|
|
`^(?:#(?:[0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3,4})|${COULEUR_FONCTION.source}.*\\)|${COULEURS_NOMMEES.join('|')})$`,
|
|||
|
|
'is',
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// Attributs du gabarit qui portent une couleur.
|
|||
|
|
const ATTRIBUTS_COULEUR = new Set(['fill', 'stroke', 'color', 'stop-color', 'flood-color', 'lighting-color']);
|
|||
|
|
|
|||
|
|
// Les déclarations d'une feuille, chacune avec le texte du sélecteur de la
|
|||
|
|
// règle la plus proche et l'arbre de ce sélecteur (null hors de toute règle) :
|
|||
|
|
// { propriete, valeur, debut, selecteur, prelude }.
|
|||
|
|
function declarations(css, texte) {
|
|||
|
|
const trouvees = [];
|
|||
|
|
parcourir(css, (noeud, ancetres) => {
|
|||
|
|
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(`(?<![\\p{L}\\p{N}])(?:on)?${motif}(?![\\p{L}\\p{N}])`, 'iu').test(texte)) ?? null;
|
|||
|
|
|
|||
|
|
// Les noms d'un arbre d'analyse — identifiants, attributs, directives,
|
|||
|
|
// chaînes littérales — que les motifs nomment : [{ debut, motif }].
|
|||
|
|
function nomsReleves(parties, motifs) {
|
|||
|
|
const trouves = [];
|
|||
|
|
parcourir(parties, (noeud) => {
|
|||
|
|
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 === '' ? '' : `<script>\n${script}\n</script>\n`}${gabarit}\n${style === '' ? '' : `<style>\n${style}\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': '<button>Modifier</button>\n',
|
|||
|
|
'src/interface/plan/sous/B.svelte': composant({
|
|||
|
|
script: " const t = (cle) => cle;\n let x = $state(true);",
|
|||
|
|
gabarit: [
|
|||
|
|
'<p title="Titre du plan" aria-label={x ? \'Oui\' : t(\'k.non\')}>{t(\'k.titre\')} — {42}</p>',
|
|||
|
|
'<svg><text>…</text><tspan>{\'·\'}</tspan><desc> </desc><title>{t(\'k.svg\')}</title></svg>',
|
|||
|
|
'<input placeholder="{x} places" alt={`Image ${x}`} aria-roledescription="table" />',
|
|||
|
|
'<span>{`${x} places`}</span><span>{x ?? \'Aucun\'}</span><span>{x && \'Plein\'}</span>',
|
|||
|
|
'<span class="vide" data-x="Nom">{x} / {x}</span><img alt="" />',
|
|||
|
|
'<span style="content: \'Note\'">.</span>',
|
|||
|
|
'<!-- Commentaire écrit en dur -->',
|
|||
|
|
].join('\n'),
|
|||
|
|
style: ' .vide::after { content: "Fin"; }\n .vide::before { content: counter(rang) ". "; }',
|
|||
|
|
}),
|
|||
|
|
'src/interface/C.test.js': "export const a = '<button>Modifier</button>';\n",
|
|||
|
|
'src/interface/D.navigateur.test.js': "export const a = '<button>Modifier</button>';\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: "<p>{t('k.present')}</p>\n<p>{t(`k.gabarit`)}</p>\n<p>{t(`k.${a}`)}</p>\n<p>{traduction.t(\"k.membre\")}</p>",
|
|||
|
|
}),
|
|||
|
|
'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': "<p>{tr('k.absente')}</p>\n",
|
|||
|
|
'src/interface/B.svelte': composant({
|
|||
|
|
script: " import { getContext } from 'svelte';\n const traduire = getContext('t');",
|
|||
|
|
gabarit: "<p>{traduire('plan.x')}</p>\n<p>{t(cle)}</p>",
|
|||
|
|
}),
|
|||
|
|
'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: '<g class="table"><circle r="1" /></g>',
|
|||
|
|
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: '<svg><rect fill="red" stroke={x ? \'hsl(0 0% 0%)\' : \'none\'} style="color: #fff; --ombre: 1px" style:fill="Canvas" /></svg>\n<Bouton --teinte="1" />',
|
|||
|
|
}),
|
|||
|
|
'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: '<button class="danger">x</button>',
|
|||
|
|
style: ' .danger { color: var(--couleur-conflit); }',
|
|||
|
|
}),
|
|||
|
|
'src/interface/plan/Table.svelte': composant({
|
|||
|
|
gabarit: '<g class:conflit={true} class="table"><rect /></g><span style="color: var(--couleur-conflit)">1</span>',
|
|||
|
|
style: ' .table.conflit rect { stroke: var(--couleur-conflit); }\n .table rect { fill: var(--couleur-conflit); }',
|
|||
|
|
}),
|
|||
|
|
'src/interface/plan/Trou.svelte': composant({
|
|||
|
|
gabarit: '<g class="table b"><rect /></g><button class="sans-conflit">x</button><i class="non-conflit a"></i>',
|
|||
|
|
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é/);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
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: '<p>{@html nom}</p>\n<svelte:window onbeforeunload={() => {}} />',
|
|||
|
|
}),
|
|||
|
|
'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: [
|
|||
|
|
'<text textLength="10" lengthAdjust="spacing">{x}</text>',
|
|||
|
|
'<div transition:fade ontransitionend={f}></div><p in:fade out:fade></p>',
|
|||
|
|
].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: '<p>{Math.round(liste.length)}</p>\n<p>{liste.reduce((s, x) => s + x, 0)}</p>\n<p>{liste.sort().join()}</p>\n<p>{liste.filter(Boolean).length}</p>',
|
|||
|
|
}),
|
|||
|
|
'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: '<g class="table"><rect /></g>',
|
|||
|
|
style: ' .table { stroke-width: 1; }\n .table.conflit rect { stroke-width: 3; }',
|
|||
|
|
}),
|
|||
|
|
'src/interface/plan/Table2.svelte': composant({
|
|||
|
|
script: ' let { conflit } = $props();',
|
|||
|
|
gabarit: '<g class="table" class:conflit><rect /></g>',
|
|||
|
|
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é/);
|
|||
|
|
});
|
|||
|
|
});
|