From aa5fafab1a511e4c99a42549aca3dad069352f86 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 6 Oct 2026 20:41:23 -0400 Subject: [PATCH] [ADD] tests: per-module coverage, 100 % branch floors, layer guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit make couverture runs the node series under V8 instrumentation and prints one row per module, full ones included, plus a line-by-line HTML report. Four modules hold a hard 100 % branch floor — indicateurs.js, plafond.js, depot.js, journal.js — and the command fails below it; there is no global threshold, which covering the easy modules would satisfy. The tree guard refuses a lowered or global floor, a floor on a file the measure never loads, and a command that does not measure. It extends determinism and layer boundaries to src/stockage, src/csv and src/application (§ 13.4), and no longer takes a string literal for DOM access. Checked: 1266 node tests; the four floors at 100 %. --- FR --- [ADD] épreuves : couverture par module, seuils durs, gardes des couches make couverture joue la série node sous l'instrumentation de V8 et imprime une ligne par module, pleins compris, et un rapport HTML ligne à ligne. Quatre modules tiennent un seuil dur de 100 % des branches — indicateurs.js, plafond.js, depot.js, journal.js — et la commande échoue en deçà ; aucun seuil global, qu'on satisferait en couvrant les modules faciles. La garde de l'arborescence refuse un seuil abaissé ou global, un seuil sur un fichier que la mesure ne charge pas, et une commande qui ne mesure pas. Elle étend le déterminisme et les frontières des couches à src/stockage, src/csv et src/application (§ 13.4), et ne prend plus une chaîne littérale pour le DOM. Vérifié : 1266 épreuves node ; les quatre seuils à 100 %. Assisted-by: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EUXSGcwCLSC69FWEdCtWSb --- Makefile | 4 + package-lock.json | 144 +++++++ package.json | 4 +- test/arborescence.test.js | 853 +++++++++++++++++++++++++++++++++----- vitest.config.js | 26 ++ 5 files changed, 933 insertions(+), 98 deletions(-) diff --git a/Makefile b/Makefile index 7a26e35..d02718d 100644 --- a/Makefile +++ b/Makefile @@ -64,6 +64,10 @@ test_navigateur: ## Joue les épreuves du navigateur, sous Chromium test_tout: ## Joue les trois séries $(AVEC_NODE) npm run test:tout +.PHONY: couverture +couverture: ## Mesure la couverture de la série node, module par module ; seuils durs du § 14.13 + $(AVEC_NODE) npm run couverture + .PHONY: build build: ## Construit la page dans www/ $(AVEC_NODE) npm run build diff --git a/package-lock.json b/package-lock.json index 4e79dc2..9f82101 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@capacitor/core": "^8.5.2", "@sveltejs/vite-plugin-svelte": "^7.3.1", "@vitest/browser-playwright": "^5.0.3", + "@vitest/coverage-v8": "^5.0.3", "electron": "^44.5.1", "electron-builder": "^26.15.3", "fast-check": "^4.10.2", @@ -22,6 +23,66 @@ "vitest": "^5.0.3" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@blazediff/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@blazediff/core/-/core-1.10.0.tgz", @@ -1249,6 +1310,58 @@ } } }, + "node_modules/@vitest/coverage-v8": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.3.tgz", + "integrity": "sha512-+klsyz7BvT1vCU28Zkfzms1Ia78XD0V41U3FUtRaa3S+vOr/EXvK1O6BvVD7l1RzEjm6SONR2aybOvDDf9u0mQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/istanbul-lib-coverage": "^1.0.0", + "@vitest/istanbul-lib-report": "^1.0.0", + "ast-v8-to-istanbul": "^1.0.5", + "magicast": "^0.5.4", + "obug": "^2.1.4", + "std-env": "^4.2.0", + "tinyrainbow": "^3.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "5.0.3", + "vitest": "5.0.3" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/istanbul-lib-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.2.tgz", + "integrity": "sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@vitest/istanbul-lib-report": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.2.tgz", + "integrity": "sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/istanbul-lib-coverage": "1.0.2" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/@vitest/mocker": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", @@ -1693,6 +1806,18 @@ "node": ">=12" } }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz", + "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -3521,6 +3646,13 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", @@ -3936,6 +4068,18 @@ "@jridgewell/sourcemap-codec": "^1.6.0" } }, + "node_modules/magicast": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", diff --git a/package.json b/package.json index f179805..4f2e536 100644 --- a/package.json +++ b/package.json @@ -17,13 +17,15 @@ "test:surveille": "vitest --project node", "test:long": "vitest run --project node-long", "test:navigateur": "vitest run --project navigateur", - "test:tout": "vitest run" + "test:tout": "vitest run", + "couverture": "vitest run --project node --coverage --reporter=default" }, "devDependencies": { "@capacitor/cli": "^8.5.2", "@capacitor/core": "^8.5.2", "@sveltejs/vite-plugin-svelte": "^7.3.1", "@vitest/browser-playwright": "^5.0.3", + "@vitest/coverage-v8": "^5.0.3", "electron": "^44.5.1", "electron-builder": "^26.15.3", "fast-check": "^4.10.2", diff --git a/test/arborescence.test.js b/test/arborescence.test.js index 7ce3021..70ff45a 100644 --- a/test/arborescence.test.js +++ b/test/arborescence.test.js @@ -1,25 +1,28 @@ // © 2026 TechnoLibre (http://www.technolibre.ca) // License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl) -// Gardes de l'arborescence, lues dans le texte des sources : le moteur et le -// générateur de démonstrations n'appellent aucune source non reproductible -// (§ 14.7), et ne parcourent les clés d'un objet que par exception nommée -// (§ 15.5, point 4) ; le graphe d'imports du moteur et de la géométrie ne -// rejoint ni l'interface, ni le stockage, ni une plateforme (§ 13.4) ; -// chaque paquet importé est déclaré dans devDependencies ; une valeur du -// contrat de données n'a qu'une définition, celle de configuration.js. -// Chaque relevé lit le texte entier, commentaires et chaînes compris : un -// commentaire qui nomme un appel refusé fait échouer la garde comme l'appel -// lui-même. Chaque garde refuse de passer sur un balayage vide (§ 14.2), et -// d'autres épreuves la font tourner sur des arbres temporaires qui portent, -// à plus d'un niveau de profondeur, les formes qu'elle refuse et des formes -// voisines qu'elle admet. +// Gardes de l'arborescence, lues dans le texte des sources : le moteur, le +// générateur de démonstrations, le stockage et l'analyseur CSV n'appellent +// aucune source non reproductible (§ 14.7), et ne parcourent les clés d'un +// objet que par exception nommée (§ 15.5, point 4) ; le graphe d'imports de +// chaque couche ne rejoint que ce que le § 13.4 lui permet ; chaque paquet +// importé est déclaré dans devDependencies ; une valeur du contrat de données +// n'a qu'une définition, celle de configuration.js ; la mesure de la +// couverture joue la série node, publie chaque module et tient ses seuils +// durs sur des modules qui existent (§ 14.13). Chaque relevé lit le +// texte entier, commentaires et chaînes compris, hors celui des accès au +// navigateur, qui saute les chaînes littérales : un commentaire qui nomme un +// appel refusé fait échouer la garde comme l'appel lui-même. Chaque garde +// refuse de passer sur un balayage vide (§ 14.2), et d'autres épreuves la +// font tourner sur des arbres temporaires qui portent, à plus d'un niveau de +// profondeur, les formes qu'elle refuse et des formes voisines qu'elle admet. import assert from 'node:assert/strict'; import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { isBuiltin } from 'node:module'; import { tmpdir } from 'node:os'; -import { dirname, extname, join, relative, resolve, sep } from 'node:path'; +import { dirname, extname, join, matchesGlob, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; +import configuration from '../vitest.config.js'; import { chargements } from './chargements.js'; import { describe, test } from './lanceur.js'; @@ -116,60 +119,104 @@ const PARCOURS_DE_CLES = [ ]; // Parcours de clés admis, chacun nommé par son fichier, relatif à la racine, -// et par le texte de sa ligne, blancs de bord retirés ; sa raison dit -// pourquoi l'ordre n'y décide d'aucun résultat. Une exception admet chaque -// ligne de ce texte dans ce fichier, et rien d'autre : la ligne réécrite, ou -// un second parcours ajouté à côté, se relève de nouveau. Elle ne couvre -// aucune source de SOURCES_NON_REPRODUCTIBLES, que rien n'admet. -const PARCOURS_DE_CLES_ADMIS = []; +// et par un motif : l'expression régulière de la forme admise, qui se lit à +// partir du parcours relevé, sur une ligne ou sur plusieurs — non un numéro +// de ligne, qu'un ajout au-dessus déplace. Sa raison dit pourquoi l'ordre n'y +// décide d'aucun résultat. Une exception admet dans son fichier chaque +// parcours de sa forme, et rien d'autre : le parcours écrit autrement, ou un +// second parcours sur la même ligne, se relève. Elle ne couvre aucune source +// de SOURCES_NON_REPRODUCTIBLES, que rien n'admet. +const PARCOURS_DE_CLES_ADMIS = [ + { + fichier: join('src', 'stockage', 'document.js'), + motif: /Object\s*\.\s*keys\s*\(\s*\w+\s*\)\s*\.\s*sort\s*\(\s*comparerTextes\s*\)/, + raison: + 'clesRangees, que lisent le relevé des clés inconnues et la recopie de canonique.js : ' + + "les clés propres d'un objet, rangées sur-le-champ par comparerTextes, un ordre total des chaînes ; " + + 'le résultat ne dépend que de leur ensemble', + }, +]; + +// Arbres dont les modules ne lisent aucune source non reproductible (§ 14.7) : +// le moteur et le générateur de démonstrations, puis le stockage et +// l'analyseur CSV, qui reçoivent l'horloge et l'aléa de l'application, en +// paramètre. +const ARBRES_DETERMINISTES = ['src/moteur', 'src/demo', 'src/stockage', 'src/csv']; + +// Vrai quand le motif se lit dans le texte à partir de l'indice donné, et non +// plus loin : une copie collante du motif, ses drapeaux g et y retirés. +function seLitA(motif, texte, indice) { + const collant = new RegExp(motif.source, `${motif.flags.replace(/[gy]/g, '')}y`); + collant.lastIndex = indice; + return collant.test(texte); +} // Relevé « fichier:ligne appel » des sources non reproductibles et des -// parcours de clés non admis dans les modules de src/moteur et de src/demo, -// par fichier puis par ligne, et sur une même ligne dans l'ordre de -// SOURCES_NON_REPRODUCTIBLES puis de PARCOURS_DE_CLES. Les épreuves en sont -// exclues : un tirage y sert légitimement (§ 14.12). Suit une ligne -// « exception sans objet : fichier « texte » » par exception qui n'admet -// rien, dans l'ordre de la liste. Lève quand l'un des deux arbres ne fournit -// aucun module. +// parcours de clés non admis dans les modules de ARBRES_DETERMINISTES, arbre +// par arbre, puis par fichier et par ligne, et sur une même ligne dans +// l'ordre de SOURCES_NON_REPRODUCTIBLES puis de PARCOURS_DE_CLES. Les +// épreuves en sont exclues : un tirage y sert légitimement (§ 14.12). Suit une +// ligne « exception sans objet : fichier /motif/ » par exception qui n'admet +// rien, dans l'ordre de la liste. Lève quand l'un des arbres ne fournit aucun +// module. function releverDeterminisme(racine, admis = PARCOURS_DE_CLES_ADMIS) { const servies = new Set(); - const estAdmis = (fichier, texteDeLigne) => { - const exception = admis.find((e) => e.fichier === fichier && e.ligne === texteDeLigne); + // Vrai quand une exception du fichier admet le parcours relevé à l'indice + // donné : son motif s'y lit. + const estAdmis = (fichier, texte, indice) => { + const exception = admis.find((e) => e.fichier === fichier && seLitA(e.motif, texte, indice)); if (exception !== undefined) servies.add(exception); return exception !== undefined; }; - const releve = ['src/moteur', 'src/demo'].flatMap((arbre) => { + const releve = ARBRES_DETERMINISTES.flatMap((arbre) => { const modules = modulesDe(join(racine, arbre)); assert.ok(modules.length > 0, `${arbre} ne fournit aucun module`); return modules.flatMap((fichier) => { const nom = relative(racine, fichier); const texte = readFileSync(fichier, 'utf8'); - const lignes = texte.split('\n'); const trouves = (motifs) => - motifs.flatMap(([appel, motif]) => - [...texte.matchAll(motif)].map(({ index }) => ({ ligne: ligneDe(texte, index), appel })), - ); + motifs.flatMap(([appel, motif]) => [...texte.matchAll(motif)].map(({ index }) => ({ index, appel }))); return [ ...trouves(SOURCES_NON_REPRODUCTIBLES), - ...trouves(PARCOURS_DE_CLES).filter(({ ligne }) => !estAdmis(nom, lignes[ligne - 1].trim())), + ...trouves(PARCOURS_DE_CLES).filter(({ index }) => !estAdmis(nom, texte, index)), ] + .map(({ index, appel }) => ({ ligne: ligneDe(texte, index), appel })) .sort((a, b) => a.ligne - b.ligne) .map(({ ligne, appel }) => `${nom}:${ligne} ${appel}`); }); }); const sansObjet = admis .filter((exception) => !servies.has(exception)) - .map(({ fichier, ligne }) => `exception sans objet : ${fichier} « ${ligne} »`); + .map(({ fichier, motif }) => `exception sans objet : ${fichier} ${motif}`); return [...releve, ...sansObjet]; } +// Un module vide dans chaque arbre que les gardes exigent : une donnée +// d'épreuve y ajoute ce qu'elle éprouve, ou en retire un arbre (sansArbre) +// pour éprouver le refus d'un balayage vide. +const MODULES_VIDES = Object.freeze({ + 'src/moteur/a.js': '', + 'src/demo/a.js': '', + 'src/stockage/a.js': '', + 'src/csv/a.js': '', +}); + +// Les fichiers donnés, sans ceux de l'arbre nommé. +const sansArbre = (fichiers, arbre) => + Object.fromEntries(Object.entries(fichiers).filter(([chemin]) => !chemin.startsWith(`${arbre}/`))); + +// Le relevé du déterminisme sans exception : une donnée d'épreuve ne porte pas +// les fichiers que nomment les exceptions de l'arbre réel. +const releverSansException = (racine) => releverDeterminisme(racine, []); + describe('arborescence : déterminisme (§ 14.7)', () => { - test("ni src/moteur ni src/demo ne lisent une source non reproductible, ni ne parcourent les clés d'un objet hors des exceptions nommées", () => { + test("ni src/moteur, ni src/demo, ni src/stockage, ni src/csv ne lisent une source non reproductible, ni ne parcourent les clés d'un objet hors des exceptions nommées", () => { assert.deepEqual(releverDeterminisme(RACINE), []); }); test('les tirages de node:crypto se relèvent comme ceux du navigateur', () => { const fichiers = { + ...MODULES_VIDES, 'src/moteur/a.js': 'export const a = 1;\n', 'src/demo/tirage.js': [ "import { randomInt, randomBytes, randomFillSync } from 'node:crypto';", @@ -178,7 +225,7 @@ describe('arborescence : déterminisme (§ 14.7)', () => { 'export const c = randomFillSync(new Uint8Array(4));', ].join('\n'), }; - assert.deepEqual(avecArbre(fichiers, releverDeterminisme), [ + assert.deepEqual(avecArbre(fichiers, releverSansException), [ 'src/demo/tirage.js:1 crypto.randomInt', 'src/demo/tirage.js:1 crypto.randomBytes', 'src/demo/tirage.js:1 crypto.randomFill', @@ -190,6 +237,7 @@ describe('arborescence : déterminisme (§ 14.7)', () => { test('la garde relève chaque appel par sa ligne, à toute profondeur, hors épreuves, et refuse un arbre sans module', () => { const fichiers = { + ...MODULES_VIDES, 'src/moteur/tirage.js': [ 'export const a = Math.random();', 'export const b = Date.now() - performance.now();', @@ -233,7 +281,7 @@ describe('arborescence : déterminisme (§ 14.7)', () => { 'src/demo/tirage.test.mjs': 'export const o = Math.random();\n', 'src/demo/tirage.test.cjs': 'module.exports = Math.random();\n', }; - assert.deepEqual(avecArbre(fichiers, releverDeterminisme), [ + assert.deepEqual(avecArbre(fichiers, releverSansException), [ 'src/moteur/sous/profond.js:1 Math.random', 'src/moteur/tirage.js:1 Math.random', 'src/moteur/tirage.js:2 Date.now', @@ -267,18 +315,47 @@ describe('arborescence : déterminisme (§ 14.7)', () => { 'src/demo/tirage.cjs:1 Date.now', 'src/demo/tirage.mjs:1 Math.random', ]); - assert.throws( - () => avecArbre({ 'src/demo/propre.js': '' }, releverDeterminisme), - /src\/moteur ne fournit aucun module/, - ); - assert.throws( - () => avecArbre({ 'src/moteur/a.js': '', 'src/demo/a.test.js': '' }, releverDeterminisme), - /src\/demo ne fournit aucun module/, - ); + for (const arbre of ['src/moteur', 'src/demo', 'src/stockage', 'src/csv']) { + const sansModule = { ...sansArbre(MODULES_VIDES, arbre), [`${arbre}/a.test.js`]: '' }; + assert.throws( + () => avecArbre(sansModule, releverSansException), + new RegExp(`${arbre} ne fournit aucun module`), + arbre, + ); + } + }); + + test('src/stockage et src/csv se relèvent comme le moteur et le générateur, à toute profondeur, hors épreuves', () => { + const fichiers = { + ...MODULES_VIDES, + 'src/stockage/horloge.js': [ + 'export const a = Date.now();', + 'export const b = (o) => Object.keys(o).map((cle) => o[cle]);', + 'export const c = new Date();', + ].join('\n'), + 'src/stockage/sous/tri.js': 'export const d = (t) => t.sort((x, y) => x.localeCompare(y));\n', + 'src/stockage/horloge.test.js': 'export const e = Date.now();\n', + 'src/csv/tirage.js': [ + 'export const f = crypto.randomUUID();', + 'for (const champ in ligne) t.push(champ);', + ].join('\n'), + 'src/csv/sous/profond.js': 'export const g = performance.now();\n', + 'src/csv/tirage.test.js': 'export const h = Math.random();\n', + }; + assert.deepEqual(avecArbre(fichiers, releverSansException), [ + 'src/stockage/horloge.js:1 Date.now', + 'src/stockage/horloge.js:2 Object.keys', + 'src/stockage/horloge.js:3 new Date', + 'src/stockage/sous/tri.js:1 localeCompare', + 'src/csv/sous/profond.js:1 performance.now', + 'src/csv/tirage.js:1 crypto.randomUUID', + 'src/csv/tirage.js:2 for…in', + ]); }); test("les parcours des clés d'un objet se relèvent par leur ligne, hors épreuves, et pas leurs voisins (§ 15.5, point 4)", () => { const fichiers = { + ...MODULES_VIDES, 'src/moteur/cles.js': [ 'export const a = Object.keys(o);', 'export const b = Object.entries(o).map(f);', @@ -309,7 +386,7 @@ describe('arborescence : déterminisme (§ 14.7)', () => { '/** @param {Object[]} entrees les entrées, dans leur ordre */', ].join('\n'), }; - assert.deepEqual(avecArbre(fichiers, releverDeterminisme), [ + assert.deepEqual(avecArbre(fichiers, releverSansException), [ 'src/moteur/cles.js:1 Object.keys', 'src/moteur/cles.js:2 Object.entries', 'src/moteur/cles.js:3 Object.values', @@ -328,29 +405,37 @@ describe('arborescence : déterminisme (§ 14.7)', () => { ]); }); - test("une exception nommée n'admet que la ligne qu'elle cite, dans son fichier, et qu'un parcours de clés ; une exception sans objet se relève", () => { - const somme = 'export const total = (o) => Object.values(o).reduce((s, v) => s + v, 0);'; + test("une exception nommée par motif n'admet, dans son fichier, que les parcours de sa forme, sur une ligne ou plusieurs ; une exception sans objet se relève", () => { + const somme = 'Object.values(o).reduce((s, v) => s + v, 0)'; const fichiers = { + ...MODULES_VIDES, 'src/moteur/a.js': 'export const a = Math.random();\n', 'src/demo/somme.js': [ - somme, + `export const total = (o) => ${somme};`, 'export const nombre = (o) => Object.keys(o).length;', - 'export const double = (o) => Object.values(o).reduce((s, v) => s + 2 * v, 0);', + `export const double = (o) => 2 * ${somme} + Object.values(o).length;`, + 'export const reste = (o) =>', + ' Object', + ' .values(o).reduce((s, v) => s + v, 0);', ].join('\n'), - 'src/demo/autre.js': `${somme}\n`, + 'src/demo/autre.js': `export const total = (o) => ${somme};\n`, }; const exceptions = [ - { fichier: 'src/demo/somme.js', ligne: somme, raison: "une somme d'entiers ne dépend pas de l'ordre de ses termes" }, - { fichier: 'src/moteur/a.js', ligne: 'export const a = Math.random();', raison: 'un tirage ne se nomme pas en exception' }, - { fichier: 'src/demo/disparu.js', ligne: 'for (const k in o) t.push(k);', raison: 'la ligne a disparu' }, + { + fichier: 'src/demo/somme.js', + motif: /Object\s*\.\s*values\(o\)\.reduce\(\(s, v\) => s \+ v, 0\)/, + raison: "une somme d'entiers ne dépend pas de l'ordre de ses termes", + }, + { fichier: 'src/moteur/a.js', motif: /Math\.random\(\)/, raison: 'un tirage ne se nomme pas en exception' }, + { fichier: 'src/demo/disparu.js', motif: /for \(const k in o\)/, raison: 'le fichier a disparu' }, ]; assert.deepEqual(avecArbre(fichiers, (racine) => releverDeterminisme(racine, exceptions)), [ 'src/moteur/a.js:1 Math.random', 'src/demo/autre.js:1 Object.values', 'src/demo/somme.js:2 Object.keys', 'src/demo/somme.js:3 Object.values', - 'exception sans objet : src/moteur/a.js « export const a = Math.random(); »', - 'exception sans objet : src/demo/disparu.js « for (const k in o) t.push(k); »', + `exception sans objet : src/moteur/a.js ${exceptions[1].motif}`, + `exception sans objet : src/demo/disparu.js ${exceptions[2].motif}`, ]); }); }); @@ -370,12 +455,57 @@ const RELATIF = /^\.{1,2}(?:\/|$)/; const nomDePaquet = (specificateur) => specificateur.split('/').slice(0, specificateur.startsWith('@') ? 2 : 1).join('/'); -// Ce que ni le moteur ni la géométrie ne chargent (§ 13.4), outre les -// modules natifs de Node : les paquets de l'interface et des plateformes, -// sous-chemins compris, dont le motif s'applique au nom de paquet, et les -// couches de src/ qui touchent l'écran ou les fichiers. -const PAQUETS_EXTERIEURS = /^(?:svelte|electron|@capacitor\/.+)$/; -const COUCHES_EXTERIEURES = ['interface', 'stockage']; +// Les paquets de l'interface et des plateformes, sous-chemins compris, dont +// le motif s'applique au nom de paquet. +const PAQUETS_DE_PLATEFORME = /^(?:svelte|electron|@capacitor\/.+)$/; + +// La frontière de chaque couche (§ 13.4), dans l'ordre du relevé. arbres : +// ceux de ses modules, dont requis nomme ceux qui doivent en fournir au moins +// un — une couche encore à écrire peut manquer. exterieures : les couches de +// src/ qu'elle n'importe pas. paquets : ceux qu'elle refuse, outre les +// modules natifs de Node, que la page n'a pas et qu'aucune couche ne charge. +// navigateur : null quand la couche y accède librement ; sinon, les fichiers +// admis à y accéder, chacun dans son seul texte. Aucun module du parcours +// n'importe un fichier admis : il ne passe qu'en paramètre, de la main de +// l'application. +// +// Le moteur et la géométrie ne connaissent ni l'écran, ni les fichiers, ni ce +// qui les commande. Le stockage lit et écrit par le système de fichiers qu'il +// reçoit ; seules ses deux implémentations de plateforme touchent le +// navigateur, par l'OPFS sous web et par le pont window.gtt sous Electron. +// L'analyseur CSV lit le modèle du stockage. L'application câble le moteur, +// le stockage, le CSV et les systèmes de fichiers des plateformes, sous +// l'interface. +const COUCHES = [ + { + arbres: ['src/moteur', 'src/geometrie'], + requis: ['src/moteur'], + exterieures: ['interface', 'application', 'stockage'], + paquets: PAQUETS_DE_PLATEFORME, + navigateur: [], + }, + { + arbres: ['src/stockage'], + requis: ['src/stockage'], + exterieures: ['interface', 'application', 'csv'], + paquets: PAQUETS_DE_PLATEFORME, + navigateur: ['src/stockage/fichiers_web.js', 'src/stockage/fichiers_electron.js'], + }, + { + arbres: ['src/csv'], + requis: ['src/csv'], + exterieures: ['interface', 'application'], + paquets: PAQUETS_DE_PLATEFORME, + navigateur: [], + }, + { + arbres: ['src/application'], + requis: [], + exterieures: ['interface'], + paquets: /^svelte$/, + navigateur: null, + }, +]; // Un accès au navigateur est un nom de document, de window, de navigator, de // location, de localStorage ou de sessionStorage suivi d'un crochet, ou @@ -383,11 +513,127 @@ const COUCHES_EXTERIEURES = ['interface', 'stockage']; // suivante. Une phrase qui continue après « document. » sur la même ligne // n'en est pas un, ni une ligne qui finit sur ce mot quand la suivante // s'ouvre sur autre chose qu'un nom, un commentaire par exemple. Le relevé -// ne distingue pas le commentaire du code : une ligne de commentaire qui -// finit sur « document. » juste avant une ligne de code se lit comme un -// accès. +// lit le texte que rend sansChaines : le spécificateur « './document.js' » +// nomme un fichier, et une chaîne n'accède à rien. Il ne distingue pas le +// commentaire du code : une ligne de commentaire qui finit sur « document. » +// juste avant une ligne de code se lit comme un accès. const ACCES_AU_DOM = /\b(document|window|navigator|location|localStorage|sessionStorage)\s*(?:\??\.(?=[\p{L}_$]|[ \t]*\r?\n\s*[\p{L}_$])|(?:\?\.)?\s*\[)/gu; +// Mots après lesquels une barre oblique ouvre une expression régulière +// littérale, comme après un opérateur, et non une division. +const MOTS_AVANT_EXPRESSION = new Set([ + 'await', 'case', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield', +]); +const LETTRE_DE_MOT = /[\p{L}\p{N}_$]/u; +const BLANC = /\s/; + +// Le texte où chaque caractère d'une chaîne littérale — entre apostrophes, +// entre guillemets, ou partie littérale d'un gabarit entre accents graves — +// devient une espace, délimiteurs et fins de ligne gardés : les indices et +// les numéros de ligne restent ceux du texte. Les commentaires restent tels +// quels, et une apostrophe qui y figure n'ouvre aucune chaîne ; le code +// d'une substitution ${…} reste lu, chaînes blanchies. Une barre oblique +// ouvre une expression régulière littérale après rien, après un opérateur ou +// une ponctuation autre que « ) » et « ] », ou après un mot comme return ; +// l'expression reste telle quelle, et un guillemet de sa classe n'ouvre pas +// de chaîne. Une chaîne entre apostrophes ou guillemets, comme une +// expression, s'arrête au plus tard à la fin de sa ligne : une barre mal lue +// n'y fait blanchir que la fin de cette ligne. Un accent grave mal lu, lui, +// ouvrirait un gabarit jusqu'au suivant. +function sansChaines(texte) { + let sortie = ''; + let i = 0; + const recopier = (fin) => { + sortie += texte.slice(i, fin); + i = fin; + }; + const blanchir = (fin) => { + for (; i < fin; i += 1) sortie += texte[i] === '\n' ? '\n' : ' '; + }; + // Une chaîne : jusqu'à son délimiteur non échappé, ou jusqu'à la fin de la + // ligne, qu'une barre oblique inverse finale prolonge. + const chaine = () => { + const delimiteur = texte[i]; + recopier(i + 1); + let fin = i; + while (fin < texte.length && texte[fin] !== delimiteur && texte[fin] !== '\n') { + fin += texte[fin] === '\\' ? 2 : 1; + } + blanchir(Math.min(fin, texte.length)); + if (texte[i] === delimiteur) recopier(i + 1); + }; + // Une expression régulière : jusqu'à la barre oblique non échappée hors + // d'une classe, ou jusqu'à la fin de la ligne, puis ses drapeaux. + const expression = () => { + let fin = i + 1; + let classe = false; + while (fin < texte.length && texte[fin] !== '\n' && (classe || texte[fin] !== '/')) { + if (texte[fin] === '\\') fin += 1; + else if (texte[fin] === '[') classe = true; + else if (texte[fin] === ']') classe = false; + fin += 1; + } + if (texte[fin] === '/') fin += 1; + while (fin < texte.length && LETTRE_DE_MOT.test(texte[fin])) fin += 1; + recopier(Math.min(fin, texte.length)); + }; + // Du code, jusqu'à la fin du texte, ou, dans une substitution, jusqu'à + // l'accolade qui la ferme, laissée à l'appelant. + const code = (substitution) => { + let profondeur = 0; + let expressionPermise = true; + while (i < texte.length) { + const c = texte[i]; + if (c === '/' && texte[i + 1] === '/') { + const fin = texte.indexOf('\n', i); + recopier(fin === -1 ? texte.length : fin); + } else if (c === '/' && texte[i + 1] === '*') { + const fin = texte.indexOf('*/', i + 2); + recopier(fin === -1 ? texte.length : fin + 2); + } else if (c === "'" || c === '"') { + chaine(); + expressionPermise = false; + } else if (c === '`') { + gabarit(); + expressionPermise = false; + } else if (c === '/' && expressionPermise) { + expression(); + expressionPermise = false; + } else if (LETTRE_DE_MOT.test(c)) { + let fin = i + 1; + while (fin < texte.length && LETTRE_DE_MOT.test(texte[fin])) fin += 1; + expressionPermise = MOTS_AVANT_EXPRESSION.has(texte.slice(i, fin)); + recopier(fin); + } else { + if (substitution && c === '}' && profondeur === 0) return; + if (c === '{') profondeur += 1; + if (c === '}') profondeur -= 1; + if (!BLANC.test(c)) expressionPermise = c !== ')' && c !== ']'; + recopier(i + 1); + } + } + }; + // Un gabarit : ses parties littérales blanchies, chaque substitution lue + // comme du code. + const gabarit = () => { + recopier(i + 1); + while (i < texte.length && texte[i] !== '`') { + if (texte[i] === '\\') { + blanchir(Math.min(i + 2, texte.length)); + } else if (texte[i] === '$' && texte[i + 1] === '{') { + recopier(i + 2); + code(true); + if (i < texte.length) recopier(i + 1); + } else { + blanchir(i + 1); + } + } + if (i < texte.length) recopier(i + 1); + }; + code(false); + return sortie; +} + // Chemin que désigne un spécificateur relatif, ou absolu depuis la racine // comme Vite le résout ; null pour un spécificateur nu ou une URL. function cibleDe(racine, fichier, specificateur) { @@ -396,40 +642,47 @@ function cibleDe(racine, fichier, specificateur) { return null; } -// Relevé des refus de la frontière des couches dans le graphe d'imports du -// moteur et de la géométrie. Le parcours part des modules de src/moteur et -// de src/geometrie, à toute profondeur, épreuves exclues. Il suit chaque -// chemin relatif, ou absolu depuis la racine, vers un module JavaScript -// existant, chaque module une fois. Un fichier d'une couche extérieure n'est -// pas parcouru : l'importer est déjà un refus. Un chemin sans extension en -// est un aussi : Node ne le résout pas (§ 14.8), et le parcours ne suit pas -// le module que Vite y trouverait. Un module natif de Node, que reconnaît -// isBuiltin, est refusé : la page où le moteur s'exécute n'en a aucun. -// Chaque refus se lit « accès:ligne refus », où l'accès est la suite des -// modules qui mène d'un module de départ à celui qui refuse. src/geometrie -// peut manquer ; lève quand src/moteur ne fournit aucun module. -function releverFrontiere(racine) { - const moteur = modulesDe(join(racine, 'src', 'moteur')); - assert.ok(moteur.length > 0, 'src/moteur ne fournit aucun module'); - const couches = COUCHES_EXTERIEURES.map((couche) => join(racine, 'src', couche)); +// Relevé des refus de la frontière d'une couche dans son graphe d'imports. +// Le parcours part des modules de ses arbres, à toute profondeur, épreuves +// exclues. Il suit chaque chemin relatif, ou absolu depuis la racine, vers un +// module JavaScript existant, chaque module une fois. Un type que la JSDoc +// importe par import(…) compte comme un import. Un fichier d'une couche +// extérieure n'est pas parcouru : l'importer est déjà un refus, comme +// importer un fichier admis au navigateur. Un chemin sans extension en est un +// aussi : Node ne le résout pas (§ 14.8), et le parcours ne suit pas le +// module que Vite y trouverait. Un module natif de Node, que reconnaît +// isBuiltin, est refusé : la page où la couche s'exécute n'en a aucun. Un +// accès au navigateur n'est admis que dans le texte d'un fichier admis, parti +// de lui-même. Chaque refus se lit « accès:ligne refus », où l'accès est la +// suite des modules qui mène d'un module de départ à celui qui refuse. Lève +// quand un arbre requis ne fournit aucun module. +function releverCouche(racine, { arbres, requis, exterieures, paquets, navigateur }) { + for (const arbre of requis) { + assert.ok(modulesDe(join(racine, arbre)).length > 0, `${arbre} ne fournit aucun module`); + } + const couches = exterieures.map((couche) => join(racine, 'src', couche)); const exterieur = (chemin) => couches.some((c) => chemin === c || chemin.startsWith(c + sep)); - const aParcourir = [...moteur, ...modulesDe(join(racine, 'src', 'geometrie'))]; + const admis = new Set((navigateur ?? []).map((fichier) => join(racine, fichier))); + const aParcourir = arbres.flatMap((arbre) => modulesDe(join(racine, arbre))); const acces = new Map(aParcourir.map((fichier) => [fichier, relative(racine, fichier)])); const refus = []; for (let i = 0; i < aParcourir.length; i += 1) { const fichier = aParcourir[i]; const texte = readFileSync(fichier, 'utf8'); - const trouves = [...texte.matchAll(ACCES_AU_DOM)].map(({ 0: lu, 1: nom, index }) => ({ - indice: index, - motif: nom + lu.at(-1), - })); + const trouves = + navigateur === null || admis.has(fichier) + ? [] + : [...sansChaines(texte).matchAll(ACCES_AU_DOM)].map(({ 0: lu, 1: nom, index }) => ({ + indice: index, + motif: nom + lu.at(-1), + })); for (const { specificateur, indice } of chargementsLitteraux(texte)) { const cible = cibleDe(racine, fichier, specificateur); if (cible === null) { - if (isBuiltin(specificateur) || PAQUETS_EXTERIEURS.test(nomDePaquet(specificateur))) { + if (isBuiltin(specificateur) || paquets.test(nomDePaquet(specificateur))) { trouves.push({ indice, motif: `import ${specificateur}` }); } - } else if (exterieur(cible)) { + } else if (exterieur(cible) || admis.has(cible)) { trouves.push({ indice, motif: `import ${specificateur}` }); } else if (extname(cible) === '') { trouves.push({ indice, motif: `import ${specificateur} sans extension` }); @@ -446,9 +699,13 @@ function releverFrontiere(racine) { return refus; } +// Relevé des refus de la frontière de chaque couche, dans l'ordre de COUCHES. +const releverFrontiere = (racine) => COUCHES.flatMap((couche) => releverCouche(racine, couche)); + describe('arborescence : frontière des couches (§ 13.4)', () => { test('les globales du navigateur hors du DOM se refusent comme lui : navigator, location, localStorage, sessionStorage', () => { const fichiers = { + ...MODULES_VIDES, 'src/moteur/a.js': [ 'export const langue = navigator.language;', 'export const adresse = location.href;', @@ -464,12 +721,13 @@ describe('arborescence : frontière des couches (§ 13.4)', () => { ]); }); - test("le graphe d'imports du moteur et de la géométrie ne rejoint ni l'interface, ni le stockage, ni une plateforme", () => { + test("le graphe d'imports de chaque couche ne rejoint que ce que le § 13.4 lui permet", () => { assert.deepEqual(releverFrontiere(RACINE), []); }); test('la garde suit les imports depuis chaque module à toute profondeur, relève chaque refus par sa ligne, hors épreuves', () => { const fichiers = { + ...MODULES_VIDES, 'src/moteur/a.js': [ "import { mount } from 'svelte';", "import { Capacitor } from '@capacitor/core';", @@ -495,6 +753,8 @@ describe('arborescence : frontière des couches (§ 13.4)', () => { 'const c = require("svelte");', "export const echelle = window?.['devicePixelRatio'];", "import fs from 'fs';", + "import { seance } from '../application/seance.js';", + "/** @param {import('../application/seance.js').Seance} s */", ].join('\n'), 'src/moteur/b.js': [ 'import {', @@ -526,6 +786,8 @@ describe('arborescence : frontière des couches (§ 13.4)', () => { 'src/geometrie/echelle.js': [ 'export const zoom = () => window?.devicePixelRatio;', "import { r } from '/src/commun/racine.js';", + "import { commande } from '../application/commandes.js';", + "/** @returns {import('../application/seance.js').Seance} */", ].join('\n'), }; assert.deepEqual(avecArbre(fichiers, releverFrontiere), [ @@ -549,20 +811,194 @@ describe('arborescence : frontière des couches (§ 13.4)', () => { 'src/moteur/a.js:22 import svelte', 'src/moteur/a.js:23 window[', 'src/moteur/a.js:24 import fs', + 'src/moteur/a.js:25 import ../application/seance.js', + 'src/moteur/a.js:26 import ../application/seance.js', 'src/moteur/b.js:4 import ../interface/App.svelte', 'src/moteur/sous/c.js:1 import ../../interface/x.js', 'src/geometrie/echelle.js:1 window.', + 'src/geometrie/echelle.js:3 import ../application/commandes.js', + 'src/geometrie/echelle.js:4 import ../application/seance.js', 'src/moteur/a.js → src/commun/aide.js:2 window.', 'src/geometrie/echelle.js → src/commun/racine.js:1 window.', + 'src/stockage/fichiers.js:1 import @capacitor/core', ]); }); - test('src/geometrie peut manquer ; un src/moteur sans module fait échouer la garde', () => { - assert.deepEqual(avecArbre({ 'src/moteur/a.js': '' }, releverFrontiere), []); - assert.throws( - () => avecArbre({ 'src/geometrie/echelle.js': '', 'src/moteur/a.test.js': '' }, releverFrontiere), - /src\/moteur ne fournit aucun module/, - ); + test("la garde du navigateur ne lit pas les chaînes littérales : « ./document.js » nomme un fichier ; elle lit le code d'une substitution et les commentaires, suit l'échappement, et distingue la division de l'expression régulière", () => { + const fichiers = { + ...MODULES_VIDES, + 'src/moteur/a.js': [ + "import { SCHEMA } from './document.js';", + 'export const a = \'window.name\' + "navigator.language";', + 'export const b = `document.title`;', + 'export const c = `${document.title}`;', + "export const d = 'l\\'objet'; export const e = window.name;", + 'export const f = x.replace(/["\'`]/g, \'\'); export const g = window.top;', + "// l'accès à document.body se relève, même en commentaire", + 'export const h = y / 2; export const i = location.href / 3;', + "export const j = `a${'location.href'}b` + localStorage.x;", + 'export const k = /\'/.test(s) ? sessionStorage.x : "";', + 'export const l = navigator', + ' .language;', + // Après « ) », « ] » ou un nom, la barre oblique divise, et la chaîne + // qui la suit se blanchit ; après un mot-clé, elle ouvre une + // expression, où l'apostrophe n'ouvre rien. + "export const milieu = (a + b) / 2; export const cle = 'document.title';", + "export const moitie = largeur / 2 + 'window.name'.length;", + "export const quart = cotes[0] / 4 + 'location.href'.length;", + "export const langue = (s) => { return /'/.test(s) ? navigator.language : ''; };", + // Un accent grave échappé ne ferme pas le gabarit ; l'accolade qui + // referme un objet dans une substitution ne ferme pas la substitution. + 'export const m = `\\`` + window.name;', + 'export const n = `x${ {a: 1}.a + document.title }y`;', + // Une barre oblique ne ferme pas l'expression dans une classe, ni + // échappée ; « /* » après un nom ouvre un commentaire, non une division. + "export const p = /[/']/.test(s) ? 'location.href' : document.title;", + "export const q = /\\/'/.test(s) ? 'window.name' : navigator.language;", + "export const r = somme /* l'entier */ + window.name;", + // Une expression après « ) » se lit comme une division : l'apostrophe + // y ouvre une chaîne, que la fin de la ligne arrête. + "if (garde) /'/.test(s) && compter();", + 'export const z = localStorage.x;', + ].join('\n'), + 'src/moteur/document.js': 'export const SCHEMA = 1;\n', + }; + assert.deepEqual(avecArbre(fichiers, releverFrontiere), [ + 'src/moteur/a.js:4 document.', + 'src/moteur/a.js:5 window.', + 'src/moteur/a.js:6 window.', + 'src/moteur/a.js:7 document.', + 'src/moteur/a.js:8 location.', + 'src/moteur/a.js:9 localStorage.', + 'src/moteur/a.js:10 sessionStorage.', + 'src/moteur/a.js:11 navigator.', + 'src/moteur/a.js:16 navigator.', + 'src/moteur/a.js:17 window.', + 'src/moteur/a.js:18 document.', + 'src/moteur/a.js:19 document.', + 'src/moteur/a.js:20 navigator.', + 'src/moteur/a.js:21 window.', + 'src/moteur/a.js:23 localStorage.', + ]); + }); + + test("src/stockage n'importe ni src/csv, ni src/application, ni src/interface, ni une plateforme, et ne touche le navigateur que dans ses deux systèmes de fichiers, que nul n'y importe", () => { + const fichiers = { + ...MODULES_VIDES, + 'src/stockage/depot.js': [ + "import { decouper } from '../csv/lecture.js';", + "import { creerSeance } from '../application/seance.js';", + "import App from '../interface/App.svelte';", + "import { mount } from 'svelte';", + "import { ipcRenderer } from 'electron';", + "import { Capacitor } from '@capacitor/core';", + "import { readFile } from 'node:fs/promises';", + "import { HISTORIQUE } from '../moteur/recherche.js';", + "import { SCHEMA } from './document.js';", + "import { creerFichiersWeb } from './fichiers_web.js';", + "/** @param {import('../application/seance.js').Seance} seance */", + "/** @returns {import('./types.js').Charge} */", + 'export const titre = () => document.title;', + "export const cle = 'localStorage.x';", + "import { voisin } from './voisin';", + ].join('\n'), + 'src/stockage/document.js': 'export const SCHEMA = 1;\n', + 'src/stockage/types.js': '/** @typedef {Object} Charge */\n', + 'src/stockage/sous/memoire.js': "export const lire = () => localStorage.getItem('x');\n", + 'src/stockage/fichiers_web.js': 'export const creerFichiersWeb = () => navigator.storage.getDirectory();\n', + 'src/stockage/fichiers_electron.js': [ + 'export const creerFichiersElectron = (pont = window.gtt.fichiers) => pont;', + "import { creerFichiersWeb } from './fichiers_web.js';", + ].join('\n'), + 'src/stockage/depot.test.js': "import { mount } from 'svelte';\nexport const corps = document.body;\n", + 'src/moteur/recherche.js': 'export const HISTORIQUE = 1000;\nexport const largeur = () => window.innerWidth;\n', + }; + assert.deepEqual(avecArbre(fichiers, releverFrontiere), [ + 'src/moteur/recherche.js:2 window.', + 'src/stockage/depot.js:1 import ../csv/lecture.js', + 'src/stockage/depot.js:2 import ../application/seance.js', + 'src/stockage/depot.js:3 import ../interface/App.svelte', + 'src/stockage/depot.js:4 import svelte', + 'src/stockage/depot.js:5 import electron', + 'src/stockage/depot.js:6 import @capacitor/core', + 'src/stockage/depot.js:7 import node:fs/promises', + 'src/stockage/depot.js:10 import ./fichiers_web.js', + 'src/stockage/depot.js:11 import ../application/seance.js', + 'src/stockage/depot.js:13 document.', + 'src/stockage/depot.js:15 import ./voisin sans extension', + 'src/stockage/fichiers_electron.js:2 import ./fichiers_web.js', + 'src/stockage/sous/memoire.js:1 localStorage.', + 'src/stockage/depot.js → src/moteur/recherche.js:2 window.', + ]); + }); + + test("src/csv n'importe ni src/application, ni src/interface, ni une plateforme, et lit le modèle de src/stockage ; un import de type en JSDoc compte comme un import", () => { + const fichiers = { + ...MODULES_VIDES, + 'src/csv/import.js': [ + "/** @param {import('../stockage/types.js').Charge} charge */", + "import { libelle } from '../application/libelles.js';", + "/** @returns {import('../interface/vue.js').Vue} */", + "import { Capacitor } from '@capacitor/core';", + "import { app } from 'electron';", + "import { writable } from 'svelte/store';", + "import { creerFichiersWeb } from '../stockage/fichiers_web.js';", + "import { SCHEMA } from '../stockage/document.js';", + 'export const largeur = () => window.innerWidth;', + ].join('\n'), + 'src/csv/sous/lecture.js': "import { readFileSync } from 'fs';\n", + 'src/stockage/types.js': '/** @typedef {Object} Charge */\n', + 'src/stockage/document.js': 'export const SCHEMA = 1;\n', + 'src/stockage/fichiers_web.js': 'export const creerFichiersWeb = () => navigator.storage.getDirectory();\n', + }; + assert.deepEqual(avecArbre(fichiers, releverFrontiere), [ + 'src/csv/import.js:2 import ../application/libelles.js', + 'src/csv/import.js:3 import ../interface/vue.js', + 'src/csv/import.js:4 import @capacitor/core', + 'src/csv/import.js:5 import electron', + 'src/csv/import.js:6 import svelte/store', + 'src/csv/import.js:9 window.', + 'src/csv/sous/lecture.js:1 import fs', + 'src/csv/import.js → src/stockage/fichiers_web.js:1 navigator.', + ]); + }); + + test("src/application n'importe ni src/interface, ni svelte, ni un module de Node ; elle câble le stockage, le CSV et les systèmes de fichiers des plateformes", () => { + const fichiers = { + ...MODULES_VIDES, + 'src/application/seance.js': [ + "import App from '../interface/App.svelte';", + "import { mount } from 'svelte';", + "import { creerFichiersWeb } from '../stockage/fichiers_web.js';", + "import { decoder } from '../csv/encodage.js';", + "import { Capacitor } from '@capacitor/core';", + "import { readFile } from 'node:fs/promises';", + 'export const largeur = () => window.innerWidth;', + "/** @param {import('../interface/vue.js').Vue} vue */", + ].join('\n'), + 'src/application/sous/commandes.js': "import { x } from '../../interface/x.js';\n", + 'src/stockage/fichiers_web.js': 'export const creerFichiersWeb = () => navigator.storage.getDirectory();\n', + 'src/csv/encodage.js': 'export const decoder = (octets) => octets;\n', + }; + assert.deepEqual(avecArbre(fichiers, releverFrontiere), [ + 'src/application/seance.js:1 import ../interface/App.svelte', + 'src/application/seance.js:2 import svelte', + 'src/application/seance.js:6 import node:fs/promises', + 'src/application/seance.js:8 import ../interface/vue.js', + 'src/application/sous/commandes.js:1 import ../../interface/x.js', + ]); + }); + + test('src/geometrie et src/application peuvent manquer ; un src/moteur, un src/stockage ou un src/csv sans module fait échouer la garde', () => { + assert.deepEqual(avecArbre(MODULES_VIDES, releverFrontiere), []); + for (const arbre of ['src/moteur', 'src/stockage', 'src/csv']) { + const sansModule = { ...sansArbre(MODULES_VIDES, arbre), [`${arbre}/a.test.js`]: '', 'src/geometrie/echelle.js': '' }; + assert.throws( + () => avecArbre(sansModule, releverFrontiere), + new RegExp(`${arbre} ne fournit aucun module`), + arbre, + ); + } }); }); @@ -770,3 +1206,226 @@ describe('arborescence : un seul propriétaire des valeurs du contrat (types.js) } }); }); + +// Modules que le § 14.13 tient à 100 % de branches : le module des indicateurs +// et du plafond, et les chemins de reprise du stockage. +const MODULES_A_SEUIL = [ + 'src/moteur/indicateurs.js', + 'src/moteur/plafond.js', + 'src/stockage/depot.js', + 'src/stockage/journal.js', +]; + +// Clés de coverage.thresholds qui vaudraient pour tous les fichiers mesurés. +const SEUILS_GLOBAUX = ['statements', 'branches', 'functions', 'lines', '100', 'perFile']; + +// Vrai quand le chemin relatif répond à l'un des motifs de fichiers donnés. +const repondA = (chemin, motifs = []) => motifs.some((motif) => matchesGlob(chemin, motif)); + +// La commande de la mesure : make couverture passe la main à npm run +// couverture, qui lance vitest run sur la série node, couverture active. +// Sans --coverage, la série passe et aucun seuil ne se lit. Outre ces deux +// arguments, le script n'admet que le choix du rapporteur des épreuves : une +// option --coverage.* redéfinirait, pour cette exécution, les seuils, include +// ou exclude que relève releverCouverture, et --changed, --config ou un +// filtre de fichiers changeraient ce qui se mesure. Une option et sa valeur +// se lisent en un argument : « --project node » comme « --project=node ». +const RECETTE_DE_MESURE = '$(AVEC_NODE) npm run couverture'; +const ARGUMENTS_REQUIS = ['--project=node', '--coverage']; +const ARGUMENT_ADMIS = /^(?:--project=node|--coverage|--reporter=\S+)$/; +const OPTION_A_VALEUR = /^--(?:project|reporter)$/; + +// Écarts de la commande de la mesure sous la racine : la recette de la cible +// couverture du Makefile, puis le script couverture de package.json, ses +// arguments requis dans l'ordre de ARGUMENTS_REQUIS, puis ses arguments non +// admis dans l'ordre du script. Un script qui ne commence pas par +// « vitest run » n'est pas lu plus loin. +function ecartsDeLaCommande(racine) { + const ecarts = []; + const makefile = readFileSync(join(racine, 'Makefile'), 'utf8'); + const recette = /^couverture:.*\n((?:\t.*(?:\n|$))*)/m.exec(makefile)?.[1] ?? ''; + if (recette.trim() !== RECETTE_DE_MESURE) ecarts.push('make couverture : ne lance pas npm run couverture'); + const { scripts = {} } = JSON.parse(readFileSync(join(racine, 'package.json'), 'utf8')); + const [programme, sousCommande, ...mots] = (scripts.couverture ?? '').trim().split(/\s+/); + if (programme !== 'vitest' || sousCommande !== 'run') { + return [...ecarts, 'npm run couverture : ne lance pas vitest run']; + } + const lus = []; + for (let i = 0; i < mots.length; i += 1) { + if (OPTION_A_VALEUR.test(mots[i]) && i + 1 < mots.length) { + lus.push(`${mots[i]}=${mots[i + 1]}`); + i += 1; + } else { + lus.push(mots[i]); + } + } + for (const requis of ARGUMENTS_REQUIS) { + if (!lus.includes(requis)) ecarts.push(`npm run couverture : sans ${requis.replace('=', ' ')}`); + } + for (const argument of lus) { + if (!ARGUMENT_ADMIS.test(argument)) ecarts.push(`npm run couverture : argument non admis ${argument}`); + } + return ecarts; +} + +// Écarts des rapports que la configuration nomme : le tableau text, une +// ligne par couche et par module, pleins compris — sans skipFull: false +// écrit, Vitest les tait quand un agent le lance —, puis le rapport html, qui +// montre ligne à ligne ce qui ne s'exécute jamais. Un rapporteur est un nom ou +// un couple [nom, options] ; reporter, un rapporteur seul ou leur liste. +function ecartsDuRapport(reporter) { + const rapporteurs = (Array.isArray(reporter) ? reporter : [reporter]).map((r) => (Array.isArray(r) ? r : [r])); + const ecarts = []; + if (!rapporteurs.some(([nom, options]) => nom === 'text' && options?.skipFull === false)) { + ecarts.push('rapport : aucun tableau text à skipFull: false'); + } + if (!rapporteurs.some(([nom]) => nom === 'html')) ecarts.push('rapport : aucun rapport html'); + return ecarts; +} + +// Relevé des écarts de la mesure au § 14.13, sur l'arbre qu'elle mesure : +// ceux de la commande, puis ceux des rapports ; puis, dans la configuration +// de couverture de Vitest, un seuil global, qu'un pourcentage d'ensemble +// satisfait en couvrant les modules faciles ; un seuil hors de +// MODULES_A_SEUIL ; un module de MODULES_A_SEUIL dont le seuil de branches +// n'est pas 100 ; un module absent de l'arbre, ou hors de la mesure — hors de +// coverage.include, ou dans coverage.exclude. Vitest tient pour atteint un +// seuil qui ne compte aucune branche, sans rien dire : sans ces deux derniers +// relevés, un module renommé passerait. Les écarts viennent dans cet ordre, +// module par module dans l'ordre de MODULES_A_SEUIL. Lève quand la +// configuration, le Makefile ou package.json manque. +function releverCouverture(racine, couverture) { + assert.ok(couverture, 'aucune configuration de couverture'); + const { thresholds = {}, include, exclude, reporter } = couverture; + const ecarts = [ + ...ecartsDeLaCommande(racine), + ...ecartsDuRapport(reporter), + ...SEUILS_GLOBAUX.filter((cle) => Object.hasOwn(thresholds, cle)).map((cle) => `seuil global : ${cle}`), + ]; + for (const cle of Object.keys(thresholds).sort()) { + if (!SEUILS_GLOBAUX.includes(cle) && !MODULES_A_SEUIL.includes(cle)) { + ecarts.push(`seuil hors des modules nommés : ${cle}`); + } + } + for (const module of MODULES_A_SEUIL) { + const seuil = thresholds[module]; + if (seuil === undefined) ecarts.push(`${module} : aucun seuil`); + else if (seuil.branches !== 100) ecarts.push(`${module} : branches à ${seuil.branches}`); + if (!existsSync(join(racine, module))) ecarts.push(`${module} : absent de l'arbre`); + else if (!repondA(module, include) || repondA(module, exclude)) ecarts.push(`${module} : hors de la mesure`); + } + return ecarts; +} + +// Une mesure conforme au § 14.13, que les données d'épreuve altèrent une +// pièce à la fois : les quatre modules, le script de package.json, la recette +// du Makefile, et la configuration de couverture. +const scriptDeMesure = (script) => JSON.stringify({ scripts: { couverture: script } }); +const MODULES_MESURES = Object.freeze(Object.fromEntries(MODULES_A_SEUIL.map((module) => [module, '']))); +const COMMANDE_CONFORME = Object.freeze({ + 'package.json': scriptDeMesure('vitest run --project node --coverage --reporter=default'), + Makefile: '.PHONY: couverture\ncouverture: ## Mesure la couverture\n\t$(AVEC_NODE) npm run couverture\n', +}); +const RAPPORT_CONFORME = Object.freeze([['text', { skipFull: false }], 'html']); +const COUVERTURE_CONFORME = Object.freeze({ + include: ['src/**/*.js'], + reporter: RAPPORT_CONFORME, + thresholds: Object.fromEntries(MODULES_A_SEUIL.map((module) => [module, { branches: 100 }])), +}); + +describe('arborescence : la couverture (§ 14.13)', () => { + test("make couverture et npm run couverture mesurent la série node ; le rapport montre chaque module ; aucun seuil global ; 100 % de branches sur chaque module nommé, présent dans l'arbre et dans la mesure", () => { + assert.deepEqual(releverCouverture(RACINE, configuration.test.coverage), []); + }); + + test("la garde relève un seuil global, un seuil hors des modules nommés, abaissé ou absent, un module absent de l'arbre ou de la mesure", () => { + const couverture = { + include: ['src/**/*.js'], + exclude: ['**/*.test.js', 'src/stockage/depot.js'], + reporter: RAPPORT_CONFORME, + thresholds: { + branches: 90, + perFile: true, + 'src/moteur/indicateurs.js': { branches: 100 }, + 'src/moteur/plafond.js': { branches: 95 }, + 'src/stockage/journal.js': { branches: 100, lines: 100 }, + 'src/csv/**': { branches: 100 }, + }, + }; + const fichiers = { + ...COMMANDE_CONFORME, + 'src/moteur/indicateurs.js': '', + 'src/stockage/depot.js': '', + 'src/stockage/journal.js': '', + }; + assert.deepEqual(avecArbre(fichiers, (racine) => releverCouverture(racine, couverture)), [ + 'seuil global : branches', + 'seuil global : perFile', + 'seuil hors des modules nommés : src/csv/**', + 'src/moteur/plafond.js : branches à 95', + "src/moteur/plafond.js : absent de l'arbre", + 'src/stockage/depot.js : aucun seuil', + 'src/stockage/depot.js : hors de la mesure', + ]); + const sansInclude = { ...couverture, include: undefined, exclude: undefined, thresholds: {} }; + assert.deepEqual( + avecArbre(fichiers, (racine) => releverCouverture(racine, sansInclude)).filter((e) => e.endsWith('mesure')), + ['src/moteur/indicateurs.js : hors de la mesure', 'src/stockage/depot.js : hors de la mesure', 'src/stockage/journal.js : hors de la mesure'], + ); + assert.throws(() => releverCouverture(RACINE, undefined), /aucune configuration de couverture/); + }); + + test('la garde relève une commande qui ne mesure pas la série node, ou qui redéfinit la mesure ; make couverture passe par npm run couverture', () => { + const relever = (fichiers) => + avecArbre({ ...MODULES_MESURES, ...COMMANDE_CONFORME, ...fichiers }, (racine) => + releverCouverture(racine, COUVERTURE_CONFORME), + ); + assert.deepEqual(relever({}), []); + assert.deepEqual(relever({ 'package.json': scriptDeMesure('vitest run --project=node --coverage --reporter default') }), []); + const scripts = [ + ['vitest run --project node --reporter=default', ['npm run couverture : sans --coverage']], + [ + 'vitest run --project node --coverage --coverage.include=src/moteur/indicateurs.js --coverage.exclude=src/stockage/journal.js', + [ + 'npm run couverture : argument non admis --coverage.include=src/moteur/indicateurs.js', + 'npm run couverture : argument non admis --coverage.exclude=src/stockage/journal.js', + ], + ], + [ + 'vitest run --project node-long --coverage', + ['npm run couverture : sans --project node', 'npm run couverture : argument non admis --project=node-long'], + ], + [ + 'vitest run --project node --coverage --changed src/moteur', + ['npm run couverture : argument non admis --changed', 'npm run couverture : argument non admis src/moteur'], + ], + ['vitest --project node --coverage', ['npm run couverture : ne lance pas vitest run']], + ]; + for (const [script, attendu] of scripts) { + assert.deepEqual(relever({ 'package.json': scriptDeMesure(script) }), attendu, script); + } + for (const makefile of ['couverture:\n\t$(AVEC_NODE) npm test\n', 'mesure:\n\t$(AVEC_NODE) npm run couverture\n']) { + assert.deepEqual(relever({ Makefile: makefile }), ['make couverture : ne lance pas npm run couverture'], makefile); + } + }); + + test("la garde relève un rapport qui tait les modules pleins, ou qui ne montre pas, ligne à ligne, ce qui ne s'exécute jamais", () => { + const relever = (reporter) => + avecArbre({ ...MODULES_MESURES, ...COMMANDE_CONFORME }, (racine) => + releverCouverture(racine, { ...COUVERTURE_CONFORME, reporter }), + ); + const sansTableau = 'rapport : aucun tableau text à skipFull: false'; + const sansHtml = 'rapport : aucun rapport html'; + assert.deepEqual(relever([['html'], ['text', { skipFull: false, maxCols: 120 }]]), []); + const rapporteurs = [ + [['text-summary'], [sansTableau, sansHtml]], + [['text', 'html'], [sansTableau]], + [[['text', { skipFull: true }], 'html'], [sansTableau]], + ['html', [sansTableau]], + [[['text', { skipFull: false }]], [sansHtml]], + ]; + for (const [reporter, attendu] of rapporteurs) { + assert.deepEqual(relever(reporter), attendu, JSON.stringify(reporter)); + } + }); +}); diff --git a/vitest.config.js b/vitest.config.js index bdd0e90..97bb0d2 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -32,6 +32,32 @@ export default defineConfig({ // l'exécution quand node ou node-long dépasse son plafond (§ 14.14). Un // chemin relatif se lit depuis la racine du projet. reporters: ['default', './test/rapporteur.js'], + // La couverture (§ 14.13), que lancent npm run couverture et make + // couverture : la série node sous l'instrumentation de V8, sans le + // rapporteur, dont les plafonds bornent la série surveillée et la série + // longue, non une mesure. Aucun seuil global : un pourcentage d'ensemble + // se satisfait en couvrant les modules faciles. Le tableau imprime une + // ligne par couche et par module de src/, pleins compris — que Vitest + // tait sinon quand un agent le lance — et à 0 % ceux qu'aucune épreuve ne + // charge ; coverage/index.html montre, ligne à ligne, ce qui n'est jamais + // exécuté. Quatre modules tiennent 100 % de branches, seuil dur : la + // commande échoue en deçà. Une branche nue s'y couvre par un cas ajouté, + // ou part avec le code mort ; le seuil ne s'abaisse pas, et + // test/arborescence.test.js garde ces quatre seuils, ces deux rapports + // et les deux commandes qui lancent la mesure. + coverage: { + provider: 'v8', + include: ['src/**/*.js'], + exclude: ['src/**/*.test.js'], + reporter: [['text', { skipFull: false }], 'html'], + reportsDirectory: 'coverage', + thresholds: { + 'src/moteur/indicateurs.js': { branches: 100 }, + 'src/moteur/plafond.js': { branches: 100 }, + 'src/stockage/depot.js': { branches: 100 }, + 'src/stockage/journal.js': { branches: 100 }, + }, + }, // Sous surveillance, une épreuve se relance quand un module de son graphe // d'imports change. Les fichiers des motifs se lisent hors de tout // graphe : le contrôle de version (§ 18.5) lit les premiers par le