diff --git a/test/arborescence.test.js b/test/arborescence.test.js new file mode 100644 index 0000000..4c44ba4 --- /dev/null +++ b/test/arborescence.test.js @@ -0,0 +1,587 @@ +// © 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) ; 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. 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. +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 { fileURLToPath } from 'node:url'; +import { describe, test } from './lanceur.js'; + +const RACINE = fileURLToPath(new URL('..', import.meta.url)); + +// É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(), 'arborescence-')); + 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 }); + } +} + +// Fichiers d'un arbre, à toute profondeur, en chemins absolus. Un arbre +// absent n'en fournit aucun. +function fichiersDe(dossier) { + try { + return readdirSync(dossier, { withFileTypes: true, recursive: true }) + .filter((e) => e.isFile()) + .map((e) => join(e.parentPath, e.name)); + } catch (erreur) { + if (erreur.code === 'ENOENT') return []; + throw erreur; + } +} + +// Modules d'un arbre : ses fichiers JavaScript, épreuves exclues, triés. +const EXTENSIONS_MODULE = new Set(['.js', '.mjs', '.cjs']); +const EPREUVE = /\.test\.[cm]?js$/; +const modulesDe = (dossier) => + fichiersDe(dossier) + .filter((fichier) => EXTENSIONS_MODULE.has(extname(fichier)) && !EPREUVE.test(fichier)) + .sort(); + +// 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; + +// Appels dont le résultat change d'une exécution à l'autre, ou d'un poste à +// l'autre : localeCompare, Intl et les méthodes toLocale… suivent la langue +// et les données régionales du moteur d'exécution (§ 14.7, § 15.5). Chaque +// motif à point admet des blancs autour de lui, que la mise en forme +// introduit quand elle coupe une chaîne d'appels, et le chaînage optionnel. +// Math, Date et performance suivis d'un crochet, accolé ou après « ?. », +// sont refusés eux aussi, quel que soit le membre nommé. new Date se relève +// avec ou sans parenthèses, et Date appelée sans new, « Date() » comme +// « Date?.() », rend elle aussi l'heure courante. getRandomValues, +// randomUUID et les tirages de node:crypto — randomInt, randomBytes, +// randomFill — se reconnaissent au nom de la fonction, quel que soit l'objet +// qui la porte ou l'import qui la nomme. +const SOURCES_NON_REPRODUCTIBLES = [ + ['Math.random', /\bMath\s*\??\.\s*random\b/g], + ['Math[', /\bMath\s*(?:\?\.)?\s*\[/g], + ['Date.now', /\bDate\s*\??\.\s*now\b/g], + ['Date[', /\bDate\s*(?:\?\.)?\s*\[/g], + ['new Date', /\bnew\s+Date\b/g], + ['Date()', /(? { + const modules = modulesDe(join(racine, arbre)); + assert.ok(modules.length > 0, `${arbre} ne fournit aucun module`); + return modules.flatMap((fichier) => { + const texte = readFileSync(fichier, 'utf8'); + return SOURCES_NON_REPRODUCTIBLES.flatMap(([appel, motif]) => + [...texte.matchAll(motif)].map(({ index }) => ({ ligne: ligneDe(texte, index), appel })), + ) + .sort((a, b) => a.ligne - b.ligne) + .map(({ ligne, appel }) => `${relative(racine, fichier)}:${ligne} ${appel}`); + }); + }); +} + +describe('arborescence : déterminisme (§ 14.7)', () => { + test('ni src/moteur ni src/demo ne lisent une source non reproductible', () => { + assert.deepEqual(releverDeterminisme(RACINE), []); + }); + + test('les tirages de node:crypto se relèvent comme ceux du navigateur', () => { + const fichiers = { + 'src/moteur/a.js': 'export const a = 1;\n', + 'src/demo/tirage.js': [ + "import { randomInt, randomBytes, randomFillSync } from 'node:crypto';", + 'export const a = randomInt(6);', + 'export const b = randomBytes(4);', + 'export const c = randomFillSync(new Uint8Array(4));', + ].join('\n'), + }; + assert.deepEqual(avecArbre(fichiers, releverDeterminisme), [ + 'src/demo/tirage.js:1 crypto.randomInt', + 'src/demo/tirage.js:1 crypto.randomBytes', + 'src/demo/tirage.js:1 crypto.randomFill', + 'src/demo/tirage.js:2 crypto.randomInt', + 'src/demo/tirage.js:3 crypto.randomBytes', + 'src/demo/tirage.js:4 crypto.randomFill', + ]); + }); + + test('la garde relève chaque appel par sa ligne, à toute profondeur, hors épreuves, et refuse un arbre sans module', () => { + const fichiers = { + 'src/moteur/tirage.js': [ + 'export const a = Math.random();', + 'export const b = Date.now() - performance.now();', + 'export const c = new Date(0);', + 'export const d = globalThis.crypto.getRandomValues(new Uint32Array(1));', + "export const e = ['b', 'a'].sort((x, y) => x.localeCompare(y));", + 'export const f = Math', + ' .random;', + 'export const i = Date', + ' .now() + performance .now();', + 'export const j = globalThis.crypto', + ' .getRandomValues(t);', + 'export const k = new Date;', + 'export const p = Date();', + 'export const q = crypto.randomUUID();', + "export const r = Math['random']();", + "export const s = new Intl.Collator('fr').compare;", + "export const u = 'É'.toLocaleLowerCase();", + 'export const v = webcrypto.getRandomValues(t);', + 'const { Collator } = Intl;', + 'export const x = source.randomUUID();', + 'export const y = (1234.5).toLocaleString();', + 'export const t1 = globalThis.performance?.now?.() ?? 0;', + 'export const t2 = Date?.now() + Math?.random();', + "export const t3 = performance['now']();", + "export const t4 = Date['now']() + Math?.['random']();", + "export const t5 = Date?.['now']() - performance?.['now']();", + 'export const t6 = Date?.();', + ].join('\n'), + 'src/moteur/tirage.test.js': 'export const g = Math.random();\n', + 'src/moteur/sous/profond.js': 'export const l = Math.random();\n', + 'src/demo/propre.js': [ + 'export const h = Math.floor(2.5) + Date.UTC(2000, 0, 1);', + 'export const mathRandom = 1;', + 'export const debutDate = { now: 2 };', + 'export const m = debutDate.now + xMath.random;', + "export const w = 'É'.toLowerCase();", + ].join('\n'), + 'src/demo/tirage.mjs': 'export const n = Math.random();\n', + 'src/demo/tirage.cjs': 'module.exports = Date.now();\n', + '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), [ + 'src/moteur/sous/profond.js:1 Math.random', + 'src/moteur/tirage.js:1 Math.random', + 'src/moteur/tirage.js:2 Date.now', + 'src/moteur/tirage.js:2 performance.now', + 'src/moteur/tirage.js:3 new Date', + 'src/moteur/tirage.js:4 crypto.getRandomValues', + 'src/moteur/tirage.js:5 localeCompare', + 'src/moteur/tirage.js:6 Math.random', + 'src/moteur/tirage.js:8 Date.now', + 'src/moteur/tirage.js:9 performance.now', + 'src/moteur/tirage.js:11 crypto.getRandomValues', + 'src/moteur/tirage.js:12 new Date', + 'src/moteur/tirage.js:13 Date()', + 'src/moteur/tirage.js:14 crypto.randomUUID', + 'src/moteur/tirage.js:15 Math[', + 'src/moteur/tirage.js:16 Intl', + 'src/moteur/tirage.js:17 toLocale…', + 'src/moteur/tirage.js:18 crypto.getRandomValues', + 'src/moteur/tirage.js:19 Intl', + 'src/moteur/tirage.js:20 crypto.randomUUID', + 'src/moteur/tirage.js:21 toLocale…', + 'src/moteur/tirage.js:22 performance.now', + 'src/moteur/tirage.js:23 Math.random', + 'src/moteur/tirage.js:23 Date.now', + 'src/moteur/tirage.js:24 performance[', + 'src/moteur/tirage.js:25 Math[', + 'src/moteur/tirage.js:25 Date[', + 'src/moteur/tirage.js:26 Date[', + 'src/moteur/tirage.js:26 performance[', + 'src/moteur/tirage.js:27 Date()', + '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/, + ); + }); +}); + +// Chargements que demande un texte : import et export statiques, import() +// et require() d'une chaîne littérale, suivie ou non d'options. Chacun est +// { specificateur, indice }. Le relevé lit le texte entier, commentaires et +// chaînes compris ; un argument calculé ne désigne aucun spécificateur et +// n'est pas relevé. Une instruction à « from » se lit à partir de ce mot, +// que seuls import et export font suivre d'une chaîne : un motif parti du +// mot-clé s'arrêterait à l'apostrophe d'un commentaire placé entre les +// accolades. L'import d'un module seul exige un blanc après le mot-clé, et +// aucune arobase devant lui : une chaîne qui finit sur « import », comme +// l'argument « --import » d'une commande, n'en est pas un, ni la règle +// @import d'une feuille de style, dont la chaîne est une adresse relative à +// la feuille et non un paquet. +const DEPUIS = /\bfrom\s*(['"])(?[^'"\n]+)\1/g; +const IMPORT_SEUL = /(?[^'"\n]+)\1/g; +const APPEL = /\b(?:import|require)\s*\(\s*(['"`])(?[^'"`$\n]+)\1\s*[,)]/g; +function chargements(texte) { + return [DEPUIS, IMPORT_SEUL, APPEL].flatMap((motif) => + [...texte.matchAll(motif)].map(({ groups, index }) => ({ + specificateur: groups.specificateur, + indice: index, + })), + ); +} + +// « ./ » ou « ../ » en tête, ou « . » et « .. » seuls. +const RELATIF = /^\.{1,2}(?:\/|$)/; + +// Nom du paquet que désigne un spécificateur nu : son premier segment, ou +// ses deux premiers pour un paquet à portée (@portée/nom). +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']; + +// 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 +// d'un point puis d'un nom de propriété, accolé ou en tête de la ligne +// 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. +const ACCES_AU_DOM = /\b(document|window|navigator|location|localStorage|sessionStorage)\s*(?:\??\.(?=[\p{L}_$]|[ \t]*\r?\n\s*[\p{L}_$])|(?:\?\.)?\s*\[)/gu; + +// 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) { + if (RELATIF.test(specificateur)) return resolve(dirname(fichier), specificateur); + if (specificateur.startsWith('/')) return join(racine, 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)); + const exterieur = (chemin) => couches.some((c) => chemin === c || chemin.startsWith(c + sep)); + const aParcourir = [...moteur, ...modulesDe(join(racine, 'src', 'geometrie'))]; + 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), + })); + for (const { specificateur, indice } of chargements(texte)) { + const cible = cibleDe(racine, fichier, specificateur); + if (cible === null) { + if (isBuiltin(specificateur) || PAQUETS_EXTERIEURS.test(nomDePaquet(specificateur))) { + trouves.push({ indice, motif: `import ${specificateur}` }); + } + } else if (exterieur(cible)) { + trouves.push({ indice, motif: `import ${specificateur}` }); + } else if (extname(cible) === '') { + trouves.push({ indice, motif: `import ${specificateur} sans extension` }); + } else if (!acces.has(cible) && EXTENSIONS_MODULE.has(extname(cible)) && existsSync(cible)) { + acces.set(cible, `${acces.get(fichier)} → ${relative(racine, cible)}`); + aParcourir.push(cible); + } + } + trouves.sort((a, b) => a.indice - b.indice); + for (const { indice, motif } of trouves) { + refus.push(`${acces.get(fichier)}:${ligneDe(texte, indice)} ${motif}`); + } + } + return refus; +} + +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 = { + 'src/moteur/a.js': [ + 'export const langue = navigator.language;', + 'export const adresse = location.href;', + "export const memoire = localStorage.getItem('x');", + "export const seance = sessionStorage['x'];", + ].join('\n'), + }; + assert.deepEqual(avecArbre(fichiers, releverFrontiere), [ + 'src/moteur/a.js:1 navigator.', + 'src/moteur/a.js:2 location.', + 'src/moteur/a.js:3 localStorage.', + 'src/moteur/a.js:4 sessionStorage[', + ]); + }); + + test("le graphe d'imports du moteur et de la géométrie ne rejoint ni l'interface, ni le stockage, ni une plateforme", () => { + 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 = { + 'src/moteur/a.js': [ + "import { mount } from 'svelte';", + "import { Capacitor } from '@capacitor/core';", + "import { app } from 'electron';", + "import App from '../interface/App.svelte';", + "import { lire } from '../stockage/fichiers.js';", + "import { aide } from '../commun/aide.js';", + "import { ErreurConfiguration } from './erreurs.js';", + 'export const largeur = document.body.clientWidth;', + "import { writable } from 'svelte/store';", + "import { ipcRenderer } from 'electron/renderer';", + "import { voisin } from '../interface-x/voisin.js';", + 'export const adresse = window.URL;', + "import { x } from '/src/interface/x.js';", + "import { f } from '../commun/fenetre';", + "import { readFileSync } from 'node:fs';", + "export const hauteur = window['innerHeight'];", + 'export const corps = () => document.', + ' body;', + "import 'svelte';", + 'import { y } from "electron";', + "import { z } from '@capacitor/cli';", + 'const c = require("svelte");', + "export const echelle = window?.['devicePixelRatio'];", + "import fs from 'fs';", + ].join('\n'), + 'src/moteur/b.js': [ + 'import {', + " // l'écran d'accueil", + ' App,', + "} from '../interface/App.svelte';", + ].join('\n'), + 'src/moteur/erreurs.js': [ + '// Une phrase finit sur le mot document. La suivante commence ici.', + "import { rien } from './absent.js';", + 'export const fenetres = { windows: 1, documentation: 2 };', + '// Cette ligne finit sur le mot document.', + '// La suivante est un commentaire.', + ].join('\n'), + 'src/moteur/sous/c.js': [ + "import { x } from '../../interface/x.js';", + "import { y } from '../interface/y.js';", + ].join('\n'), + 'src/moteur/interface/y.js': 'export const y = 1;\n', + 'src/moteur/a.test.js': "import { mount } from 'svelte';\nexport const corps = document.body;\n", + 'src/stockage/fichiers.js': "import { Filesystem } from '@capacitor/core';\n", + 'src/commun/aide.js': [ + "import { largeur } from '../moteur/a.js';", + 'export const aide = () => window', + ' .innerWidth;', + ].join('\n'), + 'src/commun/fenetre.js': 'export const f = () => window.innerHeight;\n', + 'src/commun/racine.js': 'export const r = () => window.top;\n', + 'src/geometrie/echelle.js': [ + 'export const zoom = () => window?.devicePixelRatio;', + "import { r } from '/src/commun/racine.js';", + ].join('\n'), + }; + assert.deepEqual(avecArbre(fichiers, releverFrontiere), [ + 'src/moteur/a.js:1 import svelte', + 'src/moteur/a.js:2 import @capacitor/core', + 'src/moteur/a.js:3 import electron', + 'src/moteur/a.js:4 import ../interface/App.svelte', + 'src/moteur/a.js:5 import ../stockage/fichiers.js', + 'src/moteur/a.js:8 document.', + 'src/moteur/a.js:9 import svelte/store', + 'src/moteur/a.js:10 import electron/renderer', + 'src/moteur/a.js:12 window.', + 'src/moteur/a.js:13 import /src/interface/x.js', + 'src/moteur/a.js:14 import ../commun/fenetre sans extension', + 'src/moteur/a.js:15 import node:fs', + 'src/moteur/a.js:16 window[', + 'src/moteur/a.js:17 document.', + 'src/moteur/a.js:19 import svelte', + 'src/moteur/a.js:20 import electron', + 'src/moteur/a.js:21 import @capacitor/cli', + 'src/moteur/a.js:22 import svelte', + 'src/moteur/a.js:23 window[', + 'src/moteur/a.js:24 import fs', + '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/moteur/a.js → src/commun/aide.js:2 window.', + 'src/geometrie/echelle.js → src/commun/racine.js:1 window.', + ]); + }); + + 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/, + ); + }); +}); + +// Périmètre du relevé des dépendances : les fichiers de la racine, et les +// arbres des sources, des épreuves, des scripts et de la coquille ; ni +// node_modules ni sortie de construction. +const ARBRES = ['src', 'test', 'scripts', 'electron']; +const EXTENSIONS_SOURCE = new Set(['.js', '.cjs', '.mjs', '.svelte']); +function sourcesDuProjet(racine) { + const racineSeule = readdirSync(racine, { withFileTypes: true }) + .filter((e) => e.isFile()) + .map((e) => join(racine, e.name)); + return [...racineSeule, ...ARBRES.flatMap((arbre) => fichiersDe(join(racine, arbre)))] + .filter((chemin) => EXTENSIONS_SOURCE.has(extname(chemin))) + .sort(); +} + +// Un spécificateur nu n'est ni relatif, ni absolu : ni chemin, ni URL, sinon +// sous node:, la forme des modules natifs. +const URL_ABSOLUE = /^(?!node:)[a-z][a-z\d+.-]*:/i; +const estNu = (specificateur) => + !RELATIF.test(specificateur) && !specificateur.startsWith('/') && !URL_ABSOLUE.test(specificateur); + +// Relevé « fichier:ligne spécificateur » des spécificateurs nus qui ne +// désignent ni un module natif de Node ni un paquet de devDependencies ; un +// paquet que seul dependencies déclare est relevé lui aussi, le projet +// n'ayant aucune dépendance d'exécution. Node et Vite résolvent un +// spécificateur nu en remontant les node_modules des répertoires parents : +// un paquet non déclaré, présent dans un parent sur un poste, s'y charge, +// puis manque dans un clone. isBuiltin admet le nom seul d'un module natif et +// sa forme node:, la seule qu'acceptent certains (node:test) ; il refuse un +// nom inconnu sous node:. Lève quand le périmètre ne porte aucun +// spécificateur nu. +function releverDependances(racine) { + const { devDependencies = {} } = JSON.parse(readFileSync(join(racine, 'package.json'), 'utf8')); + const declares = new Set(Object.keys(devDependencies)); + let nus = 0; + const refus = []; + for (const fichier of sourcesDuProjet(racine)) { + const texte = readFileSync(fichier, 'utf8'); + const trouves = chargements(texte) + .filter(({ specificateur }) => estNu(specificateur)) + .sort((a, b) => a.indice - b.indice); + nus += trouves.length; + for (const { specificateur, indice } of trouves) { + if (!isBuiltin(specificateur) && !declares.has(nomDePaquet(specificateur))) { + refus.push(`${relative(racine, fichier)}:${ligneDe(texte, indice)} ${specificateur}`); + } + } + } + assert.ok(nus > 0, 'aucun spécificateur nu dans le périmètre'); + return refus; +} + +// Un import de module seul par spécificateur, une ligne chacun, entre les +// guillemets donnés, doubles par défaut. Le texte de ce fichier ne porte +// ainsi aucun spécificateur entre guillemets après le mot-clé : un +// spécificateur refusé, écrit en clair dans une instruction d'import de ce +// fichier, ferait échouer la garde sur ce fichier même. Une donnée qui charge +// un paquet refusé par une autre forme l'interpole pour la même raison. +const importsSeuls = (specificateurs, guillemet = '"') => + specificateurs.map((s) => `import ${guillemet}${s}${guillemet};`).join('\n'); + +describe('arborescence : dépendances déclarées', () => { + test('chaque spécificateur nu désigne un module natif de Node ou un paquet de devDependencies', () => { + assert.deepEqual(releverDependances(RACINE), []); + }); + + test('la garde relève chaque paquet absent de devDependencies, dans son périmètre seul', () => { + const fichiers = { + 'package.json': JSON.stringify({ + dependencies: { lodash: '^4.17.21' }, + devDependencies: { vitest: '^5.0.3', '@vitest/browser-playwright': '^5.0.3' }, + }), + 'vite.config.js': "import { defineConfig } from 'vite';\n", + 'src/interface/a.svelte': [ + '', + '', + ].join('\n'), + 'electron/b.cjs': "const { app } = require('electron');\n", + 'scripts/oracle/c.mjs': [ + "const { chromium } = require('playwright');", + "import assert from 'node:assert/strict';", + 'import {', + ' describe,', + "} from 'vitest/config';", + "export * from '@vitest/browser-playwright';", + "const { join } = require('node:path');", + "const vue = await import('svelte/store');", + "import './voisin.js';", + "import '..';", + "spawnSync(process.execPath, ['--import', 'chargeur.js']);", + "import { build } from'vite';", + 'const fc = await import(`fast-check`);', + "const { app } = await import('electron', { with: {} });", + ].join('\n'), + 'test/d.test.js': [ + importsSeuls(['fs/promises', 'node:test', 'test', 'node:inexistant', '@vitest/inconnu']), + importsSeuls(['jsdom'], "'"), + importsSeuls(['/absolu.js', 'file:///e.js', 'lodash']), + `import x from ${JSON.stringify('happy-dom')};`, + `require(${JSON.stringify('undici')});`, + ].join('\n'), + 'node_modules/f/index.js': importsSeuls(['hors-perimetre']), + 'www/g.js': importsSeuls(['hors-perimetre']), + }; + assert.deepEqual(avecArbre(fichiers, releverDependances), [ + 'electron/b.cjs:1 electron', + 'scripts/oracle/c.mjs:1 playwright', + 'scripts/oracle/c.mjs:8 svelte/store', + 'scripts/oracle/c.mjs:12 vite', + 'scripts/oracle/c.mjs:13 fast-check', + 'scripts/oracle/c.mjs:14 electron', + 'src/interface/a.svelte:2 svelte', + 'test/d.test.js:3 test', + 'test/d.test.js:4 node:inexistant', + 'test/d.test.js:5 @vitest/inconnu', + 'test/d.test.js:6 jsdom', + 'test/d.test.js:9 lodash', + 'test/d.test.js:10 happy-dom', + 'test/d.test.js:11 undici', + 'vite.config.js:1 vite', + ]); + }); + + test('un périmètre sans spécificateur nu fait échouer la garde', () => { + const fichiers = { 'package.json': '{}', 'src/a.js': "import './b.js';\n" }; + assert.throws(() => avecArbre(fichiers, releverDependances), /aucun spécificateur nu/); + }); +});