[ADD] guards: determinism and layer boundary checked over src/
A seed reproduces a plan only if nothing in the engine or the demo generator reads the clock, a random source or the locale. One guard reads every module of src/moteur and src/demo and names each such call by its line, Node's crypto draws included. A second walks the engine's import graph and refuses the interface, storage, Svelte, Capacitor, Electron, Node built-ins and browser globals. Both fail on an empty tree. Checked: each forbidden form is caught in a temporary tree; the real tree is clean. --- FR --- [ADD] garde-fous : déterminisme et frontière des couches sur src/ Une graine ne reproduit un plan que si rien, dans le moteur ni dans le générateur de démonstrations, ne lit l'horloge, une source aléatoire ou la locale. Une garde lit chaque module de src/moteur et de src/demo et nomme chaque tel appel par sa ligne, tirages de crypto de Node compris. Une seconde parcourt le graphe d'imports du moteur et refuse l'interface, le stockage, Svelte, Capacitor, Electron, les modules natifs de Node et les globales du navigateur. Les deux échouent sur un arbre vide. Vérifié : chaque forme interdite est relevée dans un arbre temporaire ; l'arbre réel est propre. Assisted-by: Claude Opus 5.5
This commit is contained in:
parent
518ef93f67
commit
4e31c8d3e5
1 changed files with 587 additions and 0 deletions
587
test/arborescence.test.js
Normal file
587
test/arborescence.test.js
Normal file
|
|
@ -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()', /(?<!\bnew\s+)\bDate\s*(?:\?\.\s*)?\(/g],
|
||||||
|
['performance.now', /\bperformance\s*\??\.\s*now\b/g],
|
||||||
|
['performance[', /\bperformance\s*(?:\?\.)?\s*\[/g],
|
||||||
|
['crypto.getRandomValues', /\bgetRandomValues\b/g],
|
||||||
|
['crypto.randomUUID', /\brandomUUID\b/g],
|
||||||
|
['localeCompare', /\blocaleCompare\b/g],
|
||||||
|
['toLocale…', /\btoLocale\w*/g],
|
||||||
|
['Intl', /\bIntl\b/g],
|
||||||
|
['crypto.randomInt', /\brandomInt\b/g],
|
||||||
|
['crypto.randomBytes', /\brandomBytes\b/g],
|
||||||
|
['crypto.randomFill', /\brandomFill(?:Sync)?\b/g],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Relevé « fichier:ligne appel » des sources non reproductibles 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. Les épreuves en
|
||||||
|
// sont exclues : un tirage y sert légitimement (§ 14.12). Lève quand l'un des
|
||||||
|
// deux arbres ne fournit aucun module.
|
||||||
|
function releverDeterminisme(racine) {
|
||||||
|
return ['src/moteur', 'src/demo'].flatMap((arbre) => {
|
||||||
|
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*(['"])(?<specificateur>[^'"\n]+)\1/g;
|
||||||
|
const IMPORT_SEUL = /(?<!@)\bimport\s+(['"])(?<specificateur>[^'"\n]+)\1/g;
|
||||||
|
const APPEL = /\b(?:import|require)\s*\(\s*(['"`])(?<specificateur>[^'"`$\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': [
|
||||||
|
'<script>',
|
||||||
|
" import { mount } from 'svelte';",
|
||||||
|
'</script>',
|
||||||
|
'<style>',
|
||||||
|
" @import 'jetons.css';",
|
||||||
|
'</style>',
|
||||||
|
].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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue