archlens 0.0.2

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,282 @@
1
+ {
2
+ "meta": {
3
+ "projectName": "cli",
4
+ "analyzedAt": "2026-02-22T18:16:32.518Z",
5
+ "fileCount": 11
6
+ },
7
+ "graph": {
8
+ "nodes": [
9
+ "src/engine/fileCollector.ts",
10
+ "src/engine/graph.ts",
11
+ "src/engine/importParsers.ts",
12
+ "src/engine/index.ts",
13
+ "src/engine/metrics.ts",
14
+ "src/engine/resolve.ts",
15
+ "src/engine/score.ts",
16
+ "src/engine/tarjan.ts",
17
+ "src/engine/tsconfigAliases.ts",
18
+ "src/engine/types.ts",
19
+ "src/main.ts"
20
+ ],
21
+ "edges": []
22
+ },
23
+ "cycles": [],
24
+ "metrics": {
25
+ "perFile": [
26
+ {
27
+ "file": "src/engine/fileCollector.ts",
28
+ "fanIn": 0,
29
+ "fanOut": 0,
30
+ "instability": null,
31
+ "dangerScore": 0
32
+ },
33
+ {
34
+ "file": "src/engine/graph.ts",
35
+ "fanIn": 0,
36
+ "fanOut": 0,
37
+ "instability": null,
38
+ "dangerScore": 0
39
+ },
40
+ {
41
+ "file": "src/engine/importParsers.ts",
42
+ "fanIn": 0,
43
+ "fanOut": 0,
44
+ "instability": null,
45
+ "dangerScore": 0
46
+ },
47
+ {
48
+ "file": "src/engine/index.ts",
49
+ "fanIn": 0,
50
+ "fanOut": 0,
51
+ "instability": null,
52
+ "dangerScore": 0
53
+ },
54
+ {
55
+ "file": "src/engine/metrics.ts",
56
+ "fanIn": 0,
57
+ "fanOut": 0,
58
+ "instability": null,
59
+ "dangerScore": 0
60
+ },
61
+ {
62
+ "file": "src/engine/resolve.ts",
63
+ "fanIn": 0,
64
+ "fanOut": 0,
65
+ "instability": null,
66
+ "dangerScore": 0
67
+ },
68
+ {
69
+ "file": "src/engine/score.ts",
70
+ "fanIn": 0,
71
+ "fanOut": 0,
72
+ "instability": null,
73
+ "dangerScore": 0
74
+ },
75
+ {
76
+ "file": "src/engine/tarjan.ts",
77
+ "fanIn": 0,
78
+ "fanOut": 0,
79
+ "instability": null,
80
+ "dangerScore": 0
81
+ },
82
+ {
83
+ "file": "src/engine/tsconfigAliases.ts",
84
+ "fanIn": 0,
85
+ "fanOut": 0,
86
+ "instability": null,
87
+ "dangerScore": 0
88
+ },
89
+ {
90
+ "file": "src/engine/types.ts",
91
+ "fanIn": 0,
92
+ "fanOut": 0,
93
+ "instability": null,
94
+ "dangerScore": 0
95
+ },
96
+ {
97
+ "file": "src/main.ts",
98
+ "fanIn": 0,
99
+ "fanOut": 0,
100
+ "instability": null,
101
+ "dangerScore": 0
102
+ }
103
+ ],
104
+ "topFanIn": [
105
+ {
106
+ "file": "src/engine/fileCollector.ts",
107
+ "fanIn": 0,
108
+ "fanOut": 0,
109
+ "instability": null,
110
+ "dangerScore": 0
111
+ },
112
+ {
113
+ "file": "src/engine/graph.ts",
114
+ "fanIn": 0,
115
+ "fanOut": 0,
116
+ "instability": null,
117
+ "dangerScore": 0
118
+ },
119
+ {
120
+ "file": "src/engine/importParsers.ts",
121
+ "fanIn": 0,
122
+ "fanOut": 0,
123
+ "instability": null,
124
+ "dangerScore": 0
125
+ },
126
+ {
127
+ "file": "src/engine/index.ts",
128
+ "fanIn": 0,
129
+ "fanOut": 0,
130
+ "instability": null,
131
+ "dangerScore": 0
132
+ },
133
+ {
134
+ "file": "src/engine/metrics.ts",
135
+ "fanIn": 0,
136
+ "fanOut": 0,
137
+ "instability": null,
138
+ "dangerScore": 0
139
+ },
140
+ {
141
+ "file": "src/engine/resolve.ts",
142
+ "fanIn": 0,
143
+ "fanOut": 0,
144
+ "instability": null,
145
+ "dangerScore": 0
146
+ },
147
+ {
148
+ "file": "src/engine/score.ts",
149
+ "fanIn": 0,
150
+ "fanOut": 0,
151
+ "instability": null,
152
+ "dangerScore": 0
153
+ },
154
+ {
155
+ "file": "src/engine/tarjan.ts",
156
+ "fanIn": 0,
157
+ "fanOut": 0,
158
+ "instability": null,
159
+ "dangerScore": 0
160
+ },
161
+ {
162
+ "file": "src/engine/tsconfigAliases.ts",
163
+ "fanIn": 0,
164
+ "fanOut": 0,
165
+ "instability": null,
166
+ "dangerScore": 0
167
+ },
168
+ {
169
+ "file": "src/engine/types.ts",
170
+ "fanIn": 0,
171
+ "fanOut": 0,
172
+ "instability": null,
173
+ "dangerScore": 0
174
+ }
175
+ ],
176
+ "topFanOut": [
177
+ {
178
+ "file": "src/engine/fileCollector.ts",
179
+ "fanIn": 0,
180
+ "fanOut": 0,
181
+ "instability": null,
182
+ "dangerScore": 0
183
+ },
184
+ {
185
+ "file": "src/engine/graph.ts",
186
+ "fanIn": 0,
187
+ "fanOut": 0,
188
+ "instability": null,
189
+ "dangerScore": 0
190
+ },
191
+ {
192
+ "file": "src/engine/importParsers.ts",
193
+ "fanIn": 0,
194
+ "fanOut": 0,
195
+ "instability": null,
196
+ "dangerScore": 0
197
+ },
198
+ {
199
+ "file": "src/engine/index.ts",
200
+ "fanIn": 0,
201
+ "fanOut": 0,
202
+ "instability": null,
203
+ "dangerScore": 0
204
+ },
205
+ {
206
+ "file": "src/engine/metrics.ts",
207
+ "fanIn": 0,
208
+ "fanOut": 0,
209
+ "instability": null,
210
+ "dangerScore": 0
211
+ },
212
+ {
213
+ "file": "src/engine/resolve.ts",
214
+ "fanIn": 0,
215
+ "fanOut": 0,
216
+ "instability": null,
217
+ "dangerScore": 0
218
+ },
219
+ {
220
+ "file": "src/engine/score.ts",
221
+ "fanIn": 0,
222
+ "fanOut": 0,
223
+ "instability": null,
224
+ "dangerScore": 0
225
+ },
226
+ {
227
+ "file": "src/engine/tarjan.ts",
228
+ "fanIn": 0,
229
+ "fanOut": 0,
230
+ "instability": null,
231
+ "dangerScore": 0
232
+ },
233
+ {
234
+ "file": "src/engine/tsconfigAliases.ts",
235
+ "fanIn": 0,
236
+ "fanOut": 0,
237
+ "instability": null,
238
+ "dangerScore": 0
239
+ },
240
+ {
241
+ "file": "src/engine/types.ts",
242
+ "fanIn": 0,
243
+ "fanOut": 0,
244
+ "instability": null,
245
+ "dangerScore": 0
246
+ }
247
+ ],
248
+ "danger": []
249
+ },
250
+ "score": {
251
+ "value": 100,
252
+ "grade": "A",
253
+ "status": "Healthy",
254
+ "breakdown": [
255
+ {
256
+ "key": "cycles",
257
+ "points": 0,
258
+ "details": "No dependency cycles detected"
259
+ },
260
+ {
261
+ "key": "danger>=4",
262
+ "points": 0,
263
+ "details": "No elevated coupling hotspots (dangerScore ≥ 4)"
264
+ },
265
+ {
266
+ "key": "danger>=9",
267
+ "points": 0,
268
+ "details": "No strong coupling hotspots (dangerScore ≥ 9)"
269
+ },
270
+ {
271
+ "key": "fanOut>=10",
272
+ "points": 0,
273
+ "details": "No modules with very high fanOut (≥ 10)"
274
+ },
275
+ {
276
+ "key": "fanOut>=20",
277
+ "points": 0,
278
+ "details": "No modules with extreme fanOut (≥ 20)"
279
+ }
280
+ ]
281
+ }
282
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "archlens",
3
+ "version": "0.1.2",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "bin": {
7
+ "archlens": "dist/main.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsc -p tsconfig.json"
14
+ },
15
+ "dependencies": {
16
+ "@babel/parser": "^7.26.0",
17
+ "fast-glob": "^3.3.3",
18
+ "commander": "^..."
19
+ },
20
+ "devDependencies": {
21
+ "typescript": "^5.6.3"
22
+ }
23
+ }
@@ -0,0 +1,20 @@
1
+ import fg from "fast-glob";
2
+
3
+ export async function collectFiles(params: {
4
+ cwd: string;
5
+ entryGlobs: string[];
6
+ excludeGlobs: string[];
7
+ }): Promise<string[]> {
8
+ const { cwd, entryGlobs, excludeGlobs } = params;
9
+
10
+ const files = await fg(entryGlobs, {
11
+ cwd,
12
+ ignore: excludeGlobs,
13
+ onlyFiles: true,
14
+ dot: false,
15
+ unique: true,
16
+ absolute: false
17
+ });
18
+
19
+ return files.map((p) => p.replaceAll("\\", "/")).sort();
20
+ }
@@ -0,0 +1,66 @@
1
+ import { candidatePaths, isRelative } from "./resolve.js";
2
+ import { loadAliasRules, resolveAliasImport } from "./tsconfigAliases.js";
3
+ import { parseImportsJS, parseImportsTS } from "./importParsers.js";
4
+
5
+ import type { Edge } from "./types.js";
6
+ import fs from "node:fs/promises";
7
+ import path from "node:path";
8
+
9
+ export async function buildGraph(params: {
10
+ cwd: string;
11
+ files: string[];
12
+ }): Promise<{ nodes: string[]; edges: Edge[] }> {
13
+ const { cwd, files } = params;
14
+
15
+ const fileSet = new Set(files);
16
+ const edges: Edge[] = [];
17
+
18
+ const aliasRules = await loadAliasRules(cwd);
19
+
20
+ for (const file of files) {
21
+ const abs = path.join(cwd, file);
22
+
23
+ let text = "";
24
+ try {
25
+ text = await fs.readFile(abs, "utf8");
26
+ } catch {
27
+ continue;
28
+ }
29
+
30
+ const isTS = file.endsWith(".ts") || file.endsWith(".tsx");
31
+ const specs = isTS ? parseImportsTS(text, file) : parseImportsJS(text);
32
+
33
+ for (const spec of specs) {
34
+ // 1) relativo: ./ ../
35
+ if (isRelative(spec)) {
36
+ const candidates = candidatePaths(file, spec);
37
+ const target = candidates.find((c) => fileSet.has(c));
38
+ if (target) edges.push({ from: file, to: target });
39
+ continue;
40
+ }
41
+
42
+ // 2) alias: @/...
43
+ const aliased = resolveAliasImport(spec, aliasRules);
44
+ if (aliased) {
45
+ // aqui "aliased" vira algo como "src/app/types/product"
46
+ const candidates = [
47
+ aliased,
48
+ `${aliased}.ts`,
49
+ `${aliased}.tsx`,
50
+ `${aliased}.js`,
51
+ `${aliased}.jsx`,
52
+ `${aliased}/index.ts`,
53
+ `${aliased}/index.tsx`,
54
+ `${aliased}/index.js`,
55
+ `${aliased}/index.jsx`
56
+ ].map((p) => p.replaceAll("\\", "/"));
57
+
58
+ const target = candidates.find((c) => fileSet.has(c));
59
+ if (target) edges.push({ from: file, to: target });
60
+ }
61
+ }
62
+ }
63
+
64
+ const nodes = Array.from(new Set([...files, ...edges.flatMap((e) => [e.from, e.to])])).sort();
65
+ return { nodes, edges };
66
+ }
@@ -0,0 +1,72 @@
1
+ import { parse as babelParse } from "@babel/parser";
2
+ import ts from "typescript";
3
+
4
+ export function parseImportsTS(sourceText: string, filePath: string): string[] {
5
+ const sf = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true);
6
+ const imports: string[] = [];
7
+
8
+ function visit(node: ts.Node) {
9
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
10
+ imports.push(node.moduleSpecifier.text);
11
+ }
12
+ if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) {
13
+ imports.push(node.moduleSpecifier.text);
14
+ }
15
+ if (
16
+ ts.isCallExpression(node) &&
17
+ ts.isIdentifier(node.expression) &&
18
+ node.expression.text === "require" &&
19
+ node.arguments.length === 1 &&
20
+ ts.isStringLiteral(node.arguments[0])
21
+ ) {
22
+ imports.push(node.arguments[0].text);
23
+ }
24
+
25
+ ts.forEachChild(node, visit);
26
+ }
27
+
28
+ visit(sf);
29
+ return imports;
30
+ }
31
+
32
+ export function parseImportsJS(sourceText: string): string[] {
33
+ const ast = babelParse(sourceText, {
34
+ sourceType: "unambiguous",
35
+ plugins: ["jsx", "typescript", "decorators-legacy"]
36
+ });
37
+
38
+ const imports: string[] = [];
39
+ const stack: any[] = [ast];
40
+
41
+ while (stack.length) {
42
+ const node = stack.pop();
43
+ if (!node || typeof node !== "object") continue;
44
+
45
+ if (node.type === "ImportDeclaration" && node.source?.value) {
46
+ imports.push(String(node.source.value));
47
+ }
48
+ if (node.type === "ExportNamedDeclaration" && node.source?.value) {
49
+ imports.push(String(node.source.value));
50
+ }
51
+ if (node.type === "ExportAllDeclaration" && node.source?.value) {
52
+ imports.push(String(node.source.value));
53
+ }
54
+ if (
55
+ node.type === "CallExpression" &&
56
+ node.callee?.type === "Identifier" &&
57
+ node.callee.name === "require" &&
58
+ node.arguments?.length === 1 &&
59
+ node.arguments[0]?.type === "StringLiteral"
60
+ ) {
61
+ imports.push(String(node.arguments[0].value));
62
+ }
63
+
64
+ for (const k of Object.keys(node)) {
65
+ const v = (node as any)[k];
66
+ if (Array.isArray(v)) for (const child of v) stack.push(child);
67
+ else if (v && typeof v === "object") stack.push(v);
68
+ }
69
+ }
70
+
71
+ return imports;
72
+ }
@@ -0,0 +1,44 @@
1
+ import type { Report } from "./types.js";
2
+ import { buildGraph } from "./graph.js";
3
+ import { collectFiles } from "./fileCollector.js";
4
+ import { computeNodeMetrics } from "./metrics.js";
5
+ import { computeScore } from "./score.js";
6
+ import { stronglyConnectedComponents } from "./tarjan.js";
7
+
8
+ export async function analyzeProject(params: {
9
+ cwd: string;
10
+ projectName: string;
11
+ entryGlobs: string[];
12
+ excludeGlobs: string[];
13
+ }): Promise<Report> {
14
+
15
+ const { cwd, projectName, entryGlobs, excludeGlobs } = params;
16
+
17
+ const files = await collectFiles({ cwd, entryGlobs, excludeGlobs });
18
+ const graph = await buildGraph({ cwd, files });
19
+
20
+ const sccs = stronglyConnectedComponents(graph.nodes, graph.edges);
21
+
22
+ const cycles = sccs
23
+ .filter(c => c.length > 1)
24
+ .map((nodes, i) => ({
25
+ id: `cycle-${i + 1}`,
26
+ nodes: nodes.sort()
27
+ }));
28
+
29
+ const metrics = computeNodeMetrics(graph.nodes, graph.edges);
30
+
31
+ const score = computeScore({ cycles, metrics });
32
+
33
+ return {
34
+ meta: {
35
+ projectName,
36
+ analyzedAt: new Date().toISOString(),
37
+ fileCount: graph.nodes.length
38
+ },
39
+ graph,
40
+ cycles,
41
+ metrics,
42
+ score
43
+ };
44
+ }
@@ -0,0 +1,36 @@
1
+ import type { Edge, NodeMetrics } from "./types.js";
2
+
3
+ export function computeNodeMetrics(nodes: string[], edges: Edge[]) {
4
+ const fanIn = new Map<string, number>();
5
+ const fanOut = new Map<string, number>();
6
+
7
+ for (const n of nodes) {
8
+ fanIn.set(n, 0);
9
+ fanOut.set(n, 0);
10
+ }
11
+
12
+ for (const e of edges) {
13
+ fanOut.set(e.from, (fanOut.get(e.from) ?? 0) + 1);
14
+ fanIn.set(e.to, (fanIn.get(e.to) ?? 0) + 1);
15
+ }
16
+
17
+ const perFile: NodeMetrics[] = nodes.map((file) => {
18
+ const fi = fanIn.get(file) ?? 0;
19
+ const fo = fanOut.get(file) ?? 0;
20
+ const denom = fi + fo;
21
+ const instability = denom === 0 ? null : Number((fo / denom).toFixed(3));
22
+ const dangerScore = fi * fo;
23
+ return { file, fanIn: fi, fanOut: fo, instability, dangerScore };
24
+ });
25
+
26
+ const topFanIn = [...perFile].sort((a, b) => b.fanIn - a.fanIn).slice(0, 10);
27
+ const topFanOut = [...perFile].sort((a, b) => b.fanOut - a.fanOut).slice(0, 10);
28
+
29
+ // “perigo” = central + acoplado: alto (fanIn+fanOut) e idealmente ambos > 0
30
+ const danger = [...perFile]
31
+ .filter((m) => m.fanIn > 0 && m.fanOut > 0)
32
+ .sort((a, b) => (b.dangerScore ?? 0) - (a.dangerScore ?? 0))
33
+ .slice(0, 10);
34
+
35
+ return { perFile, topFanIn, topFanOut, danger };
36
+ }
@@ -0,0 +1,26 @@
1
+ import path from "node:path";
2
+
3
+ export function isRelative(spec: string): boolean {
4
+ return spec.startsWith(".") || spec.startsWith("/");
5
+ }
6
+
7
+ export function candidatePaths(fromFile: string, spec: string): string[] {
8
+ const fromDir = path.posix.dirname(fromFile);
9
+ const base = spec.startsWith("/")
10
+ ? path.posix.normalize(spec.slice(1))
11
+ : path.posix.normalize(path.posix.join(fromDir, spec));
12
+
13
+ return [
14
+ base,
15
+ `${base}.ts`,
16
+ `${base}.tsx`,
17
+ `${base}.js`,
18
+ `${base}.jsx`,
19
+ `${base}.mjs`,
20
+ `${base}.cjs`,
21
+ `${base}/index.ts`,
22
+ `${base}/index.tsx`,
23
+ `${base}/index.js`,
24
+ `${base}/index.jsx`
25
+ ].map((p) => p.replaceAll("\\", "/"));
26
+ }
@@ -0,0 +1,131 @@
1
+ import type { Report, Score, ScoreBreakdownItem } from "./types.js";
2
+
3
+ function clamp(n: number, min: number, max: number) {
4
+ return Math.max(min, Math.min(max, n));
5
+ }
6
+
7
+ function gradeFromScore(v: number): Score["grade"] {
8
+ if (v >= 90) return "A";
9
+ if (v >= 80) return "B";
10
+ if (v >= 70) return "C";
11
+ if (v >= 60) return "D";
12
+ return "F";
13
+ }
14
+
15
+ function statusFromScore(v: number): Score["status"] {
16
+ if (v >= 80) return "Healthy";
17
+ if (v >= 60) return "Warning";
18
+ return "Critical";
19
+ }
20
+
21
+ export function computeScore(input: {
22
+ cycles: { id: string; nodes: string[] }[];
23
+ metrics: Report["metrics"];
24
+ }): Score {
25
+ const breakdown: ScoreBreakdownItem[] = [];
26
+ let totalPenalty = 0;
27
+
28
+ // 1) Cycles
29
+ const cycleCount = input.cycles.length;
30
+ const cycleNodesTotal = input.cycles.reduce((acc, c) => acc + c.nodes.length, 0);
31
+
32
+ if (cycleCount > 0) {
33
+ const penalty = -(cycleCount * 15 + cycleNodesTotal * 2);
34
+ totalPenalty += penalty;
35
+ breakdown.push({
36
+ key: "cycles",
37
+ points: penalty,
38
+ details: `Detected ${cycleCount} cycle(s) involving ${cycleNodesTotal} file(s)`
39
+ });
40
+ } else {
41
+ breakdown.push({
42
+ key: "cycles",
43
+ points: 0,
44
+ details: "No dependency cycles detected"
45
+ });
46
+ }
47
+
48
+ // 2) Danger modules (fanIn * fanOut)
49
+ const danger4 = input.metrics.perFile.filter((m) => (m.dangerScore ?? (m.fanIn * m.fanOut)) >= 4);
50
+ const danger9 = input.metrics.perFile.filter((m) => (m.dangerScore ?? (m.fanIn * m.fanOut)) >= 9);
51
+
52
+ // Evita penalizar duas vezes o mesmo arquivo no threshold menor
53
+ const danger4Only = danger4.filter((m) => (m.dangerScore ?? (m.fanIn * m.fanOut)) < 9);
54
+
55
+ if (danger4Only.length > 0) {
56
+ const penalty = -(danger4Only.length * 2);
57
+ totalPenalty += penalty;
58
+ breakdown.push({
59
+ key: "danger>=4",
60
+ points: penalty,
61
+ details: `${danger4Only.length} module(s) have elevated coupling (dangerScore ≥ 4)`
62
+ });
63
+ } else {
64
+ breakdown.push({
65
+ key: "danger>=4",
66
+ points: 0,
67
+ details: "No elevated coupling hotspots (dangerScore ≥ 4)"
68
+ });
69
+ }
70
+
71
+ if (danger9.length > 0) {
72
+ const penalty = -(danger9.length * 4);
73
+ totalPenalty += penalty;
74
+ breakdown.push({
75
+ key: "danger>=9",
76
+ points: penalty,
77
+ details: `${danger9.length} module(s) are strong coupling hotspots (dangerScore ≥ 9)`
78
+ });
79
+ } else {
80
+ breakdown.push({
81
+ key: "danger>=9",
82
+ points: 0,
83
+ details: "No strong coupling hotspots (dangerScore ≥ 9)"
84
+ });
85
+ }
86
+
87
+ // 3) High fan-out
88
+ const fanOut10 = input.metrics.perFile.filter((m) => m.fanOut >= 10 && m.fanOut < 20);
89
+ const fanOut20 = input.metrics.perFile.filter((m) => m.fanOut >= 20);
90
+
91
+ if (fanOut10.length > 0) {
92
+ const penalty = -(fanOut10.length * 2);
93
+ totalPenalty += penalty;
94
+ breakdown.push({
95
+ key: "fanOut>=10",
96
+ points: penalty,
97
+ details: `${fanOut10.length} module(s) depend on 10–19 internal modules (fanOut ≥ 10)`
98
+ });
99
+ } else {
100
+ breakdown.push({
101
+ key: "fanOut>=10",
102
+ points: 0,
103
+ details: "No modules with very high fanOut (≥ 10)"
104
+ });
105
+ }
106
+
107
+ if (fanOut20.length > 0) {
108
+ const penalty = -(fanOut20.length * 4);
109
+ totalPenalty += penalty;
110
+ breakdown.push({
111
+ key: "fanOut>=20",
112
+ points: penalty,
113
+ details: `${fanOut20.length} module(s) depend on 20+ internal modules (fanOut ≥ 20)`
114
+ });
115
+ } else {
116
+ breakdown.push({
117
+ key: "fanOut>=20",
118
+ points: 0,
119
+ details: "No modules with extreme fanOut (≥ 20)"
120
+ });
121
+ }
122
+
123
+ const value = clamp(100 + totalPenalty, 0, 100);
124
+
125
+ return {
126
+ value,
127
+ grade: gradeFromScore(value),
128
+ status: statusFromScore(value),
129
+ breakdown
130
+ };
131
+ }