archlens 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.
@@ -0,0 +1,13 @@
1
+ import fg from "fast-glob";
2
+ export async function collectFiles(params) {
3
+ const { cwd, entryGlobs, excludeGlobs } = params;
4
+ const files = await fg(entryGlobs, {
5
+ cwd,
6
+ ignore: excludeGlobs,
7
+ onlyFiles: true,
8
+ dot: false,
9
+ unique: true,
10
+ absolute: false
11
+ });
12
+ return files.map((p) => p.replaceAll("\\", "/")).sort();
13
+ }
@@ -0,0 +1,54 @@
1
+ import { candidatePaths, isRelative } from "./resolve.js";
2
+ import { loadAliasRules, resolveAliasImport } from "./tsconfigAliases.js";
3
+ import { parseImportsJS, parseImportsTS } from "./importParsers.js";
4
+ import fs from "node:fs/promises";
5
+ import path from "node:path";
6
+ export async function buildGraph(params) {
7
+ const { cwd, files } = params;
8
+ const fileSet = new Set(files);
9
+ const edges = [];
10
+ const aliasRules = await loadAliasRules(cwd);
11
+ for (const file of files) {
12
+ const abs = path.join(cwd, file);
13
+ let text = "";
14
+ try {
15
+ text = await fs.readFile(abs, "utf8");
16
+ }
17
+ catch {
18
+ continue;
19
+ }
20
+ const isTS = file.endsWith(".ts") || file.endsWith(".tsx");
21
+ const specs = isTS ? parseImportsTS(text, file) : parseImportsJS(text);
22
+ for (const spec of specs) {
23
+ // 1) relativo: ./ ../
24
+ if (isRelative(spec)) {
25
+ const candidates = candidatePaths(file, spec);
26
+ const target = candidates.find((c) => fileSet.has(c));
27
+ if (target)
28
+ edges.push({ from: file, to: target });
29
+ continue;
30
+ }
31
+ // 2) alias: @/...
32
+ const aliased = resolveAliasImport(spec, aliasRules);
33
+ if (aliased) {
34
+ // aqui "aliased" vira algo como "src/app/types/product"
35
+ const candidates = [
36
+ aliased,
37
+ `${aliased}.ts`,
38
+ `${aliased}.tsx`,
39
+ `${aliased}.js`,
40
+ `${aliased}.jsx`,
41
+ `${aliased}/index.ts`,
42
+ `${aliased}/index.tsx`,
43
+ `${aliased}/index.js`,
44
+ `${aliased}/index.jsx`
45
+ ].map((p) => p.replaceAll("\\", "/"));
46
+ const target = candidates.find((c) => fileSet.has(c));
47
+ if (target)
48
+ edges.push({ from: file, to: target });
49
+ }
50
+ }
51
+ }
52
+ const nodes = Array.from(new Set([...files, ...edges.flatMap((e) => [e.from, e.to])])).sort();
53
+ return { nodes, edges };
54
+ }
@@ -0,0 +1,62 @@
1
+ import { parse as babelParse } from "@babel/parser";
2
+ import ts from "typescript";
3
+ export function parseImportsTS(sourceText, filePath) {
4
+ const sf = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true);
5
+ const imports = [];
6
+ function visit(node) {
7
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
8
+ imports.push(node.moduleSpecifier.text);
9
+ }
10
+ if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
11
+ imports.push(node.moduleSpecifier.text);
12
+ }
13
+ if (ts.isCallExpression(node) &&
14
+ ts.isIdentifier(node.expression) &&
15
+ node.expression.text === "require" &&
16
+ node.arguments.length === 1 &&
17
+ ts.isStringLiteral(node.arguments[0])) {
18
+ imports.push(node.arguments[0].text);
19
+ }
20
+ ts.forEachChild(node, visit);
21
+ }
22
+ visit(sf);
23
+ return imports;
24
+ }
25
+ export function parseImportsJS(sourceText) {
26
+ const ast = babelParse(sourceText, {
27
+ sourceType: "unambiguous",
28
+ plugins: ["jsx", "typescript", "decorators-legacy"]
29
+ });
30
+ const imports = [];
31
+ const stack = [ast];
32
+ while (stack.length) {
33
+ const node = stack.pop();
34
+ if (!node || typeof node !== "object")
35
+ continue;
36
+ if (node.type === "ImportDeclaration" && node.source?.value) {
37
+ imports.push(String(node.source.value));
38
+ }
39
+ if (node.type === "ExportNamedDeclaration" && node.source?.value) {
40
+ imports.push(String(node.source.value));
41
+ }
42
+ if (node.type === "ExportAllDeclaration" && node.source?.value) {
43
+ imports.push(String(node.source.value));
44
+ }
45
+ if (node.type === "CallExpression" &&
46
+ node.callee?.type === "Identifier" &&
47
+ node.callee.name === "require" &&
48
+ node.arguments?.length === 1 &&
49
+ node.arguments[0]?.type === "StringLiteral") {
50
+ imports.push(String(node.arguments[0].value));
51
+ }
52
+ for (const k of Object.keys(node)) {
53
+ const v = node[k];
54
+ if (Array.isArray(v))
55
+ for (const child of v)
56
+ stack.push(child);
57
+ else if (v && typeof v === "object")
58
+ stack.push(v);
59
+ }
60
+ }
61
+ return imports;
62
+ }
@@ -0,0 +1,30 @@
1
+ import { buildGraph } from "./graph.js";
2
+ import { collectFiles } from "./fileCollector.js";
3
+ import { computeNodeMetrics } from "./metrics.js";
4
+ import { computeScore } from "./score.js";
5
+ import { stronglyConnectedComponents } from "./tarjan.js";
6
+ export async function analyzeProject(params) {
7
+ const { cwd, projectName, entryGlobs, excludeGlobs } = params;
8
+ const files = await collectFiles({ cwd, entryGlobs, excludeGlobs });
9
+ const graph = await buildGraph({ cwd, files });
10
+ const sccs = stronglyConnectedComponents(graph.nodes, graph.edges);
11
+ const cycles = sccs
12
+ .filter(c => c.length > 1)
13
+ .map((nodes, i) => ({
14
+ id: `cycle-${i + 1}`,
15
+ nodes: nodes.sort()
16
+ }));
17
+ const metrics = computeNodeMetrics(graph.nodes, graph.edges);
18
+ const score = computeScore({ cycles, metrics });
19
+ return {
20
+ meta: {
21
+ projectName,
22
+ analyzedAt: new Date().toISOString(),
23
+ fileCount: graph.nodes.length
24
+ },
25
+ graph,
26
+ cycles,
27
+ metrics,
28
+ score
29
+ };
30
+ }
@@ -0,0 +1,28 @@
1
+ export function computeNodeMetrics(nodes, edges) {
2
+ const fanIn = new Map();
3
+ const fanOut = new Map();
4
+ for (const n of nodes) {
5
+ fanIn.set(n, 0);
6
+ fanOut.set(n, 0);
7
+ }
8
+ for (const e of edges) {
9
+ fanOut.set(e.from, (fanOut.get(e.from) ?? 0) + 1);
10
+ fanIn.set(e.to, (fanIn.get(e.to) ?? 0) + 1);
11
+ }
12
+ const perFile = nodes.map((file) => {
13
+ const fi = fanIn.get(file) ?? 0;
14
+ const fo = fanOut.get(file) ?? 0;
15
+ const denom = fi + fo;
16
+ const instability = denom === 0 ? null : Number((fo / denom).toFixed(3));
17
+ const dangerScore = fi * fo;
18
+ return { file, fanIn: fi, fanOut: fo, instability, dangerScore };
19
+ });
20
+ const topFanIn = [...perFile].sort((a, b) => b.fanIn - a.fanIn).slice(0, 10);
21
+ const topFanOut = [...perFile].sort((a, b) => b.fanOut - a.fanOut).slice(0, 10);
22
+ // “perigo” = central + acoplado: alto (fanIn+fanOut) e idealmente ambos > 0
23
+ const danger = [...perFile]
24
+ .filter((m) => m.fanIn > 0 && m.fanOut > 0)
25
+ .sort((a, b) => (b.dangerScore ?? 0) - (a.dangerScore ?? 0))
26
+ .slice(0, 10);
27
+ return { perFile, topFanIn, topFanOut, danger };
28
+ }
@@ -0,0 +1,23 @@
1
+ import path from "node:path";
2
+ export function isRelative(spec) {
3
+ return spec.startsWith(".") || spec.startsWith("/");
4
+ }
5
+ export function candidatePaths(fromFile, spec) {
6
+ const fromDir = path.posix.dirname(fromFile);
7
+ const base = spec.startsWith("/")
8
+ ? path.posix.normalize(spec.slice(1))
9
+ : path.posix.normalize(path.posix.join(fromDir, spec));
10
+ return [
11
+ base,
12
+ `${base}.ts`,
13
+ `${base}.tsx`,
14
+ `${base}.js`,
15
+ `${base}.jsx`,
16
+ `${base}.mjs`,
17
+ `${base}.cjs`,
18
+ `${base}/index.ts`,
19
+ `${base}/index.tsx`,
20
+ `${base}/index.js`,
21
+ `${base}/index.jsx`
22
+ ].map((p) => p.replaceAll("\\", "/"));
23
+ }
@@ -0,0 +1,123 @@
1
+ function clamp(n, min, max) {
2
+ return Math.max(min, Math.min(max, n));
3
+ }
4
+ function gradeFromScore(v) {
5
+ if (v >= 90)
6
+ return "A";
7
+ if (v >= 80)
8
+ return "B";
9
+ if (v >= 70)
10
+ return "C";
11
+ if (v >= 60)
12
+ return "D";
13
+ return "F";
14
+ }
15
+ function statusFromScore(v) {
16
+ if (v >= 80)
17
+ return "Healthy";
18
+ if (v >= 60)
19
+ return "Warning";
20
+ return "Critical";
21
+ }
22
+ export function computeScore(input) {
23
+ const breakdown = [];
24
+ let totalPenalty = 0;
25
+ // 1) Cycles
26
+ const cycleCount = input.cycles.length;
27
+ const cycleNodesTotal = input.cycles.reduce((acc, c) => acc + c.nodes.length, 0);
28
+ if (cycleCount > 0) {
29
+ const penalty = -(cycleCount * 15 + cycleNodesTotal * 2);
30
+ totalPenalty += penalty;
31
+ breakdown.push({
32
+ key: "cycles",
33
+ points: penalty,
34
+ details: `Detected ${cycleCount} cycle(s) involving ${cycleNodesTotal} file(s)`
35
+ });
36
+ }
37
+ else {
38
+ breakdown.push({
39
+ key: "cycles",
40
+ points: 0,
41
+ details: "No dependency cycles detected"
42
+ });
43
+ }
44
+ // 2) Danger modules (fanIn * fanOut)
45
+ const danger4 = input.metrics.perFile.filter((m) => (m.dangerScore ?? (m.fanIn * m.fanOut)) >= 4);
46
+ const danger9 = input.metrics.perFile.filter((m) => (m.dangerScore ?? (m.fanIn * m.fanOut)) >= 9);
47
+ // Evita penalizar duas vezes o mesmo arquivo no threshold menor
48
+ const danger4Only = danger4.filter((m) => (m.dangerScore ?? (m.fanIn * m.fanOut)) < 9);
49
+ if (danger4Only.length > 0) {
50
+ const penalty = -(danger4Only.length * 2);
51
+ totalPenalty += penalty;
52
+ breakdown.push({
53
+ key: "danger>=4",
54
+ points: penalty,
55
+ details: `${danger4Only.length} module(s) have elevated coupling (dangerScore ≥ 4)`
56
+ });
57
+ }
58
+ else {
59
+ breakdown.push({
60
+ key: "danger>=4",
61
+ points: 0,
62
+ details: "No elevated coupling hotspots (dangerScore ≥ 4)"
63
+ });
64
+ }
65
+ if (danger9.length > 0) {
66
+ const penalty = -(danger9.length * 4);
67
+ totalPenalty += penalty;
68
+ breakdown.push({
69
+ key: "danger>=9",
70
+ points: penalty,
71
+ details: `${danger9.length} module(s) are strong coupling hotspots (dangerScore ≥ 9)`
72
+ });
73
+ }
74
+ else {
75
+ breakdown.push({
76
+ key: "danger>=9",
77
+ points: 0,
78
+ details: "No strong coupling hotspots (dangerScore ≥ 9)"
79
+ });
80
+ }
81
+ // 3) High fan-out
82
+ const fanOut10 = input.metrics.perFile.filter((m) => m.fanOut >= 10 && m.fanOut < 20);
83
+ const fanOut20 = input.metrics.perFile.filter((m) => m.fanOut >= 20);
84
+ if (fanOut10.length > 0) {
85
+ const penalty = -(fanOut10.length * 2);
86
+ totalPenalty += penalty;
87
+ breakdown.push({
88
+ key: "fanOut>=10",
89
+ points: penalty,
90
+ details: `${fanOut10.length} module(s) depend on 10–19 internal modules (fanOut ≥ 10)`
91
+ });
92
+ }
93
+ else {
94
+ breakdown.push({
95
+ key: "fanOut>=10",
96
+ points: 0,
97
+ details: "No modules with very high fanOut (≥ 10)"
98
+ });
99
+ }
100
+ if (fanOut20.length > 0) {
101
+ const penalty = -(fanOut20.length * 4);
102
+ totalPenalty += penalty;
103
+ breakdown.push({
104
+ key: "fanOut>=20",
105
+ points: penalty,
106
+ details: `${fanOut20.length} module(s) depend on 20+ internal modules (fanOut ≥ 20)`
107
+ });
108
+ }
109
+ else {
110
+ breakdown.push({
111
+ key: "fanOut>=20",
112
+ points: 0,
113
+ details: "No modules with extreme fanOut (≥ 20)"
114
+ });
115
+ }
116
+ const value = clamp(100 + totalPenalty, 0, 100);
117
+ return {
118
+ value,
119
+ grade: gradeFromScore(value),
120
+ status: statusFromScore(value),
121
+ breakdown
122
+ };
123
+ }
@@ -0,0 +1,46 @@
1
+ export function stronglyConnectedComponents(nodes, edges) {
2
+ const adjacency = new Map();
3
+ for (const n of nodes)
4
+ adjacency.set(n, []);
5
+ for (const e of edges) {
6
+ adjacency.get(e.from)?.push(e.to);
7
+ }
8
+ let index = 0;
9
+ const stack = [];
10
+ const onStack = new Set();
11
+ const indices = new Map();
12
+ const lowlink = new Map();
13
+ const sccs = [];
14
+ function strongConnect(v) {
15
+ indices.set(v, index);
16
+ lowlink.set(v, index);
17
+ index++;
18
+ stack.push(v);
19
+ onStack.add(v);
20
+ for (const w of adjacency.get(v) ?? []) {
21
+ if (!indices.has(w)) {
22
+ strongConnect(w);
23
+ lowlink.set(v, Math.min(lowlink.get(v), lowlink.get(w)));
24
+ }
25
+ else if (onStack.has(w)) {
26
+ lowlink.set(v, Math.min(lowlink.get(v), indices.get(w)));
27
+ }
28
+ }
29
+ if (lowlink.get(v) === indices.get(v)) {
30
+ const component = [];
31
+ while (true) {
32
+ const w = stack.pop();
33
+ onStack.delete(w);
34
+ component.push(w);
35
+ if (w === v)
36
+ break;
37
+ }
38
+ sccs.push(component);
39
+ }
40
+ }
41
+ for (const v of nodes) {
42
+ if (!indices.has(v))
43
+ strongConnect(v);
44
+ }
45
+ return sccs;
46
+ }
@@ -0,0 +1,68 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ async function readJsonIfExists(filePath) {
4
+ try {
5
+ const raw = await fs.readFile(filePath, "utf8");
6
+ return JSON.parse(raw);
7
+ }
8
+ catch {
9
+ return null;
10
+ }
11
+ }
12
+ function normalizeStarPattern(p) {
13
+ // "@/*" => {prefix:"@/", hasStar:true}
14
+ // "src/*" => {targetPrefix:"src/", hasStar:true}
15
+ const hasStar = p.includes("*");
16
+ const prefix = p.replaceAll("*", "");
17
+ return { prefix, hasStar };
18
+ }
19
+ export async function loadAliasRules(cwd) {
20
+ const tsconfigPath = path.join(cwd, "tsconfig.json");
21
+ const jsconfigPath = path.join(cwd, "jsconfig.json");
22
+ const tsconfig = (await readJsonIfExists(tsconfigPath)) ?? (await readJsonIfExists(jsconfigPath));
23
+ if (!tsconfig)
24
+ return [];
25
+ const cfg = tsconfig;
26
+ const pathsMap = cfg.compilerOptions?.paths ?? {};
27
+ // baseUrl pode existir (e você já adicionou), mas pro nosso caso de @/* não é obrigatório.
28
+ // Mesmo assim, mantemos para futuro.
29
+ const baseUrl = cfg.compilerOptions?.baseUrl ?? ".";
30
+ const rules = [];
31
+ for (const [fromPattern, toPatterns] of Object.entries(pathsMap)) {
32
+ if (!Array.isArray(toPatterns) || toPatterns.length === 0)
33
+ continue;
34
+ // Pegamos o primeiro target (MVP). Depois suportamos múltiplos.
35
+ const toPattern = toPatterns[0];
36
+ const from = normalizeStarPattern(fromPattern);
37
+ const to = normalizeStarPattern(toPattern);
38
+ // MVP: suportar só padrões com "*" do tipo "@/*": ["src/*"]
39
+ // Se não tiver *, ainda dá pra suportar como prefixo direto, mas vamos aceitar os dois.
40
+ const fromPrefix = from.prefix;
41
+ const targetPrefix = to.prefix;
42
+ // baseUrl: resolve "src/" relativo ao baseUrl (normalmente ".")
43
+ // Ex: baseUrl="." e targetPrefix="src/" => "src/"
44
+ // Ex: baseUrl="src" e targetPrefix="" => "src/"
45
+ const resolvedTargetPrefix = path.posix
46
+ .normalize(path.posix.join(baseUrl.replaceAll("\\", "/"), targetPrefix.replaceAll("\\", "/")))
47
+ .replaceAll("\\", "/");
48
+ rules.push({
49
+ prefix: fromPrefix,
50
+ targetPrefix: resolvedTargetPrefix.endsWith("/") ? resolvedTargetPrefix : `${resolvedTargetPrefix}/`
51
+ });
52
+ }
53
+ // Ordena por prefixo mais longo primeiro (evita conflito entre "@/" e "@shared/")
54
+ rules.sort((a, b) => b.prefix.length - a.prefix.length);
55
+ return rules;
56
+ }
57
+ export function resolveAliasImport(spec, rules) {
58
+ for (const r of rules) {
59
+ if (spec.startsWith(r.prefix)) {
60
+ const rest = spec.slice(r.prefix.length); // o que vem depois do prefixo
61
+ // Ex: "@/app/types/product" => "src/app/types/product"
62
+ const candidateBase = (r.targetPrefix + rest).replaceAll("\\", "/");
63
+ // removendo "//" acidentais
64
+ return candidateBase.replaceAll("//", "/");
65
+ }
66
+ }
67
+ return null;
68
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/main.js ADDED
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { analyzeProject } from "./engine/index.js";
4
+ import fs from "node:fs/promises";
5
+ import path from "node:path";
6
+ const program = new Command();
7
+ program
8
+ .name("archlens")
9
+ .description("Architecture health analyzer for JS/TS projects")
10
+ .version("0.1.0");
11
+ program
12
+ .command("analyze")
13
+ .argument("<targetPath>", "Path do projeto (ex: .)")
14
+ .option("--config <file>", "Arquivo de config", "archlens.config.json")
15
+ .option("--out <dir>", "Pasta de saída", "./archlens-report")
16
+ .action(async (targetPath, opts) => {
17
+ const cwd = path.resolve(process.cwd(), targetPath);
18
+ const outDir = path.resolve(process.cwd(), opts.out);
19
+ const configPath = path.resolve(cwd, opts.config);
20
+ const defaults = {
21
+ entryGlobs: ["src/**/*.{ts,tsx,js,jsx}"],
22
+ excludeGlobs: [
23
+ "**/node_modules/**",
24
+ "**/dist/**",
25
+ "**/build/**",
26
+ "**/coverage/**",
27
+ ],
28
+ };
29
+ let cfg = {};
30
+ try {
31
+ // config é opcional: se não existir, usa defaults
32
+ cfg = JSON.parse(await fs.readFile(configPath, "utf8"));
33
+ }
34
+ catch {
35
+ cfg = {};
36
+ }
37
+ const projectName = cfg.projectName ?? path.basename(cwd);
38
+ const entryGlobs = cfg.entryGlobs ?? defaults.entryGlobs;
39
+ const excludeGlobs = cfg.excludeGlobs ?? defaults.excludeGlobs;
40
+ const report = await analyzeProject({
41
+ cwd,
42
+ projectName,
43
+ entryGlobs,
44
+ excludeGlobs,
45
+ });
46
+ await fs.mkdir(outDir, { recursive: true });
47
+ const jsonPath = path.join(outDir, "report.json");
48
+ await fs.writeFile(jsonPath, JSON.stringify(report, null, 2), "utf8");
49
+ console.log(`\n✅ ArchLens analysis complete`);
50
+ console.log(`Project: ${report.meta.projectName}`);
51
+ console.log(`Files analyzed: ${report.meta.fileCount}`);
52
+ console.log(`Edges: ${report.graph.edges.length}`);
53
+ console.log(`Architecture Health Score: ${report.score.value}/100 (${report.score.grade})`);
54
+ console.log(`Status: ${report.score.status}`);
55
+ const penalties = report.score.breakdown.filter((b) => b.points < 0);
56
+ if (penalties.length) {
57
+ console.log("\nScore breakdown (penalties):");
58
+ for (const p of penalties) {
59
+ console.log(` ${p.points} ${p.details}`);
60
+ }
61
+ }
62
+ const topIn = report.metrics.topFanIn.slice(0, 3);
63
+ const topOut = report.metrics.topFanOut.slice(0, 3);
64
+ const danger = report.metrics.danger.slice(0, 3);
65
+ console.log("Top Fan-in (críticos):");
66
+ for (const m of topIn)
67
+ console.log(` - ${m.fanIn} in | ${m.fanOut} out | ${m.file}`);
68
+ console.log("Top Fan-out (instáveis):");
69
+ for (const m of topOut)
70
+ console.log(` - ${m.fanIn} in | ${m.fanOut} out | ${m.file}`);
71
+ console.log("Danger (acoplados):");
72
+ for (const m of danger)
73
+ console.log(` - ${m.fanIn} in | ${m.fanOut} out | ${m.file}`);
74
+ if (report.cycles.length) {
75
+ console.log(`Cycles detected: ${report.cycles.length}`);
76
+ console.log(` - ${report.cycles[0].id}: ${report.cycles[0].nodes.join(" -> ")}`);
77
+ }
78
+ console.log(`Report: ${jsonPath}\n`);
79
+ });
80
+ program.parse(process.argv);
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "archlens",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "archlens": "dist/main.js"
7
+ },
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "scripts": {
12
+ "build": "tsc -p tsconfig.json"
13
+ },
14
+ "dependencies": {
15
+ "@babel/parser": "^7.26.0",
16
+ "fast-glob": "^3.3.3",
17
+ "commander": "^..."
18
+ },
19
+ "devDependencies": {
20
+ "typescript": "^5.6.3"
21
+ }
22
+ }