@ucm-kit/adapter-typescript 0.1.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.
- package/README.md +20 -0
- package/package.json +40 -0
- package/src/cli.mjs +25 -0
- package/src/generation.mjs +101 -0
- package/src/index.d.mts +37 -0
- package/src/index.mjs +8 -0
- package/src/parite.mjs +372 -0
- package/src/types-variants.mjs +33 -0
package/README.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# @ucm-kit/adapter-typescript
|
|
2
|
+
|
|
3
|
+
Adaptateur optionnel pour les repositories TypeScript qui consomment des
|
|
4
|
+
contrats UCM. Il ajoute deux capacités sans faire dépendre `@ucm-kit/core` du
|
|
5
|
+
compilateur TypeScript :
|
|
6
|
+
|
|
7
|
+
- `ucm check` le découvre automatiquement lorsqu'il est installé dans le
|
|
8
|
+
repository et compare les props, les BOOLEAN lues et les compositions ;
|
|
9
|
+
- `ucm-typescript` génère les unions d'enums dans
|
|
10
|
+
`src/generated/contracts/` (ou dans le dossier donné à `--out`).
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm install --save-dev @ucm-kit/adapter-typescript@0.1.0 @ucm-kit/cli@0.1.7
|
|
14
|
+
npx ucm-typescript
|
|
15
|
+
npx --no-install ucm check
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
La parité exige un `tsconfig.json` à la racine. Les valeurs d'enum réellement
|
|
19
|
+
traitées par le comportement du composant restent hors de la garantie statique :
|
|
20
|
+
le contrat décrit les vues disponibles, pas la logique qui en choisit une.
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ucm-kit/adapter-typescript",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Adaptateur TypeScript UCM : parité statique des composants et génération des types dérivés des contrats.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Vassili-g/UCM-Exporter.git",
|
|
10
|
+
"directory": "packages/adapter-typescript"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=20"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"bin": {
|
|
19
|
+
"ucm-typescript": "src/cli.mjs"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"src",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./src/index.d.mts",
|
|
28
|
+
"default": "./src/index.mjs"
|
|
29
|
+
},
|
|
30
|
+
"./package.json": "./package.json"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"typecheck": "tsc --noEmit --module NodeNext --moduleResolution NodeNext --target ES2022 src/index.d.mts",
|
|
34
|
+
"test": "node scripts/run-tests.cjs"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@ucm-kit/core": "0.1.11",
|
|
38
|
+
"typescript": ">=5.5.0 <6"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Point d'entrée du générateur de types UCM. */
|
|
3
|
+
import { genererTypes, rendreGeneration } from "./generation.mjs";
|
|
4
|
+
|
|
5
|
+
function lireArguments(arguments_) {
|
|
6
|
+
if (arguments_.length === 0) return { dossierSortie: "src/generated/contracts" };
|
|
7
|
+
if (arguments_.length === 2 && arguments_[0] === "--out" && arguments_[1]) {
|
|
8
|
+
return { dossierSortie: arguments_[1] };
|
|
9
|
+
}
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const options = lireArguments(process.argv.slice(2));
|
|
14
|
+
if (!options) {
|
|
15
|
+
console.error("Usage : ucm-typescript [--out <dossier>]");
|
|
16
|
+
process.exit(2);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const texte = rendreGeneration(genererTypes(process.cwd(), options));
|
|
21
|
+
if (texte) console.log(texte);
|
|
22
|
+
} catch (erreur) {
|
|
23
|
+
console.error(`✗ ${erreur?.message ?? erreur}`);
|
|
24
|
+
process.exit(2);
|
|
25
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/** Génère les unions TypeScript dérivées des contrats d'un repository. */
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { codeIdentifier } from "@ucm-kit/core/format";
|
|
6
|
+
import { libelleNombre, lireConfiguration, trouverContrats } from "@ucm-kit/core/lecteurs";
|
|
7
|
+
|
|
8
|
+
import { nomsEnumsDeVariantes, typeVariantesExactes } from "./types-variants.mjs";
|
|
9
|
+
|
|
10
|
+
/** « iconLeft » devient « IconLeft », en restant un identifiant TypeScript. */
|
|
11
|
+
function pascal(nom) {
|
|
12
|
+
return nom
|
|
13
|
+
.replace(/[^a-zA-Z0-9]+(.)/g, (_, lettre) => lettre.toUpperCase())
|
|
14
|
+
.replace(/[^a-zA-Z0-9]/g, "")
|
|
15
|
+
.replace(/^./, (lettre) => lettre.toUpperCase());
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Contenu d'un fichier dérivé d'un contrat, ou `null` s'il n'a aucun enum. */
|
|
19
|
+
export function typesDuContrat(contrat) {
|
|
20
|
+
const composant = codeIdentifier(String(contrat?.name ?? ""));
|
|
21
|
+
const enums = Object.entries(contrat?.props ?? {}).filter(
|
|
22
|
+
([, prop]) => (
|
|
23
|
+
prop?.type === "enum"
|
|
24
|
+
&& Array.isArray(prop.values)
|
|
25
|
+
&& prop.values.length > 0
|
|
26
|
+
&& prop.values.every((valeur) => typeof valeur === "string")
|
|
27
|
+
),
|
|
28
|
+
);
|
|
29
|
+
if (enums.length === 0) return null;
|
|
30
|
+
|
|
31
|
+
const types = enums.map(([nom, prop]) => {
|
|
32
|
+
const union = prop.values.map((valeur) => JSON.stringify(valeur)).join(" | ");
|
|
33
|
+
return `/** Valeurs de la prop « ${nom} » du contrat. */\nexport type ${composant}${pascal(nom)} = ${union};`;
|
|
34
|
+
});
|
|
35
|
+
const nomsAxes = nomsEnumsDeVariantes(
|
|
36
|
+
enums.map(([nom]) => nom),
|
|
37
|
+
contrat.structure?.variantAxes,
|
|
38
|
+
);
|
|
39
|
+
const typeExact = nomsAxes.length > 0
|
|
40
|
+
? typeVariantesExactes(composant, nomsAxes, contrat.variants)
|
|
41
|
+
: null;
|
|
42
|
+
if (typeExact) types.push(typeExact);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
composant,
|
|
46
|
+
nombreUnions: enums.length,
|
|
47
|
+
contenu: [
|
|
48
|
+
"/**",
|
|
49
|
+
` * Types dérivés de ${composant}.contract.json — NE PAS ÉDITER À LA MAIN.`,
|
|
50
|
+
" * Régénéré par `ucm-typescript` : ces unions reflètent mécaniquement les",
|
|
51
|
+
" * enums du contrat, comme une feuille de tokens reflète tokens.json.",
|
|
52
|
+
" */",
|
|
53
|
+
"",
|
|
54
|
+
types.join("\n\n"),
|
|
55
|
+
"",
|
|
56
|
+
].join("\n"),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Génère tous les fichiers et rend un compte rendu structuré.
|
|
62
|
+
*
|
|
63
|
+
* Un contrat illisible est omis : `ucm check` en possède le diagnostic précis,
|
|
64
|
+
* tandis que ce générateur ne doit jamais masquer ce rapport par une exception.
|
|
65
|
+
*/
|
|
66
|
+
export function genererTypes(racine = process.cwd(), { dossierSortie = "src/generated/contracts" } = {}) {
|
|
67
|
+
const { configuration, erreur } = lireConfiguration(racine);
|
|
68
|
+
if (erreur) throw new Error(erreur);
|
|
69
|
+
|
|
70
|
+
const sortie = resolve(racine, dossierSortie);
|
|
71
|
+
mkdirSync(sortie, { recursive: true });
|
|
72
|
+
const generes = [];
|
|
73
|
+
const omis = [];
|
|
74
|
+
|
|
75
|
+
for (const chemin of trouverContrats(join(racine, configuration.components))) {
|
|
76
|
+
let contrat;
|
|
77
|
+
try {
|
|
78
|
+
contrat = JSON.parse(readFileSync(chemin, "utf8").replace(/^/, ""));
|
|
79
|
+
} catch {
|
|
80
|
+
omis.push({ chemin, raison: "illisible" });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const resultat = typesDuContrat(contrat);
|
|
84
|
+
if (!resultat) continue;
|
|
85
|
+
const cible = join(sortie, `${resultat.composant}.ts`);
|
|
86
|
+
writeFileSync(cible, resultat.contenu, "utf8");
|
|
87
|
+
generes.push({ ...resultat, chemin: cible });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { generes, omis, dossierSortie: sortie };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Texte terminal d'un compte rendu de génération. */
|
|
94
|
+
export function rendreGeneration({ generes, omis }) {
|
|
95
|
+
return [
|
|
96
|
+
...generes.map(({ composant, nombreUnions, chemin }) => (
|
|
97
|
+
`✓ ${composant} : ${libelleNombre(nombreUnions, "union")} générée${nombreUnions === 1 ? "" : "s"}. Fichier : ${chemin}`
|
|
98
|
+
)),
|
|
99
|
+
...omis.map(({ chemin }) => `⚠ ${basename(chemin)} illisible : types non générés (lancez « ucm check »).`),
|
|
100
|
+
].join("\n");
|
|
101
|
+
}
|
package/src/index.d.mts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { AdaptateurDImplementation } from "@ucm-kit/core/lecteurs";
|
|
2
|
+
|
|
3
|
+
export const adaptateurTypeScript: AdaptateurDImplementation;
|
|
4
|
+
export function nomInterfaceAttendue(fichierComposant: string): string;
|
|
5
|
+
export function lireApiPublique(
|
|
6
|
+
fichiers: readonly string[],
|
|
7
|
+
racine: string,
|
|
8
|
+
): Map<string, unknown>;
|
|
9
|
+
export function ecartsDeParite(
|
|
10
|
+
contrat: unknown,
|
|
11
|
+
releve: unknown,
|
|
12
|
+
nomInterface: string,
|
|
13
|
+
options?: { presente?: boolean; chemin?: string },
|
|
14
|
+
): {
|
|
15
|
+
implementationAbsente: boolean;
|
|
16
|
+
implementationNonLue: string | null;
|
|
17
|
+
interfaceAbsente: string | null;
|
|
18
|
+
fonctionAbsente: string | null;
|
|
19
|
+
manquantes: string[];
|
|
20
|
+
typesIncorrects: Array<{ prop: string; attendu: string; recu: string }>;
|
|
21
|
+
booleensNonUtilises: string[];
|
|
22
|
+
compositionsIncorrectes: Array<{ component: string; attendu: number; rendu: number }>;
|
|
23
|
+
};
|
|
24
|
+
export function typesDuContrat(contrat: unknown): null | {
|
|
25
|
+
composant: string;
|
|
26
|
+
nombreUnions: number;
|
|
27
|
+
contenu: string;
|
|
28
|
+
};
|
|
29
|
+
export function genererTypes(
|
|
30
|
+
racine?: string,
|
|
31
|
+
options?: { dossierSortie?: string },
|
|
32
|
+
): {
|
|
33
|
+
generes: Array<{ composant: string; nombreUnions: number; contenu: string; chemin: string }>;
|
|
34
|
+
omis: Array<{ chemin: string; raison: string }>;
|
|
35
|
+
dossierSortie: string;
|
|
36
|
+
};
|
|
37
|
+
export function rendreGeneration(resultat: ReturnType<typeof genererTypes>): string;
|
package/src/index.mjs
ADDED
package/src/parite.mjs
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mesure la parité entre un contrat UCM et une implémentation TypeScript.
|
|
3
|
+
*
|
|
4
|
+
* Le vérificateur de types résout les membres hérités et les alias d'import.
|
|
5
|
+
* L'adaptateur ne compare que ce que le contrat gouverne : les props qu'il
|
|
6
|
+
* déclare, les BOOLEAN réellement lues et les dépendances composées rendues.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { basename, extname, join } from "node:path";
|
|
10
|
+
|
|
11
|
+
import { codeIdentifier } from "@ucm-kit/core/format";
|
|
12
|
+
import ts from "typescript";
|
|
13
|
+
|
|
14
|
+
/** Nom de l'interface qui porte l'API publique du composant. */
|
|
15
|
+
export function nomInterfaceAttendue(fichierComposant) {
|
|
16
|
+
return `${basename(fichierComposant, extname(fichierComposant))}Props`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Nom textuel d'une clé de binding, quand elle est statiquement connaissable. */
|
|
20
|
+
function nomDeBinding(noeud) {
|
|
21
|
+
return ts.isIdentifier(noeud) || ts.isStringLiteral(noeud) || ts.isNumericLiteral(noeud)
|
|
22
|
+
? noeud.text
|
|
23
|
+
: null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Fonction portée par une expression, à travers les emballages usuels. */
|
|
27
|
+
function fonctionEmballee(noeud) {
|
|
28
|
+
if (!noeud) return null;
|
|
29
|
+
if (ts.isArrowFunction(noeud) || ts.isFunctionExpression(noeud)) return noeud;
|
|
30
|
+
if (!ts.isCallExpression(noeud)) return null;
|
|
31
|
+
for (const argument of noeud.arguments) {
|
|
32
|
+
const fonction = fonctionEmballee(argument);
|
|
33
|
+
if (fonction) return fonction;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Recherche la fonction qui porte le même nom que le fichier, ou l'export par défaut. */
|
|
39
|
+
function trouverFonctionComposant(source, nomComposant) {
|
|
40
|
+
let nommee = null;
|
|
41
|
+
let parDefaut = null;
|
|
42
|
+
|
|
43
|
+
ts.forEachChild(source, (noeud) => {
|
|
44
|
+
if (ts.isFunctionDeclaration(noeud) && noeud.name?.text === nomComposant) {
|
|
45
|
+
nommee = noeud;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (ts.isExportAssignment(noeud) && !noeud.isExportEquals) {
|
|
49
|
+
parDefaut = fonctionEmballee(noeud.expression);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (!ts.isVariableStatement(noeud)) return;
|
|
53
|
+
for (const declaration of noeud.declarationList.declarations) {
|
|
54
|
+
if (ts.isIdentifier(declaration.name) && declaration.name.text === nomComposant) {
|
|
55
|
+
nommee = fonctionEmballee(declaration.initializer) ?? nommee;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return nommee ?? parDefaut;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Vrai si un symbole local est réellement lu dans le corps du composant. */
|
|
64
|
+
function symboleLuDans(noeud, symbole, verificateur) {
|
|
65
|
+
let lu = false;
|
|
66
|
+
const visiter = (enfant) => {
|
|
67
|
+
if (lu) return;
|
|
68
|
+
if (ts.isIdentifier(enfant)) {
|
|
69
|
+
const symboleValeur = ts.isShorthandPropertyAssignment(enfant.parent)
|
|
70
|
+
? verificateur.getShorthandAssignmentValueSymbol(enfant.parent)
|
|
71
|
+
: verificateur.getSymbolAtLocation(enfant);
|
|
72
|
+
if (symboleValeur === symbole) {
|
|
73
|
+
lu = true;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
ts.forEachChild(enfant, visiter);
|
|
78
|
+
};
|
|
79
|
+
visiter(noeud);
|
|
80
|
+
return lu;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Relève les props effectivement consommées par la fonction du composant. */
|
|
84
|
+
function propsConsommees(fonction, nomsProps, verificateur) {
|
|
85
|
+
const consommees = new Set();
|
|
86
|
+
const parametre = fonction?.parameters[0];
|
|
87
|
+
if (!parametre || !fonction.body) return consommees;
|
|
88
|
+
|
|
89
|
+
if (ts.isObjectBindingPattern(parametre.name)) {
|
|
90
|
+
for (const element of parametre.name.elements) {
|
|
91
|
+
if (element.dotDotDotToken || !ts.isIdentifier(element.name)) continue;
|
|
92
|
+
const nom = nomDeBinding(element.propertyName ?? element.name);
|
|
93
|
+
const symbole = verificateur.getSymbolAtLocation(element.name);
|
|
94
|
+
if (nom && symbole && symboleLuDans(fonction.body, symbole, verificateur)) {
|
|
95
|
+
consommees.add(nom);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return consommees;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!ts.isIdentifier(parametre.name)) return consommees;
|
|
102
|
+
const symboleParametre = verificateur.getSymbolAtLocation(parametre.name);
|
|
103
|
+
if (!symboleParametre) return consommees;
|
|
104
|
+
|
|
105
|
+
const estLeParametre = (expression) => (
|
|
106
|
+
ts.isIdentifier(expression)
|
|
107
|
+
&& verificateur.getSymbolAtLocation(expression) === symboleParametre
|
|
108
|
+
);
|
|
109
|
+
const visiter = (noeud) => {
|
|
110
|
+
if (ts.isPropertyAccessExpression(noeud) && estLeParametre(noeud.expression)) {
|
|
111
|
+
consommees.add(noeud.name.text);
|
|
112
|
+
} else if (
|
|
113
|
+
ts.isElementAccessExpression(noeud)
|
|
114
|
+
&& estLeParametre(noeud.expression)
|
|
115
|
+
&& noeud.argumentExpression
|
|
116
|
+
&& ts.isStringLiteral(noeud.argumentExpression)
|
|
117
|
+
) {
|
|
118
|
+
consommees.add(noeud.argumentExpression.text);
|
|
119
|
+
} else if (
|
|
120
|
+
(ts.isJsxSpreadAttribute(noeud) || ts.isSpreadAssignment(noeud))
|
|
121
|
+
&& estLeParametre(noeud.expression)
|
|
122
|
+
) {
|
|
123
|
+
for (const nom of nomsProps) consommees.add(nom);
|
|
124
|
+
}
|
|
125
|
+
ts.forEachChild(noeud, visiter);
|
|
126
|
+
};
|
|
127
|
+
visiter(fonction.body);
|
|
128
|
+
return consommees;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Additionne des occurrences rendues ensemble. */
|
|
132
|
+
function additionnerRendus(...releves) {
|
|
133
|
+
const somme = new Map();
|
|
134
|
+
for (const releve of releves) {
|
|
135
|
+
for (const [nom, occurrences] of releve) {
|
|
136
|
+
somme.set(nom, (somme.get(nom) ?? 0) + occurrences);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return somme;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Garde, composant par composant, la cardinalité maximale de vues alternatives. */
|
|
143
|
+
function maximumRendus(...releves) {
|
|
144
|
+
const maximum = new Map();
|
|
145
|
+
for (const releve of releves) {
|
|
146
|
+
for (const [nom, occurrences] of releve) {
|
|
147
|
+
maximum.set(nom, Math.max(maximum.get(nom) ?? 0, occurrences));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return maximum;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Fonction portée par le symbole d'une balise locale. */
|
|
154
|
+
function fonctionLocale(symbole) {
|
|
155
|
+
for (const declaration of symbole?.declarations ?? []) {
|
|
156
|
+
if (ts.isFunctionDeclaration(declaration)) return declaration;
|
|
157
|
+
if (ts.isVariableDeclaration(declaration)) {
|
|
158
|
+
const fonction = fonctionEmballee(declaration.initializer);
|
|
159
|
+
if (fonction) return fonction;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Relève les composants rendus par une fonction et ses vues locales.
|
|
167
|
+
*
|
|
168
|
+
* Les frères s'additionnent. Les branches alternatives gardent leur maximum,
|
|
169
|
+
* comme le `composes` global du contrat. Une vue locale est dépliée jusqu'aux
|
|
170
|
+
* composants importés et n'est jamais prise pour une dépendance contractuelle.
|
|
171
|
+
*/
|
|
172
|
+
function composantsRendus(fonction, verificateur) {
|
|
173
|
+
const enCours = new Set();
|
|
174
|
+
|
|
175
|
+
const releverFonction = (fonctionLue) => {
|
|
176
|
+
if (!fonctionLue?.body || enCours.has(fonctionLue)) return new Map();
|
|
177
|
+
enCours.add(fonctionLue);
|
|
178
|
+
const rendus = releverNoeud(fonctionLue.body);
|
|
179
|
+
enCours.delete(fonctionLue);
|
|
180
|
+
return rendus;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const releverBalise = (balise) => {
|
|
184
|
+
if (!ts.isIdentifier(balise) || !/^[A-Z]/.test(balise.text)) return new Map();
|
|
185
|
+
const symbole = verificateur.getSymbolAtLocation(balise);
|
|
186
|
+
const locale = symbole && !(symbole.flags & ts.SymbolFlags.Alias)
|
|
187
|
+
? fonctionLocale(symbole)
|
|
188
|
+
: null;
|
|
189
|
+
if (locale) return releverFonction(locale);
|
|
190
|
+
|
|
191
|
+
let nom = balise.text;
|
|
192
|
+
if (symbole && symbole.flags & ts.SymbolFlags.Alias) {
|
|
193
|
+
const nomOriginal = verificateur.getAliasedSymbol(symbole).getName();
|
|
194
|
+
if (nomOriginal !== "default") nom = nomOriginal;
|
|
195
|
+
}
|
|
196
|
+
return new Map([[nom, 1]]);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
function releverNoeud(noeud) {
|
|
200
|
+
if (ts.isConditionalExpression(noeud)) {
|
|
201
|
+
return maximumRendus(releverNoeud(noeud.whenTrue), releverNoeud(noeud.whenFalse));
|
|
202
|
+
}
|
|
203
|
+
if (
|
|
204
|
+
ts.isBinaryExpression(noeud)
|
|
205
|
+
&& [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken]
|
|
206
|
+
.includes(noeud.operatorToken.kind)
|
|
207
|
+
) {
|
|
208
|
+
return releverNoeud(noeud.right);
|
|
209
|
+
}
|
|
210
|
+
if (ts.isJsxSelfClosingElement(noeud)) return releverBalise(noeud.tagName);
|
|
211
|
+
if (ts.isJsxElement(noeud)) {
|
|
212
|
+
return additionnerRendus(
|
|
213
|
+
releverBalise(noeud.openingElement.tagName),
|
|
214
|
+
...noeud.children.map(releverNoeud),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const enfants = [];
|
|
219
|
+
// `forEachChild` s'arrête si le callback rend une valeur truthy. La forme
|
|
220
|
+
// en bloc est donc nécessaire : `enfants.push(...)` rendrait sa longueur
|
|
221
|
+
// et couperait le parcours au premier enfant.
|
|
222
|
+
ts.forEachChild(noeud, (enfant) => {
|
|
223
|
+
enfants.push(releverNoeud(enfant));
|
|
224
|
+
});
|
|
225
|
+
return additionnerRendus(...enfants);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return releverFonction(fonction);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Lit le `tsconfig.json` racine, précondition explicite de l'adaptateur. */
|
|
232
|
+
function optionsTypeScript(racine) {
|
|
233
|
+
const chemin = join(racine, "tsconfig.json");
|
|
234
|
+
if (!existsSync(chemin)) {
|
|
235
|
+
throw new Error(`L'adaptateur TypeScript exige un tsconfig.json à la racine du repository : ${chemin}`);
|
|
236
|
+
}
|
|
237
|
+
const config = ts.readConfigFile(chemin, ts.sys.readFile);
|
|
238
|
+
if (config.error) {
|
|
239
|
+
const message = ts.flattenDiagnosticMessageText(config.error.messageText, "\n");
|
|
240
|
+
throw new Error(`Le tsconfig.json du repository est illisible : ${message}`);
|
|
241
|
+
}
|
|
242
|
+
const analysee = ts.parseJsonConfigFileContent(config.config, ts.sys, racine);
|
|
243
|
+
if (analysee.errors.length > 0) {
|
|
244
|
+
const message = analysee.errors
|
|
245
|
+
.map((erreur) => ts.flattenDiagnosticMessageText(erreur.messageText, "\n"))
|
|
246
|
+
.join("\n");
|
|
247
|
+
throw new Error(`Le tsconfig.json du repository est invalide : ${message}`);
|
|
248
|
+
}
|
|
249
|
+
return analysee.options;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Relève en un seul programme les API publiques des fichiers qui existent. */
|
|
253
|
+
export function lireApiPublique(fichiers, racine) {
|
|
254
|
+
const api = new Map();
|
|
255
|
+
const existants = fichiers.filter((fichier) => existsSync(fichier));
|
|
256
|
+
if (existants.length === 0) return api;
|
|
257
|
+
|
|
258
|
+
const programme = ts.createProgram(existants, optionsTypeScript(racine));
|
|
259
|
+
const verificateur = programme.getTypeChecker();
|
|
260
|
+
|
|
261
|
+
for (const fichier of existants) {
|
|
262
|
+
const source = programme.getSourceFile(fichier);
|
|
263
|
+
if (!source) continue;
|
|
264
|
+
const attendue = nomInterfaceAttendue(fichier);
|
|
265
|
+
const nomComposant = basename(fichier, extname(fichier));
|
|
266
|
+
const fonction = trouverFonctionComposant(source, nomComposant);
|
|
267
|
+
let props = null;
|
|
268
|
+
|
|
269
|
+
ts.forEachChild(source, (noeud) => {
|
|
270
|
+
if (!ts.isInterfaceDeclaration(noeud) || noeud.name.text !== attendue) return;
|
|
271
|
+
const type = verificateur.getTypeAtLocation(noeud.name);
|
|
272
|
+
const membres = verificateur.getPropertiesOfType(type);
|
|
273
|
+
const consommees = propsConsommees(
|
|
274
|
+
fonction,
|
|
275
|
+
membres.map((membre) => membre.getName()),
|
|
276
|
+
verificateur,
|
|
277
|
+
);
|
|
278
|
+
props = Object.fromEntries(membres.map((membre) => {
|
|
279
|
+
const typeMembre = verificateur.getTypeOfSymbolAtLocation(
|
|
280
|
+
membre,
|
|
281
|
+
membre.valueDeclaration ?? noeud,
|
|
282
|
+
);
|
|
283
|
+
const significatifs = (typeMembre.isUnion() ? typeMembre.types : [typeMembre])
|
|
284
|
+
.filter((item) => !(item.flags & ts.TypeFlags.Undefined));
|
|
285
|
+
const estBoolean = significatifs.length > 0 && significatifs.every(
|
|
286
|
+
(item) => Boolean(item.flags & (ts.TypeFlags.Boolean | ts.TypeFlags.BooleanLiteral)),
|
|
287
|
+
);
|
|
288
|
+
return [membre.getName(), {
|
|
289
|
+
type: estBoolean ? "boolean" : "autre",
|
|
290
|
+
typescript: verificateur.typeToString(typeMembre),
|
|
291
|
+
utilisee: consommees.has(membre.getName()),
|
|
292
|
+
}];
|
|
293
|
+
}));
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
api.set(fichier, {
|
|
297
|
+
props,
|
|
298
|
+
fonctionTrouvee: Boolean(fonction),
|
|
299
|
+
composants: composantsRendus(fonction, verificateur),
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return api;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Compare le contrat au relevé TypeScript, sans rendre le diagnostic. */
|
|
307
|
+
export function ecartsDeParite(contrat, releve, nomInterface, options = {}) {
|
|
308
|
+
const vide = {
|
|
309
|
+
implementationAbsente: false,
|
|
310
|
+
implementationNonLue: null,
|
|
311
|
+
interfaceAbsente: null,
|
|
312
|
+
fonctionAbsente: null,
|
|
313
|
+
manquantes: [],
|
|
314
|
+
typesIncorrects: [],
|
|
315
|
+
booleensNonUtilises: [],
|
|
316
|
+
compositionsIncorrectes: [],
|
|
317
|
+
};
|
|
318
|
+
if (!releve) {
|
|
319
|
+
return options.presente
|
|
320
|
+
? { ...vide, implementationNonLue: options.chemin ?? nomInterface }
|
|
321
|
+
: { ...vide, implementationAbsente: true };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const { props, composants = new Map(), fonctionTrouvee = true } = releve;
|
|
325
|
+
if (props === null) return { ...vide, interfaceAbsente: nomInterface };
|
|
326
|
+
if (!fonctionTrouvee) return { ...vide, fonctionAbsente: contrat?.name ?? nomInterface };
|
|
327
|
+
|
|
328
|
+
const attendues = new Map();
|
|
329
|
+
for (const dependance of contrat?.composes ?? []) {
|
|
330
|
+
if (typeof dependance?.component === "string") {
|
|
331
|
+
attendues.set(
|
|
332
|
+
dependance.component,
|
|
333
|
+
(attendues.get(dependance.component) ?? 0) + 1,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const compositionsIncorrectes = Array.from(attendues, ([component, attendu]) => ({
|
|
338
|
+
component,
|
|
339
|
+
attendu,
|
|
340
|
+
rendu: composants.get(codeIdentifier(component)) ?? 0,
|
|
341
|
+
}))
|
|
342
|
+
.filter(({ attendu, rendu }) => rendu !== attendu)
|
|
343
|
+
.sort((gauche, droite) => gauche.component.localeCompare(droite.component));
|
|
344
|
+
|
|
345
|
+
const declarees = Object.entries(contrat?.props ?? {});
|
|
346
|
+
const manquantes = declarees
|
|
347
|
+
.map(([nom]) => nom)
|
|
348
|
+
.filter((nom) => !(nom in props))
|
|
349
|
+
.sort();
|
|
350
|
+
const typesIncorrects = declarees
|
|
351
|
+
.filter(([nom, prop]) => nom in props && prop?.type === "boolean" && props[nom].type !== "boolean")
|
|
352
|
+
.map(([nom]) => ({ prop: nom, attendu: "boolean", recu: props[nom].typescript }))
|
|
353
|
+
.sort((gauche, droite) => gauche.prop.localeCompare(droite.prop));
|
|
354
|
+
const booleensNonUtilises = declarees
|
|
355
|
+
.filter(([nom, prop]) => (
|
|
356
|
+
nom in props
|
|
357
|
+
&& prop?.type === "boolean"
|
|
358
|
+
&& props[nom].type === "boolean"
|
|
359
|
+
&& props[nom].utilisee !== true
|
|
360
|
+
))
|
|
361
|
+
.map(([nom]) => nom)
|
|
362
|
+
.sort();
|
|
363
|
+
|
|
364
|
+
return { ...vide, manquantes, typesIncorrects, booleensNonUtilises, compositionsIncorrectes };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Surface que `@ucm-kit/core/lecteurs` attend d'un adaptateur. */
|
|
368
|
+
export const adaptateurTypeScript = Object.freeze({
|
|
369
|
+
lireApiPublique,
|
|
370
|
+
nomInterfaceAttendue,
|
|
371
|
+
ecartsDeParite,
|
|
372
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Produit le type discriminé des combinaisons d'enums réellement exportées. */
|
|
2
|
+
export function typeVariantesExactes(composant, nomsEnums, variants) {
|
|
3
|
+
const combinaisons = [];
|
|
4
|
+
const vues = new Set();
|
|
5
|
+
for (const variant of Array.isArray(variants) ? variants : []) {
|
|
6
|
+
const valeurs = Object.fromEntries(
|
|
7
|
+
nomsEnums
|
|
8
|
+
.filter((nom) => Object.hasOwn(variant?.values ?? {}, nom))
|
|
9
|
+
.map((nom) => [nom, variant.values[nom]]),
|
|
10
|
+
);
|
|
11
|
+
if (Object.keys(valeurs).length !== nomsEnums.length) continue;
|
|
12
|
+
const signature = JSON.stringify(valeurs);
|
|
13
|
+
if (vues.has(signature)) continue;
|
|
14
|
+
vues.add(signature);
|
|
15
|
+
combinaisons.push(valeurs);
|
|
16
|
+
}
|
|
17
|
+
if (combinaisons.length === 0) return null;
|
|
18
|
+
|
|
19
|
+
const membres = combinaisons.map((valeurs) => {
|
|
20
|
+
const proprietes = Object.entries(valeurs)
|
|
21
|
+
.map(([nom, valeur]) => `${JSON.stringify(nom)}: ${JSON.stringify(valeur)}`)
|
|
22
|
+
.join("; ");
|
|
23
|
+
return ` | { ${proprietes} }`;
|
|
24
|
+
});
|
|
25
|
+
return `/** Combinaisons de props réellement présentes dans Figma. */\n`
|
|
26
|
+
+ `export type ${composant}VariantProps =\n${membres.join("\n")};`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Les enums d'API ne sont pas tous des axes : un wrapper peut en ajouter. */
|
|
30
|
+
export function nomsEnumsDeVariantes(nomsEnums, axes) {
|
|
31
|
+
const axesPublies = new Set(Array.isArray(axes) ? axes : []);
|
|
32
|
+
return nomsEnums.filter((nom) => axesPublies.has(nom));
|
|
33
|
+
}
|