@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 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
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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
+ }