@starterlib/cli 0.10.13 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Etape `autonome` (lot 240) : ce qu'une application AUTONOME doit tenir, et que les etapes d'un hote
3
+ * ou d'un remote ne voient pas. Une application autonome est servie seule, sans federation : l'ecran
4
+ * anonyme d'identity (`ui-auth`, ADR 0031 decision 4), que le BFF rend sur `/auth/**`.
5
+ *
6
+ * 1. AUCUNE FEDERATION LIVREE : ni `remoteEntry.json`, ni carte d'importation, ni `es-module-shims`,
7
+ * ni le moteur de Native Federation.
8
+ * 2. LES MEMES VERSIONS QUE SON JUMEAU (`starterWeb.jumeau`, le dossier du remote du meme service) :
9
+ * Angular et le socle frontal, pour que deux applications d'un service ne divergent pas en silence.
10
+ * 3. LE POIDS DE SON ECRAN (`starterWeb.ecran` : `{ "entree": "src/app/pages/connexion.ts",
11
+ * "seuilKo": 155 }`) : le chargement initial et la route de l'ecran, tout ce qu'ils importent
12
+ * statiquement, en brotli, lus dans la carte de la construction (`ng build --stats-json`, que
13
+ * `starter-web construire` pose pour ce role). Un seuil se baisse ; il ne se leve jamais sans
14
+ * mesure ecrite et datee (ADR 0031).
15
+ *
16
+ * Remplace `verification/application-autonome.mjs` que l'ecran anonyme d'identity tenait seul.
17
+ */
18
+ import { existsSync, readFileSync } from 'node:fs';
19
+ import { dirname, join, resolve } from 'node:path';
20
+ import { cloture, departDeLaPage, totaux } from '../cloture.mjs';
21
+ import { fichiers, ko, lireJson, relatif } from '../commun.mjs';
22
+
23
+ /** Ce qui trahit un moteur de federation dans un fichier livre. */
24
+ const MOTEUR_DE_FEDERATION =
25
+ /es-module-shims|importShim|__NATIVE_FEDERATION__|@angular-architects\/native-federation/;
26
+
27
+ /** Les defauts de federation d'un dossier livre. */
28
+ export function federationLivree(sortie) {
29
+ const defauts = [];
30
+ for (const chemin of fichiers(sortie)) {
31
+ const nom = relatif(sortie, chemin);
32
+ if (/^remoteEntry/.test(nom)) {
33
+ defauts.push(`${nom} livre : une application autonome n'est pas un remote.`);
34
+ continue;
35
+ }
36
+ if (/\.(?:m?js|html)$/.test(nom) && MOTEUR_DE_FEDERATION.test(readFileSync(chemin, 'utf8'))) {
37
+ defauts.push(`${nom} : moteur de federation livre (es-module-shims ou Native Federation).`);
38
+ }
39
+ }
40
+ const index = join(sortie, 'index.html');
41
+ if (existsSync(index) && /<script[^>]*type=["']?importmap/i.test(readFileSync(index, 'utf8'))) {
42
+ defauts.push("index.html : carte d'importation livree.");
43
+ }
44
+ return defauts;
45
+ }
46
+
47
+ /** Les versions d'Angular et du socle qui different de celles du jumeau. */
48
+ export function versionsDuJumeau(paquet, jumeau) {
49
+ const siennes = { ...jumeau.dependencies, ...jumeau.devDependencies };
50
+ const defauts = [];
51
+ for (const [nom, version] of Object.entries({
52
+ ...paquet.dependencies,
53
+ ...paquet.devDependencies,
54
+ })) {
55
+ if (!nom.startsWith('@angular/') && !nom.startsWith('@starterlib/')) continue;
56
+ const duJumeau = siennes[nom];
57
+ if (duJumeau !== undefined && duJumeau !== version) {
58
+ defauts.push(
59
+ `${nom} ${String(version)} : le jumeau est en ${String(duJumeau)} (meme service, memes versions).`,
60
+ );
61
+ }
62
+ }
63
+ return defauts;
64
+ }
65
+
66
+ /** Le morceau de l'entree `entree` dans la carte de la construction, ou `null`. */
67
+ export function morceauDeLEntree(carte, entree) {
68
+ for (const [sortie, description] of Object.entries(carte?.outputs ?? {})) {
69
+ if (description.entryPoint === entree && sortie.endsWith('.js')) {
70
+ return sortie.replace(/^.*browser\//, '');
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /** Le poids de l'ecran : chargement initial, et initial plus la route de l'ecran. */
77
+ export function poidsDeLEcran(sortie, morceau) {
78
+ const depart = departDeLaPage(readFileSync(join(sortie, 'index.html'), 'utf8'));
79
+ const initial = totaux(cloture(sortie, depart, {}, 0));
80
+ const ecran = totaux(cloture(sortie, morceau === null ? depart : [...depart, morceau], {}, 0));
81
+ return { initial, ecran };
82
+ }
83
+
84
+ export default {
85
+ nom: 'autonome',
86
+ roles: ['autonome'],
87
+ executer(projet) {
88
+ if (!existsSync(join(projet.sortie, 'index.html'))) {
89
+ return { defauts: [`${projet.sortieRelative}/index.html absent : construire d'abord.`] };
90
+ }
91
+ const reglages = projet.paquet.starterWeb ?? {};
92
+ const defauts = [...federationLivree(projet.sortie)];
93
+ const details = [];
94
+
95
+ if (typeof reglages.jumeau === 'string') {
96
+ const jumeau = lireJson(join(resolve(projet.racine, reglages.jumeau), 'package.json'));
97
+ if (jumeau === null)
98
+ defauts.push(`starterWeb.jumeau : ${reglages.jumeau}/package.json illisible.`);
99
+ else defauts.push(...versionsDuJumeau(projet.paquet, jumeau));
100
+ }
101
+
102
+ const ecran = reglages.ecran;
103
+ if (ecran !== undefined) {
104
+ const carte = lireJson(join(dirname(projet.sortie), 'browser-stats.json'));
105
+ const morceau = carte === null ? null : morceauDeLEntree(carte, ecran.entree);
106
+ if (morceau === null) {
107
+ defauts.push(
108
+ `starterWeb.ecran : la route ${String(ecran.entree)} est introuvable dans browser-stats.json ` +
109
+ '(starter-web construire la pose : ng build --stats-json).',
110
+ );
111
+ } else {
112
+ const { initial, ecran: poids } = poidsDeLEcran(projet.sortie, morceau);
113
+ details.push(
114
+ `chargement initial ${String(ko(initial.brotli))} Ko br (${String(initial.fichiers)} fichiers)`,
115
+ `ecran ${String(ecran.entree)} ${String(ko(poids.brotli))} Ko br (${String(poids.fichiers)} fichiers, seuil ${String(ecran.seuilKo)} Ko br)`,
116
+ );
117
+ if (typeof ecran.seuilKo === 'number' && ko(poids.brotli) > ecran.seuilKo) {
118
+ defauts.push(
119
+ `ecran : ${String(ko(poids.brotli))} Ko br > ${String(ecran.seuilKo)} Ko br (un seuil ne se ` +
120
+ 'leve jamais sans mesure ecrite et datee, ADR 0031).',
121
+ );
122
+ }
123
+ }
124
+ }
125
+ return {
126
+ defauts,
127
+ details,
128
+ resume: 'aucune federation livree, versions du jumeau et poids de l ecran conformes.',
129
+ };
130
+ },
131
+ };
@@ -25,19 +25,23 @@ import { natureDesFichiers } from '../livraison.mjs';
25
25
  export const BUDGETS_DU_REMOTE = { pageKo: 60, totalKo: 250, catalogueKo: 30 };
26
26
 
27
27
  /**
28
- * Les seuils de l'hote, en Ko brotli (ADR 0031) : 301 pour le premier ecran reel, 282 pour la
29
- * charge initiale, ramenes au socle 0.10.0 (releves et historique dans l'ADR). La charge initiale
30
- * est passee de 280 a 281 le 2026-09-29, decision du proprietaire : 280,4 Ko mesures avec le socle
31
- * 0.10.9, dont les controles d'empreinte S05 du lot 272 tournent au demarrage. Puis, le meme jour,
32
- * 282 et 301 (decision de l'orchestrateur sur autorisation du proprietaire) : la coquille mesuree
33
- * contre le socle 0.10.11 construit, avant publication, 281,0 et 300,1 Ko (+0,4 Ko chacun : titre
34
- * de page, banniere collee, marque textuelle, creneau de marque, demarrage determine, amorce).
28
+ * Les seuils de l'hote, en Ko brotli (ADR 0031) : 309 pour le premier ecran reel, 288 pour la
29
+ * charge initiale (ADR web 0005, D167). Leur histoire : ramenes au socle 0.10.0 (releves dans
30
+ * l'ADR 0031) ; la charge initiale passee de 280 a 281 le 2026-09-29, decision du proprietaire :
31
+ * 280,4 Ko mesures avec le socle 0.10.9, dont les controles d'empreinte S05 du lot 272 tournent au
32
+ * demarrage. Puis, le meme jour, 282 et 301 (decision de l'orchestrateur sur autorisation du
33
+ * proprietaire) : la coquille mesuree contre le socle 0.10.11 construit, avant publication, 281,0
34
+ * et 300,1 Ko (+0,4 Ko chacun : titre de page, banniere collee, marque textuelle, creneau de
35
+ * marque, demarrage determine, amorce). Puis 288 et 309 le 2026-10-07 : la coquille d'Aplomb
36
+ * mesuree contre la 0.11.0 construite, avant publication, 286,96 et 307,86 Ko (+5,1 et +6,2 Ko sur
37
+ * la 0.10.14 : barre inferieure, action critique et raccourci, ressource rafraichie, tronc), puis
38
+ * 287,4 et 308,3 Ko avec sa feuille de marque et la feuille de sa police de titre (+0,44 Ko).
35
39
  *
36
40
  * ⚠️ Ce ne sont pas la cible du plan SPA §2.8 (170 Ko). L'ecart restant est STRUCTUREL : un paquet
37
41
  * partage en singleton part ENTIER, parce que n'importe quel remote peut en avoir besoin. Un seuil
38
42
  * se baisse ; il ne se leve jamais sans mesure ecrite et sans date.
39
43
  */
40
- export const BUDGETS_DE_L_HOTE = { premierEcranKo: 301, chargeInitialeKo: 282, cibleDuPlanKo: 170 };
44
+ export const BUDGETS_DE_L_HOTE = { premierEcranKo: 309, chargeInitialeKo: 288, cibleDuPlanKo: 170 };
41
45
 
42
46
  /** Les imports DYNAMIQUES d'un module : les pages qu'il charge a la demande. */
43
47
  const IMPORT_DYNAMIQUE = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g;
@@ -97,7 +97,7 @@ export function jugerLesDependances(paquet, outils = []) {
97
97
 
98
98
  export default {
99
99
  nom: 'dependances',
100
- roles: ['remote', 'hote'],
100
+ roles: ['remote', 'hote', 'autonome'],
101
101
  executer(projet) {
102
102
  const dossier = join(projet.racine, 'tools');
103
103
  const outils = existsSync(dossier) ? readdirSync(dossier) : [];
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Les formulations qu'une application interdit dans ses textes affiches : une recherche textuelle
3
+ * simple dans ses catalogues, a chaque `verify` ; ni un controle du design, ni un jugement du sens
4
+ * (lot P09-27 d'Aplomb, generalise). L'etape `i18n` la lance.
5
+ *
6
+ * L'application declare sa liste : `package.json#starterWeb.formulationsInterdites`, chemin d'un
7
+ * fichier JSON relatif a sa racine. Le socle n'en connait aucune ; sans reglage, rien n'est cherche.
8
+ *
9
+ * {
10
+ * "formulations": [
11
+ * { "id": "F01", "textes": ["garanti", "garantie"], "langues": ["fr"] },
12
+ * { "id": "F07", "motif": "(?<![\\p{L}\\p{N}])[A-Z]-\\d{2}(?![\\p{L}\\p{N}])" }
13
+ * ],
14
+ * "exceptions": [
15
+ * { "formulation": "F01", "cle": "app.legal.absence", "raison": "...", "date": "2026-10-07" }
16
+ * ]
17
+ * }
18
+ *
19
+ * - `textes` : chaque forme se nomme (aucune flexion devinee) ; la recherche ignore la casse et les
20
+ * accents, confond les blancs (espaces insecables compris) et les tirets, et ne trouve un texte
21
+ * qu'en mots entiers : « garanti » ne se trouve pas dans « garantit ».
22
+ * - `motif` : une expression reguliere (drapeau `u`), appliquee au texte tel quel, casse comprise.
23
+ * - `langues` : parmi `fr` et `en` ; les deux par defaut.
24
+ * - une exception se nomme, se justifie et se date, pour une cle et une formulation ; celle dont la
25
+ * cle appartient a un perimetre du projet sans y exister est perimee : refusee.
26
+ */
27
+
28
+ /** Les blancs et les tirets, confondus en une espace. */
29
+ const BLANCS_ET_TIRETS = /[\s\p{Pd}]+/gu;
30
+ const BORD_AVANT = '(?<![\\p{L}\\p{N}])';
31
+ const BORD_APRES = '(?![\\p{L}\\p{N}])';
32
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
33
+
34
+ /** Le texte tel que la recherche le compare : sans casse ni accents, blancs et tirets confondus. */
35
+ export function normaliser(texte) {
36
+ return texte.normalize('NFD').replace(/\p{M}/gu, '').toLowerCase().replace(BLANCS_ET_TIRETS, ' ');
37
+ }
38
+
39
+ function echapper(texte) {
40
+ return texte.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
41
+ }
42
+
43
+ function texteNonVide(valeur) {
44
+ return typeof valeur === 'string' && valeur.trim() !== '';
45
+ }
46
+
47
+ /** Une formulation lue : `{ formulation }` prete a chercher, ou `{ defaut }`. */
48
+ function lireUneFormulation(entree, rang, langues, ids) {
49
+ const ou = `formulation ${String(rang + 1)}`;
50
+ if (entree === null || typeof entree !== 'object')
51
+ return { defaut: `${ou} : un objet est attendu.` };
52
+ const { id, textes, motif } = entree;
53
+ if (!texteNonVide(id)) return { defaut: `${ou} : "id" manquant.` };
54
+ if (ids.has(id)) return { defaut: `${id} : identifiant en double.` };
55
+ ids.add(id);
56
+ const cherchees = entree.langues ?? langues;
57
+ if (
58
+ !Array.isArray(cherchees) ||
59
+ cherchees.length === 0 ||
60
+ cherchees.some((langue) => !langues.includes(langue))
61
+ ) {
62
+ return { defaut: `${id} : "langues" parmi ${langues.join(', ')}.` };
63
+ }
64
+ const motifs = [];
65
+ if (textes !== undefined) {
66
+ if (
67
+ !Array.isArray(textes) ||
68
+ textes.length === 0 ||
69
+ textes.some((texte) => typeof texte !== 'string' || normaliser(texte).trim() === '')
70
+ ) {
71
+ return { defaut: `${id} : "textes", une liste de textes non vides, est attendu.` };
72
+ }
73
+ for (const texte of textes) {
74
+ const cherche = BORD_AVANT + echapper(normaliser(texte).trim()) + BORD_APRES;
75
+ motifs.push({ expression: new RegExp(cherche, 'u'), surLeTexteNormalise: true });
76
+ }
77
+ }
78
+ if (motif !== undefined) {
79
+ if (!texteNonVide(motif))
80
+ return { defaut: `${id} : "motif", une expression non vide, est attendu.` };
81
+ try {
82
+ motifs.push({ expression: new RegExp(motif, 'u'), surLeTexteNormalise: false });
83
+ } catch (erreur) {
84
+ return {
85
+ defaut: `${id} : motif illisible (${erreur instanceof Error ? erreur.message : String(erreur)}).`,
86
+ };
87
+ }
88
+ }
89
+ if (motifs.length === 0) return { defaut: `${id} : "textes" ou "motif" attendu.` };
90
+ return { formulation: { id, langues: cherchees, motifs } };
91
+ }
92
+
93
+ /**
94
+ * La liste lue et jugee : les formulations pretes a chercher, les exceptions, et les defauts du
95
+ * fichier. Une liste fautive se refuse entiere : elle ne cherche rien de travers.
96
+ */
97
+ export function lireLesFormulations(liste, langues) {
98
+ const defauts = [];
99
+ const formulations = [];
100
+ const exceptions = [];
101
+ if (liste === null || typeof liste !== 'object' || !Array.isArray(liste.formulations)) {
102
+ return {
103
+ formulations,
104
+ exceptions,
105
+ defauts: ['un objet { "formulations": [...] } est attendu.'],
106
+ };
107
+ }
108
+ const ids = new Set();
109
+ for (const [rang, entree] of liste.formulations.entries()) {
110
+ const lue = lireUneFormulation(entree, rang, langues, ids);
111
+ if (lue.defaut === undefined) formulations.push(lue.formulation);
112
+ else defauts.push(lue.defaut);
113
+ }
114
+ if (liste.exceptions !== undefined && !Array.isArray(liste.exceptions)) {
115
+ defauts.push('"exceptions" : une liste est attendue.');
116
+ }
117
+ for (const [rang, exception] of (Array.isArray(liste.exceptions)
118
+ ? liste.exceptions
119
+ : []
120
+ ).entries()) {
121
+ const ou = `exception ${String(rang + 1)}`;
122
+ if (exception === null || typeof exception !== 'object') {
123
+ defauts.push(`${ou} : un objet est attendu.`);
124
+ continue;
125
+ }
126
+ const { formulation, cle, raison, date } = exception;
127
+ if (!ids.has(formulation))
128
+ defauts.push(`${ou} : formulation "${String(formulation)}" inconnue.`);
129
+ else if (!texteNonVide(cle)) defauts.push(`${ou} : "cle" manquante.`);
130
+ else if (!texteNonVide(raison))
131
+ defauts.push(`${ou} (${cle}) : "raison" manquante : une exception se justifie.`);
132
+ else if (typeof date !== 'string' || !DATE.test(date)) {
133
+ defauts.push(`${ou} (${cle}) : "date" AAAA-MM-JJ manquante : une exception se date.`);
134
+ } else exceptions.push({ formulation, cle });
135
+ }
136
+ return { formulations, exceptions, defauts };
137
+ }
138
+
139
+ /**
140
+ * Les textes des catalogues ou une formulation interdite se trouve, hors exception ; puis les
141
+ * exceptions perimees. `catalogues` : `{ <perimetre>: { fr: {...}, en: {...} } }`.
142
+ */
143
+ export function jugerLesFormulations({ formulations, exceptions }, catalogues) {
144
+ const defauts = [];
145
+ const admises = new Set(exceptions.map(({ formulation, cle }) => `${formulation} ${cle}`));
146
+ for (const [perimetre, parLangue] of Object.entries(catalogues)) {
147
+ for (const [langue, catalogue] of Object.entries(parLangue)) {
148
+ if (catalogue === null || typeof catalogue !== 'object') continue;
149
+ for (const [cle, message] of Object.entries(catalogue)) {
150
+ if (typeof message !== 'string') continue;
151
+ const normalise = normaliser(message);
152
+ for (const { id, langues, motifs } of formulations) {
153
+ if (!langues.includes(langue) || admises.has(`${id} ${cle}`)) continue;
154
+ for (const { expression, surLeTexteNormalise } of motifs) {
155
+ const trouve = (surLeTexteNormalise ? normalise : message).match(expression);
156
+ if (trouve !== null) {
157
+ defauts.push(
158
+ `${perimetre}.${langue}.json : cle "${cle}", formulation interdite ${id} ("${trouve[0]}").`,
159
+ );
160
+ break;
161
+ }
162
+ }
163
+ }
164
+ }
165
+ }
166
+ }
167
+ for (const { formulation, cle } of exceptions) {
168
+ const perimetre = cle.split('.')[0];
169
+ const parLangue = catalogues[perimetre];
170
+ if (parLangue === undefined) continue;
171
+ const presente = Object.values(parLangue).some(
172
+ (catalogue) =>
173
+ catalogue !== null && typeof catalogue === 'object' && Object.hasOwn(catalogue, cle),
174
+ );
175
+ if (!presente) {
176
+ defauts.push(
177
+ `exception ${formulation} : cle "${cle}" absente du perimetre "${perimetre}" (exception perimee, a retirer).`,
178
+ );
179
+ }
180
+ }
181
+ return defauts;
182
+ }
@@ -18,13 +18,16 @@
18
18
  * - l'hote porte les cles que `@starterlib/core` traduit par son catalogue
19
19
  * (`schemas/cles-exigees-du-socle.json`, constat A0837) ;
20
20
  * - le type des cles engendre (`src/app/i18n/cles.ts`, `starter-web i18n:types`) est a jour : une cle
21
- * inconnue dans `t()` est alors une erreur de compilation.
21
+ * inconnue dans `t()` est alors une erreur de compilation ;
22
+ * - aucun texte ne porte une formulation que l'application interdit
23
+ * (`starterWeb.formulationsInterdites`, voir `formulations.mjs`), hors exception nommee et datee.
22
24
  */
23
25
  import { existsSync, readFileSync } from 'node:fs';
24
- import { join } from 'node:path';
26
+ import { join, resolve } from 'node:path';
25
27
  import { fichiers, lireJson, relatif } from '../commun.mjs';
26
28
  import { dossierInstalle } from '../socle-installe.mjs';
27
29
  import { cheminDesTypesDeCles, typesDeCles } from '../construction/types-i18n.mjs';
30
+ import { jugerLesFormulations, lireLesFormulations } from './formulations.mjs';
28
31
 
29
32
  export const LANGUES = ['fr', 'en'];
30
33
 
@@ -264,12 +267,50 @@ function lire(chemin) {
264
267
  }
265
268
  }
266
269
 
270
+ /**
271
+ * La recherche des formulations interdites, si l'application en declare : `cherchees` vaut `null`
272
+ * sans reglage, le nombre de formulations cherchees sinon.
273
+ */
274
+ export function rechercherLesFormulations(projet, catalogues) {
275
+ const reglage = projet.paquet?.starterWeb?.formulationsInterdites;
276
+ if (reglage === undefined) return { defauts: [], cherchees: null };
277
+ if (typeof reglage !== 'string' || reglage.trim() === '') {
278
+ return {
279
+ defauts: [
280
+ "package.json : starterWeb.formulationsInterdites, le chemin d'un fichier JSON, est attendu.",
281
+ ],
282
+ cherchees: 0,
283
+ };
284
+ }
285
+ const liste = lire(resolve(projet.racine, reglage));
286
+ if (liste === null) {
287
+ return {
288
+ defauts: [`${reglage} : absent ou illisible (starterWeb.formulationsInterdites).`],
289
+ cherchees: 0,
290
+ };
291
+ }
292
+ const lue = lireLesFormulations(liste, LANGUES);
293
+ if (lue.defauts.length > 0) {
294
+ return { defauts: lue.defauts.map((defaut) => `${reglage} : ${defaut}`), cherchees: 0 };
295
+ }
296
+ return { defauts: jugerLesFormulations(lue, catalogues), cherchees: lue.formulations.length };
297
+ }
298
+
299
+ /**
300
+ * Les perimetres de catalogues d'un projet : ceux du manifeste (remote), ceux de
301
+ * `starterWeb.i18n.perimetres` (application autonome, lot 240), `socle` pour l'hote.
302
+ */
303
+ export function perimetresDuProjet(projet) {
304
+ if (projet.role === 'remote') return projet.source?.i18n?.perimetres ?? [];
305
+ if (projet.role === 'autonome') return projet.paquet.starterWeb?.i18n?.perimetres ?? ['socle'];
306
+ return ['socle'];
307
+ }
308
+
267
309
  export default {
268
310
  nom: 'i18n',
269
- roles: ['remote', 'hote'],
311
+ roles: ['remote', 'hote', 'autonome'],
270
312
  executer(projet) {
271
- const perimetres =
272
- projet.role === 'remote' ? (projet.source?.i18n?.perimetres ?? []) : ['socle'];
313
+ const perimetres = perimetresDuProjet(projet);
273
314
  if (perimetres.length === 0) {
274
315
  return { defauts: ['manifest.source.json : i18n.perimetres vide, aucun catalogue a lire.'] };
275
316
  }
@@ -305,8 +346,9 @@ export default {
305
346
  // L'aide contextuelle de chaque page du manifeste, et aucune aide sans page (lot 280).
306
347
  defauts.push(...jugerLesAides(catalogues, projet.source?.routes ?? []));
307
348
 
308
- // L'hote porte les cles que le noyau traduit par son catalogue.
309
- if (projet.role === 'hote') {
349
+ // L'hote, et l'application autonome qui pose le noyau comme lui, portent les cles que le noyau
350
+ // traduit par son catalogue.
351
+ if (projet.role === 'hote' || projet.role === 'autonome') {
310
352
  const dossier = dossierInstalle(projet.racine, '@starterlib/core');
311
353
  const exigees =
312
354
  dossier === null ? null : lireJson(join(dossier, 'schemas', 'cles-exigees-du-socle.json'));
@@ -321,6 +363,10 @@ export default {
321
363
  }
322
364
  }
323
365
 
366
+ // Les formulations que l'application interdit dans ses textes (P09-27 d'Aplomb, generalise).
367
+ const recherche = rechercherLesFormulations(projet, catalogues);
368
+ defauts.push(...recherche.defauts);
369
+
324
370
  // Le type des cles engendre, a jour.
325
371
  const cheminDesTypes = cheminDesTypesDeCles(projet.racine);
326
372
  const attendu = typesDeCles(perimetres, catalogues);
@@ -332,7 +378,11 @@ export default {
332
378
 
333
379
  return {
334
380
  defauts,
335
- resume: `${String(perimetres.length)} perimetre(s), ${String(cles)} cle(s) en fr et en en, messages lisibles, cles appelees presentes.`,
381
+ resume:
382
+ `${String(perimetres.length)} perimetre(s), ${String(cles)} cle(s) en fr et en en, messages lisibles, cles appelees presentes` +
383
+ (recherche.cherchees === null
384
+ ? '.'
385
+ : `, ${String(recherche.cherchees)} formulation(s) interdite(s) cherchee(s).`),
336
386
  };
337
387
  },
338
388
  };
@@ -11,15 +11,24 @@
11
11
  *
12
12
  * Sont lues les feuilles `.scss` et `.css` sous `src/`, et les styles EN LIGNE des composants
13
13
  * (`styles: \`...\``), que la copie de la coquille ne voyait pas.
14
+ *
15
+ * La MARQUE de la SPA (niveau 2 bis : `marque.json`, `marque.ts` ou `marque.mjs` a la racine) est
16
+ * rejouee ici a chaque verification, par le moteur des jetons installe : une valeur reprise qui fait
17
+ * tomber une paire sous son seuil rougit `verify`, paire nommee, avant la construction.
14
18
  */
15
19
  import { readFileSync } from 'node:fs';
16
20
  import { join } from 'node:path';
17
21
  import { fichiers, relatif } from '../commun.mjs';
22
+ import { fichierDeMarque, jugerLaMarque } from '../marque.mjs';
18
23
 
19
24
  /** Poses a l'execution par un composant du socle, donc absents des feuilles engendrees. */
20
25
  const POSES_A_L_EXECUTION = new Set([
21
26
  // st-app-shell mesure l'en-tete collant et publie sa hauteur (scroll-padding de la page).
22
27
  '--st-hauteur-entete',
28
+ // st-bottom-nav (P08-05) et l'action permanente flottante de st-app-shell (P08-06) publient la
29
+ // hauteur qu'elles couvrent au bas de la fenetre.
30
+ '--st-hauteur-pied',
31
+ '--st-hauteur-action',
23
32
  ]);
24
33
 
25
34
  /** Une lecture de jeton : son nom, et le caractere qui suit (`,` : avec repli ; `)` : sans). */
@@ -67,8 +76,8 @@ export function jugerLesJetons(feuilles, definis) {
67
76
 
68
77
  export default {
69
78
  nom: 'jetons',
70
- roles: ['remote', 'hote'],
71
- executer(projet) {
79
+ roles: ['remote', 'hote', 'autonome'],
80
+ async executer(projet) {
72
81
  const socle = join(projet.racine, 'node_modules', '@starterlib', 'tokens', 'styles');
73
82
  const definis = new Set();
74
83
  for (const feuille of fichiers(socle).filter((chemin) => chemin.endsWith('.css'))) {
@@ -99,9 +108,19 @@ export default {
99
108
  };
100
109
  }
101
110
  const { defauts, lues } = jugerLesJetons(feuilles, definis);
111
+ let marque = '';
112
+ const chemin = fichierDeMarque(projet.racine);
113
+ if (chemin !== null) {
114
+ const nom = relatif(projet.racine, chemin);
115
+ const juge = await jugerLaMarque(projet.racine, chemin);
116
+ for (const defaut of juge.defauts) defauts.push(`${nom} : ${defaut}`);
117
+ if (juge.defauts.length === 0) marque = ` ; marque ${String(juge.valeurs.nom)} conforme`;
118
+ }
102
119
  return {
103
120
  defauts,
104
- resume: `${String(lues)} lecture(s) sans repli dans ${String(feuilles.length)} feuille(s), toutes definies.`,
121
+ resume:
122
+ `${String(lues)} lecture(s) sans repli dans ${String(feuilles.length)} feuille(s), toutes ` +
123
+ `definies${marque}.`,
105
124
  };
106
125
  },
107
126
  };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Etape `navigateurs` (lot 276) : la liste browserslist de la SPA est celle du socle, lue dans la
3
+ * matrice publiee avec ce CLI (`src/navigateurs.mjs`). Ni plus bas, ni plus haut, ni absente.
4
+ */
5
+ import { jugerLesNavigateurs, navigateursDeLaSpa, navigateursDuSocle } from '../navigateurs.mjs';
6
+
7
+ export default {
8
+ nom: 'navigateurs',
9
+ roles: ['remote', 'hote', 'autonome'],
10
+ executer(projet) {
11
+ const attendus = navigateursDuSocle();
12
+ const lus = navigateursDeLaSpa(projet.racine);
13
+ const defauts = jugerLesNavigateurs(lus, attendus);
14
+ return defauts.length > 0
15
+ ? { defauts }
16
+ : {
17
+ defauts,
18
+ resume: `${lus?.origine ?? ''} conforme au plancher du socle (${String(attendus.length)} requetes).`,
19
+ };
20
+ },
21
+ };
@@ -34,6 +34,49 @@ export function jugerLesSelecteurs(prefixe, sources) {
34
34
  return defauts;
35
35
  }
36
36
 
37
+ /**
38
+ * Les regles de style du socle (P08-11 : seul le socle fait le design) : stylelint pour les feuilles,
39
+ * le greffon pour les gabarits. Les lever exige une exemption NOMMEE et DATEE.
40
+ */
41
+ const REGLES_DE_STYLE =
42
+ /\b(?:color-no-hex|color-named|declaration-property-value-(?:dis)?allowed-list|property-disallowed-list|socle\/no-style-literal|socle\/variantes-reservees)\b/;
43
+
44
+ /** Un commentaire qui leve des regles : `stylelint-disable-next-line <regles> -- <raison>`. */
45
+ const EXEMPTION = /(stylelint|eslint)-disable(?:-next-line|-line)?\b(.*?)(?:\*\/|-->|$)/gm;
46
+
47
+ /** Une raison qui commence par sa date : `-- 2026-10-07 : <raison>`. */
48
+ const DATEE = /--\s*\d{4}-\d{2}-\d{2}\s*:?\s*\S/;
49
+
50
+ /**
51
+ * Les exemptions des regles de style (P08-11) dans les sources d'une SPA (`{ nom, texte }` : feuilles,
52
+ * gabarits, code) : chacune nommee ET datee (`-- AAAA-MM-JJ : <raison>`), pour se journaliser dans le
53
+ * CHANGELOG de l'application. Une levee de TOUTES les regles (sans liste) dans une feuille ou un
54
+ * gabarit compte comme une exemption de style. Rend `{ defauts, datees }`.
55
+ */
56
+ export function jugerLesExemptions(sources) {
57
+ const defauts = [];
58
+ let datees = 0;
59
+ for (const { nom, texte } of sources) {
60
+ const codeSeul = nom.endsWith('.ts');
61
+ for (const exemption of texte.matchAll(EXEMPTION)) {
62
+ const suite = exemption[2] ?? '';
63
+ const regles = suite.split('--')[0] ?? '';
64
+ const toutes = regles.trim() === '';
65
+ if (toutes ? codeSeul : !REGLES_DE_STYLE.test(regles)) continue;
66
+ if (DATEE.test(suite)) {
67
+ datees += 1;
68
+ continue;
69
+ }
70
+ const ligne = texte.slice(0, exemption.index).split('\n').length;
71
+ defauts.push(
72
+ `${nom}:${String(ligne)} : exemption de style sans date ni raison ; ecrire ` +
73
+ '`-- AAAA-MM-JJ : <raison>` et la journaliser dans le CHANGELOG de l application.',
74
+ );
75
+ }
76
+ }
77
+ return { defauts, datees };
78
+ }
79
+
37
80
  /** Le resultat d'une commande deleguee : `resume` si elle reussit, son code sinon. */
38
81
  function deleguer(projet, arguments_, resume) {
39
82
  const code = executer('pnpm', arguments_, projet.racine);
@@ -44,7 +87,7 @@ function deleguer(projet, arguments_, resume) {
44
87
 
45
88
  export const format = {
46
89
  nom: 'format',
47
- roles: ['remote', 'hote'],
90
+ roles: ['remote', 'hote', 'autonome'],
48
91
  executer: (projet) =>
49
92
  deleguer(projet, ['exec', 'prettier', '--check', '.'], 'prettier --check . conforme.'),
50
93
  };
@@ -59,7 +102,7 @@ const CONFIGURATIONS_STYLELINT = [
59
102
 
60
103
  export const lint = {
61
104
  nom: 'lint',
62
- roles: ['remote', 'hote'],
105
+ roles: ['remote', 'hote', 'autonome'],
63
106
  preparation: 'sprite',
64
107
  executer(projet) {
65
108
  const sources = fichiers(join(projet.racine, 'src'))
@@ -70,6 +113,21 @@ export const lint = {
70
113
  }));
71
114
  const selecteurs = jugerLesSelecteurs(projet.prefixe, sources);
72
115
  if (selecteurs.length > 0) return { defauts: selecteurs };
116
+ // P08-11 : une exemption de style est nommee et datee, jamais muette.
117
+ const exemptions = jugerLesExemptions(
118
+ fichiers(join(projet.racine, 'src'))
119
+ .filter((chemin) => /\.(scss|css|html|ts)$/.test(chemin))
120
+ .filter((chemin) => !chemin.includes(DOSSIER_DU_CLIENT_ENGENDRE))
121
+ .map((chemin) => ({
122
+ nom: relatif(projet.racine, chemin),
123
+ texte: readFileSync(chemin, 'utf8'),
124
+ })),
125
+ );
126
+ if (exemptions.defauts.length > 0) return { defauts: exemptions.defauts };
127
+ const ecartes =
128
+ exemptions.datees === 0
129
+ ? ''
130
+ : ` ; ${String(exemptions.datees)} exemption(s) de style datee(s), a journaliser dans le CHANGELOG`;
73
131
  // Les feuilles sous stylelint (lot 178) : jetons, pixels, plans, ordre du DOM. La configuration
74
132
  // etend celle du socle (@starterlib/eslint-config/stylelint) ; sans elle, rien n'est garde.
75
133
  if (!CONFIGURATIONS_STYLELINT.some((nom) => existsSync(join(projet.racine, nom)))) {
@@ -88,14 +146,14 @@ export const lint = {
88
146
  return deleguer(
89
147
  projet,
90
148
  ['exec', 'stylelint', 'src/**/*.{scss,css}', '--allow-empty-input'],
91
- `selecteurs en ${String(projet.prefixe)}-, eslint . et stylelint conformes.`,
149
+ `selecteurs en ${String(projet.prefixe)}-, eslint . et stylelint conformes${ecartes}.`,
92
150
  );
93
151
  },
94
152
  };
95
153
 
96
154
  export const test = {
97
155
  nom: 'test',
98
- roles: ['remote', 'hote'],
156
+ roles: ['remote', 'hote', 'autonome'],
99
157
  preparation: 'sprite',
100
158
  executer: (projet) => deleguer(projet, ['exec', 'ng', 'test', '--no-watch'], 'ng test conforme.'),
101
159
  };
@@ -136,7 +194,7 @@ export function motifsNonPortables(racine) {
136
194
  */
137
195
  export const contrat = {
138
196
  nom: 'contrat',
139
- roles: ['remote', 'hote'],
197
+ roles: ['remote', 'hote', 'autonome'],
140
198
  executer(projet) {
141
199
  if (projet.paquet.scripts?.['contrat:check'] === undefined) {
142
200
  return { sautee: 'aucun script contrat:check : pas de client engendre (ADR web 0008).' };