@angularkit/inventory 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/LICENSE +9 -0
- package/README.md +73 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +32 -0
- package/dist/inventory.d.ts +35 -0
- package/dist/inventory.js +352 -0
- package/package.json +63 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gaetan / EURL ReCowd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# @angularkit/inventory
|
|
2
|
+
|
|
3
|
+
Répond à la question « est-ce que ce composant existe déjà dans ma codebase ? » sans grep ni Slack.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx @angularkit/inventory . # rapport sur stdout
|
|
7
|
+
npx @angularkit/inventory . --md COMPONENTS.md # catalogue à committer / donner à l'agent
|
|
8
|
+
npx @angularkit/inventory . --json components.json
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Ce que ça sort
|
|
12
|
+
|
|
13
|
+
- **Catalogue** : chaque `@Component`, son sélecteur, ses inputs/outputs (décorateurs et API signal), s'il est exporté par un `index.ts`/`public-api.ts`, combien de fois et où il est utilisé.
|
|
14
|
+
- **Concepts en doublon** : `ProfileCardComponent`, `StatTile`, `OrderPanel` et `UiCard` sont regroupés sous *card* (synonymes : card/tile/panel/box, modal/dialog/popup, …).
|
|
15
|
+
- **Quasi-composants** : mêmes signatures de classes CSS (≥ 4 classes) copiées-collées dans plusieurs templates — le composant qui n'a jamais été extrait.
|
|
16
|
+
- **Jamais utilisés** : composants qu'aucun template ne référence (pages routées, ou code mort).
|
|
17
|
+
|
|
18
|
+
## Comment ça marche
|
|
19
|
+
|
|
20
|
+
Analyse statique via l'API du compilateur TypeScript (pas besoin de compiler le projet, pas besoin d'Angular installé). Zéro réseau, zéro télémétrie : rien ne sort du poste.
|
|
21
|
+
|
|
22
|
+
Ignore `node_modules`, `dist`, `.nx`, `.angular`, `coverage`, `*.spec.ts`, `*.stories.ts`.
|
|
23
|
+
|
|
24
|
+
## Limites connues (v0.1)
|
|
25
|
+
|
|
26
|
+
- Les usages sont comptés par regex sur les templates : `<app-card` et `[appHighlight]`. Les sélecteurs de classe ou complexes sont ignorés.
|
|
27
|
+
- Le clustering est lexical (nom + synonymes), pas sémantique. Il produit des faux positifs assumés : mieux vaut trop signaler que rater un doublon.
|
|
28
|
+
- Un composant `templateUrl` pointant hors du projet n'est pas résolu.
|
|
29
|
+
- Outil fourni « as is ».
|
|
30
|
+
|
|
31
|
+
## Utilisation avec un agent
|
|
32
|
+
|
|
33
|
+
Génère `COMPONENTS.md` et référence-le depuis ton `CLAUDE.md` / `AGENTS.md` :
|
|
34
|
+
|
|
35
|
+
> Avant de créer un composant UI, lis `COMPONENTS.md` et réutilise l'existant.
|
|
36
|
+
|
|
37
|
+
## Développement
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm ci
|
|
41
|
+
npm test # tests node:test sur le fixture
|
|
42
|
+
npm run dev -- fixture
|
|
43
|
+
npm run build
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Publier une version
|
|
47
|
+
|
|
48
|
+
Tout est automatique via [release-please](https://github.com/googleapis/release-please) et les
|
|
49
|
+
[Conventional Commits](https://www.conventionalcommits.org/fr/) :
|
|
50
|
+
|
|
51
|
+
1. Les commits sur `main` suivent la convention : `feat: …` (version mineure), `fix: …` (patch),
|
|
52
|
+
`feat!: …` ou footer `BREAKING CHANGE:` (majeure). Les `chore:`, `docs:`, `refactor:` n'ouvrent pas de release.
|
|
53
|
+
2. release-please ouvre et maintient une PR « chore(main): release X.Y.Z » qui met à jour
|
|
54
|
+
`package.json` et `CHANGELOG.md`.
|
|
55
|
+
3. Merger cette PR crée le tag `vX.Y.Z`, la GitHub Release, puis publie sur npm avec provenance
|
|
56
|
+
(`.github/workflows/release.yml`).
|
|
57
|
+
|
|
58
|
+
Pour forcer un numéro précis, ajouter un footer `Release-As: 1.2.3` à un commit.
|
|
59
|
+
|
|
60
|
+
Mise en place, une seule fois :
|
|
61
|
+
|
|
62
|
+
- **GitHub, au choix** :
|
|
63
|
+
- **Token dédié (recommandé)** : créer un PAT *fine-grained* limité à ce repo avec *Contents* et
|
|
64
|
+
*Pull requests* en lecture/écriture, et le poser en secret `RELEASE_PLEASE_TOKEN`. Avantage : la PR de
|
|
65
|
+
release déclenche la CI, ce qu'une PR ouverte avec `GITHUB_TOKEN` ne fait jamais.
|
|
66
|
+
- **Sans token** : activer *Allow GitHub Actions to create and approve pull requests* dans
|
|
67
|
+
Settings → Actions → General, d'abord au niveau de l'organisation (sinon la case est grisée dans le repo),
|
|
68
|
+
puis dans le repo.
|
|
69
|
+
- **npm, au choix** :
|
|
70
|
+
- Secret `NPM_TOKEN` (automation token, bypass 2FA) dans le repo. Indispensable pour la toute première publication.
|
|
71
|
+
- Ensuite, **Trusted Publishing (recommandé, sans token)** : sur npmjs.com → package → *Settings* →
|
|
72
|
+
*Trusted Publisher* : repo `AngularKit/inventory`, workflow `release.yml`, environnement `npm`.
|
|
73
|
+
Le secret peut alors être supprimé.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { scan, toMarkdown } from './inventory.js';
|
|
5
|
+
const args = process.argv.slice(2);
|
|
6
|
+
const usage = `Usage: angular-inventory [dir] [--json <file>] [--md <file>] [--quiet]
|
|
7
|
+
|
|
8
|
+
Scanne un projet Angular et produit l'inventaire des composants :
|
|
9
|
+
sélecteurs, inputs/outputs, visibilité (API publique), usages, doublons de concept, quasi-composants.
|
|
10
|
+
|
|
11
|
+
dir racine à scanner (défaut : .)
|
|
12
|
+
--json <file> écrit l'inventaire JSON (ex. components.json)
|
|
13
|
+
--md <file> écrit le rapport Markdown (ex. COMPONENTS.md)
|
|
14
|
+
--quiet n'affiche pas le rapport sur stdout`;
|
|
15
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
16
|
+
console.log(usage);
|
|
17
|
+
process.exit(0);
|
|
18
|
+
}
|
|
19
|
+
const opt = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
|
|
20
|
+
const dir = args.find((a, i) => !a.startsWith('--') && args[i - 1] !== '--json' && args[i - 1] !== '--md') ?? '.';
|
|
21
|
+
const inv = scan(dir);
|
|
22
|
+
const md = toMarkdown(inv);
|
|
23
|
+
const jsonOut = opt('--json');
|
|
24
|
+
const mdOut = opt('--md');
|
|
25
|
+
if (jsonOut)
|
|
26
|
+
fs.writeFileSync(path.resolve(jsonOut), JSON.stringify(inv, null, 2));
|
|
27
|
+
if (mdOut)
|
|
28
|
+
fs.writeFileSync(path.resolve(mdOut), md);
|
|
29
|
+
if (!args.includes('--quiet'))
|
|
30
|
+
console.log(md);
|
|
31
|
+
if (jsonOut || mdOut)
|
|
32
|
+
console.error(`\nÉcrit : ${[jsonOut, mdOut].filter(Boolean).join(', ')}`);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface ComponentInfo {
|
|
2
|
+
className: string;
|
|
3
|
+
selectors: string[];
|
|
4
|
+
file: string;
|
|
5
|
+
templateFile?: string;
|
|
6
|
+
inputs: string[];
|
|
7
|
+
outputs: string[];
|
|
8
|
+
public: boolean;
|
|
9
|
+
usages: number;
|
|
10
|
+
usedIn: string[];
|
|
11
|
+
}
|
|
12
|
+
export interface Cluster {
|
|
13
|
+
concept: string;
|
|
14
|
+
components: string[];
|
|
15
|
+
}
|
|
16
|
+
export interface ClassPattern {
|
|
17
|
+
classes: string;
|
|
18
|
+
count: number;
|
|
19
|
+
files: string[];
|
|
20
|
+
}
|
|
21
|
+
export interface Inventory {
|
|
22
|
+
root: string;
|
|
23
|
+
scannedAt: string;
|
|
24
|
+
components: ComponentInfo[];
|
|
25
|
+
clusters: Cluster[];
|
|
26
|
+
quasiComponents: ClassPattern[];
|
|
27
|
+
stats: {
|
|
28
|
+
total: number;
|
|
29
|
+
public: number;
|
|
30
|
+
private: number;
|
|
31
|
+
unused: number;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export declare function scan(rootInput: string): Inventory;
|
|
35
|
+
export declare function toMarkdown(inv: Inventory): string;
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import ts from 'typescript';
|
|
4
|
+
// ---------- Filesystem walk ----------
|
|
5
|
+
const IGNORED_DIRS = new Set([
|
|
6
|
+
'node_modules', 'dist', '.nx', '.angular', 'coverage', '.git', 'tmp', 'out-tsc', 'storybook-static',
|
|
7
|
+
]);
|
|
8
|
+
function walk(dir, acc = []) {
|
|
9
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
10
|
+
if (entry.isDirectory()) {
|
|
11
|
+
if (!IGNORED_DIRS.has(entry.name))
|
|
12
|
+
walk(path.join(dir, entry.name), acc);
|
|
13
|
+
}
|
|
14
|
+
else if (entry.isFile()) {
|
|
15
|
+
acc.push(path.join(dir, entry.name));
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return acc;
|
|
19
|
+
}
|
|
20
|
+
// ---------- Decorator extraction ----------
|
|
21
|
+
function decoratorsOf(node) {
|
|
22
|
+
return ts.canHaveDecorators(node) ? ts.getDecorators(node) ?? [] : [];
|
|
23
|
+
}
|
|
24
|
+
function decoratorName(d) {
|
|
25
|
+
const e = d.expression;
|
|
26
|
+
if (ts.isCallExpression(e) && ts.isIdentifier(e.expression))
|
|
27
|
+
return e.expression.text;
|
|
28
|
+
if (ts.isIdentifier(e))
|
|
29
|
+
return e.text;
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
function decoratorArg(d) {
|
|
33
|
+
const e = d.expression;
|
|
34
|
+
if (ts.isCallExpression(e) && e.arguments[0] && ts.isObjectLiteralExpression(e.arguments[0])) {
|
|
35
|
+
return e.arguments[0];
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
function stringProp(obj, name) {
|
|
40
|
+
for (const p of obj.properties) {
|
|
41
|
+
if (ts.isPropertyAssignment(p) && p.name.getText() === name) {
|
|
42
|
+
const init = p.initializer;
|
|
43
|
+
if (ts.isStringLiteral(init) || ts.isNoSubstitutionTemplateLiteral(init))
|
|
44
|
+
return init.text;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
/** Detects `x = input(...)`, `input.required(...)`, `model(...)`, `output(...)` (signal-based APIs). */
|
|
50
|
+
function signalKind(init) {
|
|
51
|
+
if (!init || !ts.isCallExpression(init))
|
|
52
|
+
return undefined;
|
|
53
|
+
const callee = init.expression;
|
|
54
|
+
let name;
|
|
55
|
+
if (ts.isIdentifier(callee))
|
|
56
|
+
name = callee.text;
|
|
57
|
+
else if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression))
|
|
58
|
+
name = callee.expression.text; // input.required
|
|
59
|
+
if (name === 'input' || name === 'model')
|
|
60
|
+
return 'input';
|
|
61
|
+
if (name === 'output' || name === 'outputFromObservable')
|
|
62
|
+
return 'output';
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
function extractComponents(sourceFile, root) {
|
|
66
|
+
const result = [];
|
|
67
|
+
const visit = (node) => {
|
|
68
|
+
if (ts.isClassDeclaration(node) && node.name) {
|
|
69
|
+
const dec = decoratorsOf(node).find((d) => decoratorName(d) === 'Component');
|
|
70
|
+
if (dec) {
|
|
71
|
+
const meta = decoratorArg(dec);
|
|
72
|
+
const selector = meta ? stringProp(meta, 'selector') : undefined;
|
|
73
|
+
const templateUrl = meta ? stringProp(meta, 'templateUrl') : undefined;
|
|
74
|
+
const inputs = [];
|
|
75
|
+
const outputs = [];
|
|
76
|
+
for (const m of node.members) {
|
|
77
|
+
if (ts.isPropertyDeclaration(m) || ts.isSetAccessorDeclaration(m)) {
|
|
78
|
+
const names = decoratorsOf(m).map(decoratorName);
|
|
79
|
+
const prop = m.name.getText();
|
|
80
|
+
if (names.includes('Input'))
|
|
81
|
+
inputs.push(prop);
|
|
82
|
+
else if (names.includes('Output'))
|
|
83
|
+
outputs.push(prop);
|
|
84
|
+
else if (ts.isPropertyDeclaration(m)) {
|
|
85
|
+
const k = signalKind(m.initializer);
|
|
86
|
+
if (k === 'input')
|
|
87
|
+
inputs.push(prop);
|
|
88
|
+
if (k === 'output')
|
|
89
|
+
outputs.push(prop);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
result.push({
|
|
94
|
+
className: node.name.text,
|
|
95
|
+
selectors: selector ? selector.split(',').map((s) => s.trim()).filter(Boolean) : [],
|
|
96
|
+
file: path.relative(root, sourceFile.fileName),
|
|
97
|
+
templateFile: templateUrl ? path.relative(root, path.resolve(path.dirname(sourceFile.fileName), templateUrl)) : undefined,
|
|
98
|
+
inputs,
|
|
99
|
+
outputs,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
ts.forEachChild(node, visit);
|
|
104
|
+
};
|
|
105
|
+
visit(sourceFile);
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
/** Inline templates (`template: \`...\``) of every component in a file, keyed by class name. */
|
|
109
|
+
function inlineTemplates(sourceFile) {
|
|
110
|
+
const map = new Map();
|
|
111
|
+
const visit = (node) => {
|
|
112
|
+
if (ts.isClassDeclaration(node) && node.name) {
|
|
113
|
+
const dec = decoratorsOf(node).find((d) => decoratorName(d) === 'Component');
|
|
114
|
+
const meta = dec && decoratorArg(dec);
|
|
115
|
+
const tpl = meta && stringProp(meta, 'template');
|
|
116
|
+
if (tpl)
|
|
117
|
+
map.set(node.name.text, tpl);
|
|
118
|
+
}
|
|
119
|
+
ts.forEachChild(node, visit);
|
|
120
|
+
};
|
|
121
|
+
visit(sourceFile);
|
|
122
|
+
return map;
|
|
123
|
+
}
|
|
124
|
+
// ---------- Public API resolution ----------
|
|
125
|
+
const ENTRY_FILES = new Set(['index.ts', 'public-api.ts', 'public_api.ts']);
|
|
126
|
+
function resolveModule(from, spec) {
|
|
127
|
+
if (!spec.startsWith('.'))
|
|
128
|
+
return undefined;
|
|
129
|
+
const base = path.resolve(path.dirname(from), spec);
|
|
130
|
+
const candidates = [base + '.ts', path.join(base, 'index.ts'), path.join(base, 'public-api.ts'), base];
|
|
131
|
+
return candidates.find((c) => fs.existsSync(c) && fs.statSync(c).isFile());
|
|
132
|
+
}
|
|
133
|
+
/** Set of files reachable through re-exports from any entry file. */
|
|
134
|
+
function publicFiles(entryFiles) {
|
|
135
|
+
const seen = new Set();
|
|
136
|
+
const queue = [...entryFiles];
|
|
137
|
+
while (queue.length) {
|
|
138
|
+
const f = queue.pop();
|
|
139
|
+
if (seen.has(f))
|
|
140
|
+
continue;
|
|
141
|
+
seen.add(f);
|
|
142
|
+
const sf = ts.createSourceFile(f, fs.readFileSync(f, 'utf8'), ts.ScriptTarget.Latest, true);
|
|
143
|
+
for (const st of sf.statements) {
|
|
144
|
+
if (ts.isExportDeclaration(st) && st.moduleSpecifier && ts.isStringLiteral(st.moduleSpecifier)) {
|
|
145
|
+
const target = resolveModule(f, st.moduleSpecifier.text);
|
|
146
|
+
if (target)
|
|
147
|
+
queue.push(target);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return seen;
|
|
152
|
+
}
|
|
153
|
+
// ---------- Usage counting ----------
|
|
154
|
+
function escapeRe(s) {
|
|
155
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
156
|
+
}
|
|
157
|
+
/** Regex matching a selector inside a template: `<app-card` or `appHighlight` as attribute. */
|
|
158
|
+
function selectorRegex(selector) {
|
|
159
|
+
const attr = selector.match(/^\[([\w-]+)\]$/);
|
|
160
|
+
if (attr)
|
|
161
|
+
return new RegExp(`[\\s(\\[]\\(?\\[?${escapeRe(attr[1])}\\]?[\\s=>\\]/)]`, 'g');
|
|
162
|
+
if (/^[\w-]+$/.test(selector))
|
|
163
|
+
return new RegExp(`<${escapeRe(selector)}[\\s>/]`, 'g');
|
|
164
|
+
return undefined; // class selectors, complex selectors: skipped
|
|
165
|
+
}
|
|
166
|
+
// ---------- Clustering by concept ----------
|
|
167
|
+
const SYNONYMS = [
|
|
168
|
+
['card', 'tile', 'panel', 'box', 'carte', 'vignette'],
|
|
169
|
+
['modal', 'dialog', 'popup', 'popin', 'overlay', 'drawer', 'sheet'],
|
|
170
|
+
['button', 'btn', 'bouton', 'cta'],
|
|
171
|
+
['input', 'field', 'champ', 'textfield'],
|
|
172
|
+
['select', 'dropdown', 'combobox', 'picker'],
|
|
173
|
+
['list', 'liste', 'table', 'grid', 'datagrid', 'tableau'],
|
|
174
|
+
['toast', 'snackbar', 'notification', 'alert', 'banner', 'message'],
|
|
175
|
+
['spinner', 'loader', 'loading', 'skeleton', 'progress'],
|
|
176
|
+
['badge', 'chip', 'tag', 'pill', 'label'],
|
|
177
|
+
['tabs', 'tab', 'onglet', 'stepper', 'wizard'],
|
|
178
|
+
['nav', 'navbar', 'menu', 'sidebar', 'header', 'toolbar'],
|
|
179
|
+
['avatar', 'icon', 'icone', 'logo', 'image'],
|
|
180
|
+
['form', 'formulaire'],
|
|
181
|
+
['tooltip', 'popover', 'hint'],
|
|
182
|
+
['page', 'view', 'screen', 'container', 'layout'],
|
|
183
|
+
];
|
|
184
|
+
const STOPWORDS = new Set(['component', 'app', 'ui', 'lib', 'shared', 'common', 'base', 'item', 'wrapper', 'element']);
|
|
185
|
+
function conceptOf(token) {
|
|
186
|
+
const t = token.toLowerCase().replace(/s$/, '');
|
|
187
|
+
for (const group of SYNONYMS)
|
|
188
|
+
if (group.includes(t))
|
|
189
|
+
return group[0];
|
|
190
|
+
return t;
|
|
191
|
+
}
|
|
192
|
+
function tokens(c) {
|
|
193
|
+
const src = [c.className, ...c.selectors].join(' ');
|
|
194
|
+
return src
|
|
195
|
+
.replace(/[\[\]]/g, '')
|
|
196
|
+
.replace(/([a-z])([A-Z])/g, '$1-$2')
|
|
197
|
+
.toLowerCase()
|
|
198
|
+
.split(/[-_\s]+/)
|
|
199
|
+
.filter((t) => t && !STOPWORDS.has(t));
|
|
200
|
+
}
|
|
201
|
+
function clusterComponents(components) {
|
|
202
|
+
const byConcept = new Map();
|
|
203
|
+
for (const c of components) {
|
|
204
|
+
const toks = tokens(c);
|
|
205
|
+
// Drop the most common prefix token (first token of every selector, e.g. "app")
|
|
206
|
+
for (const t of toks) {
|
|
207
|
+
const concept = conceptOf(t);
|
|
208
|
+
if (concept.length < 3)
|
|
209
|
+
continue;
|
|
210
|
+
if (!byConcept.has(concept))
|
|
211
|
+
byConcept.set(concept, new Set());
|
|
212
|
+
byConcept.get(concept).add(c.className);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return [...byConcept.entries()]
|
|
216
|
+
.filter(([, set]) => set.size >= 2)
|
|
217
|
+
.map(([concept, set]) => ({ concept, components: [...set].sort() }))
|
|
218
|
+
.sort((a, b) => b.components.length - a.components.length);
|
|
219
|
+
}
|
|
220
|
+
// ---------- Quasi-components (repeated class signatures) ----------
|
|
221
|
+
function classSignatures(template) {
|
|
222
|
+
const out = [];
|
|
223
|
+
const re = /\bclass="([^"]+)"/g;
|
|
224
|
+
let m;
|
|
225
|
+
while ((m = re.exec(template))) {
|
|
226
|
+
const classes = m[1].split(/\s+/).filter(Boolean);
|
|
227
|
+
if (classes.length >= 4)
|
|
228
|
+
out.push([...new Set(classes)].sort().join(' '));
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
// ---------- Main ----------
|
|
233
|
+
export function scan(rootInput) {
|
|
234
|
+
const root = path.resolve(rootInput);
|
|
235
|
+
const files = walk(root);
|
|
236
|
+
const tsFiles = files.filter((f) => f.endsWith('.ts') && !f.endsWith('.spec.ts') && !f.endsWith('.d.ts') && !f.endsWith('.stories.ts'));
|
|
237
|
+
const htmlFiles = files.filter((f) => f.endsWith('.html'));
|
|
238
|
+
const components = [];
|
|
239
|
+
// template text by owning file (relative path) — html files + inline templates
|
|
240
|
+
const templates = new Map();
|
|
241
|
+
for (const f of htmlFiles)
|
|
242
|
+
templates.set(path.relative(root, f), fs.readFileSync(f, 'utf8'));
|
|
243
|
+
for (const f of tsFiles) {
|
|
244
|
+
const text = fs.readFileSync(f, 'utf8');
|
|
245
|
+
if (!text.includes('@Component'))
|
|
246
|
+
continue;
|
|
247
|
+
const sf = ts.createSourceFile(f, text, ts.ScriptTarget.Latest, true);
|
|
248
|
+
const found = extractComponents(sf, root);
|
|
249
|
+
const inline = inlineTemplates(sf);
|
|
250
|
+
for (const c of found) {
|
|
251
|
+
components.push({ ...c, public: false, usages: 0, usedIn: [] });
|
|
252
|
+
const tpl = inline.get(c.className);
|
|
253
|
+
if (tpl)
|
|
254
|
+
templates.set(c.file + '#' + c.className, tpl);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// Public API
|
|
258
|
+
const entries = tsFiles.filter((f) => ENTRY_FILES.has(path.basename(f)));
|
|
259
|
+
const pub = publicFiles(entries);
|
|
260
|
+
for (const c of components)
|
|
261
|
+
c.public = pub.has(path.join(root, c.file));
|
|
262
|
+
// Usages
|
|
263
|
+
for (const c of components) {
|
|
264
|
+
const own = new Set([c.templateFile, c.file + '#' + c.className].filter(Boolean));
|
|
265
|
+
for (const sel of c.selectors) {
|
|
266
|
+
const re = selectorRegex(sel);
|
|
267
|
+
if (!re)
|
|
268
|
+
continue;
|
|
269
|
+
for (const [tplFile, tpl] of templates) {
|
|
270
|
+
if (own.has(tplFile))
|
|
271
|
+
continue;
|
|
272
|
+
const n = tpl.match(re)?.length ?? 0;
|
|
273
|
+
if (n > 0) {
|
|
274
|
+
c.usages += n;
|
|
275
|
+
const fileOnly = tplFile.split('#')[0];
|
|
276
|
+
if (!c.usedIn.includes(fileOnly))
|
|
277
|
+
c.usedIn.push(fileOnly);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// Quasi-components
|
|
283
|
+
const sigs = new Map();
|
|
284
|
+
for (const [tplFile, tpl] of templates) {
|
|
285
|
+
for (const s of classSignatures(tpl)) {
|
|
286
|
+
if (!sigs.has(s))
|
|
287
|
+
sigs.set(s, new Set());
|
|
288
|
+
sigs.get(s).add(tplFile.split('#')[0]);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const sigCounts = new Map();
|
|
292
|
+
for (const [, tpl] of templates)
|
|
293
|
+
for (const s of classSignatures(tpl))
|
|
294
|
+
sigCounts.set(s, (sigCounts.get(s) ?? 0) + 1);
|
|
295
|
+
const quasiComponents = [...sigs.entries()]
|
|
296
|
+
.map(([classes, fileSet]) => ({ classes, count: sigCounts.get(classes) ?? 0, files: [...fileSet].sort() }))
|
|
297
|
+
.filter((p) => p.count >= 3 && p.files.length >= 2)
|
|
298
|
+
.sort((a, b) => b.count - a.count);
|
|
299
|
+
components.sort((a, b) => a.file.localeCompare(b.file));
|
|
300
|
+
const clusters = clusterComponents(components);
|
|
301
|
+
return {
|
|
302
|
+
root,
|
|
303
|
+
scannedAt: new Date().toISOString(),
|
|
304
|
+
components,
|
|
305
|
+
clusters,
|
|
306
|
+
quasiComponents,
|
|
307
|
+
stats: {
|
|
308
|
+
total: components.length,
|
|
309
|
+
public: components.filter((c) => c.public).length,
|
|
310
|
+
private: components.filter((c) => !c.public).length,
|
|
311
|
+
unused: components.filter((c) => c.usages === 0).length,
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
// ---------- Markdown report ----------
|
|
316
|
+
export function toMarkdown(inv) {
|
|
317
|
+
const L = [];
|
|
318
|
+
const { stats } = inv;
|
|
319
|
+
L.push(`# Inventaire des composants Angular`, '');
|
|
320
|
+
L.push(`Racine : \`${inv.root}\` `);
|
|
321
|
+
L.push(`**${stats.total} composants** — ${stats.public} exportés (API publique), ${stats.private} privés, ${stats.unused} jamais référencés dans un template.`, '');
|
|
322
|
+
if (inv.clusters.length) {
|
|
323
|
+
L.push(`## Concepts en doublon potentiel`, '');
|
|
324
|
+
L.push(`| Concept | Nb | Composants |`, `|---|---|---|`);
|
|
325
|
+
for (const c of inv.clusters)
|
|
326
|
+
L.push(`| ${c.concept} | ${c.components.length} | ${c.components.join(', ')} |`);
|
|
327
|
+
L.push('');
|
|
328
|
+
}
|
|
329
|
+
if (inv.quasiComponents.length) {
|
|
330
|
+
L.push(`## Quasi-composants (mêmes classes CSS répétées)`, '');
|
|
331
|
+
L.push(`Signatures de classes (≥ 4 classes) répétées ≥ 3 fois dans ≥ 2 fichiers : un composant qui n'a jamais été extrait.`, '');
|
|
332
|
+
for (const q of inv.quasiComponents.slice(0, 15)) {
|
|
333
|
+
L.push(`- **×${q.count}** dans ${q.files.length} fichiers — \`${q.classes}\``);
|
|
334
|
+
}
|
|
335
|
+
L.push('');
|
|
336
|
+
}
|
|
337
|
+
L.push(`## Catalogue`, '');
|
|
338
|
+
L.push(`| Composant | Sélecteur | Public | Usages | Inputs | Outputs | Fichier |`, `|---|---|---|---|---|---|---|`);
|
|
339
|
+
for (const c of inv.components) {
|
|
340
|
+
L.push(`| ${c.className} | \`${c.selectors.join(', ') || '—'}\` | ${c.public ? '✅' : '—'} | ${c.usages} | ${c.inputs.join(', ') || '—'} | ${c.outputs.join(', ') || '—'} | ${c.file} |`);
|
|
341
|
+
}
|
|
342
|
+
L.push('');
|
|
343
|
+
const unused = inv.components.filter((c) => c.usages === 0 && c.selectors.length);
|
|
344
|
+
if (unused.length) {
|
|
345
|
+
L.push(`## Jamais utilisés dans un template`, '');
|
|
346
|
+
L.push(`Peuvent être des pages routées (normal) ou du code mort.`, '');
|
|
347
|
+
for (const c of unused)
|
|
348
|
+
L.push(`- ${c.className} (\`${c.selectors[0]}\`) — ${c.file}`);
|
|
349
|
+
L.push('');
|
|
350
|
+
}
|
|
351
|
+
return L.join('\n');
|
|
352
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@angularkit/inventory",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Inventaire des composants d'un projet Angular : sélecteurs, inputs/outputs, API publique, usages, doublons de concept et quasi-composants.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Gaetan Redin (ReCowd)",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"packageManager": "npm@10.9.7",
|
|
9
|
+
"bin": {
|
|
10
|
+
"angular-inventory": "./dist/cli.js"
|
|
11
|
+
},
|
|
12
|
+
"main": "./dist/inventory.js",
|
|
13
|
+
"types": "./dist/inventory.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/inventory.d.ts",
|
|
17
|
+
"default": "./dist/inventory.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"README.md"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc -p tsconfig.json",
|
|
26
|
+
"clean": "rm -rf dist",
|
|
27
|
+
"dev": "tsx src/cli.ts",
|
|
28
|
+
"test": "tsx --test test/*.test.ts",
|
|
29
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
30
|
+
"prepack": "npm run clean && npm run build"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"typescript": "^5.4.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^22.0.0",
|
|
37
|
+
"tsx": "^4.0.0"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=18"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public",
|
|
44
|
+
"provenance": true
|
|
45
|
+
},
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "git+https://github.com/AngularKit/inventory.git"
|
|
49
|
+
},
|
|
50
|
+
"homepage": "https://github.com/AngularKit/inventory#readme",
|
|
51
|
+
"bugs": {
|
|
52
|
+
"url": "https://github.com/AngularKit/inventory/issues"
|
|
53
|
+
},
|
|
54
|
+
"keywords": [
|
|
55
|
+
"angular",
|
|
56
|
+
"components",
|
|
57
|
+
"inventory",
|
|
58
|
+
"audit",
|
|
59
|
+
"design-system",
|
|
60
|
+
"nx",
|
|
61
|
+
"ai-agents"
|
|
62
|
+
]
|
|
63
|
+
}
|