Skip to content

Commit 8ee244d

Browse files
committed
Merge remote-tracking branch 'origin/main' into feature/issue-175-circular-dependency-check
2 parents f0512c0 + 0340309 commit 8ee244d

6 files changed

Lines changed: 738 additions & 5 deletions

File tree

docs/ANALYZE_SCORING.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Analyze Scoring Reference
2+
3+
This document explains how the current `pyscn analyze` command derives the health score and the category scores that appear in CLI and HTML outputs. The implementation lives primarily in `domain/analyze.go` with orchestration in `app/analyze_usecase.go`.
4+
5+
## Calculation Flow
6+
7+
1. Each analyzer populates an `AnalyzeResponse`. The `AnalyzeUseCase` composes the project summary (`AnalyzeSummary`) with aggregate metrics (function counts, average complexity, clone duplication, dependency stats, etc.).
8+
2. `AnalyzeSummary.CalculateHealthScore()` validates the inputs, computes penalties per category, converts those penalties to scores on a 0–100 scale, and subtracts the penalties from an overall score that starts at 100.
9+
3. If validation fails, the CLI logs a warning, applies a lightweight fallback scorer, and still surfaces the grade.
10+
11+
All scores are bounded to 0–100. The overall health score has a floor of 10 to avoid degenerate results for heavily penalised projects.
12+
13+
## Category Penalties and Scores
14+
15+
Penalties are additive. Each category subtracts up to the maximum listed points from the base score (100). The same penalty value is then converted to a category score via `100 - (penalty / maxPenalty * 100)`.
16+
17+
| Category | Metric(s) | Thresholds → Penalty | Max Penalty |
18+
|---------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------|-------------|
19+
| Complexity | Average cyclomatic complexity across functions | >20 → 20, >10 → 12, >5 → 6 | 20 |
20+
| Dead Code | Count of critical dead code issues, normalised by logarithm of total files (threshold kicks in once more than 10 files are analysed) | Up to 20 based on `criticalDeadCode / normalizationFactor`, capped at 20 | 20 |
21+
| Duplication | Percentage of duplicated code across clone groups | >20% → 20, >10% → 12, >3% → 6 | 20 |
22+
| Coupling (CBO) | Weighted ratio of high-risk (`CBO > 7`) and medium-risk (`3 < CBO ≤ 7`) classes using weight 1.0 and 0.5 respectively, divided by total measured classes | >30% → 20, >15% → 12, >5% → 6 | 20 |
23+
| Dependencies | Module dependency graph: proportion of modules in cycles, dependency depth above `log₂(N)+1`, Main Sequence Deviation | Cycles up to 8 pts + depth up to 2 pts + MSD up to 2 pts (ratio/overflow calculations clamp to [0, max]) | 12 |
24+
| Architecture | Architecture rules compliance ratio (0–1) | `round((1 - compliance) * 8)` | 8 |
25+
26+
When a category is disabled (e.g., `--skip-clones`), its penalty is zero and the prior score (100) carries forward so the missing analysis does not hurt the overall grade.
27+
28+
## Overall Health Score and Grade
29+
30+
`HealthScore = max(10, 100 - Σ penalties)`
31+
32+
Grades mirror the score quality thresholds that the CLI uses for emoji indicators:
33+
34+
- A: ≥85
35+
- B: ≥70
36+
- C: ≥55
37+
- D: ≥40
38+
- F: <40
39+
40+
The CLI treats a project as “healthy” when `HealthScore ≥ 70`.
41+
42+
## Presentation Details
43+
44+
- The CLI summary shows the overall score, letter grade, and per-category scores with emojis (`` ≥85, `👍` ≥70, `⚠️` ≥55, `` otherwise).
45+
- HTML and JSON outputs expose the same scores and include additional per-category context (e.g., high-risk counts).
46+
- When dependency or architecture analyses are disabled, their sections are omitted from the detailed summary, but the rest of the scoring remains unchanged.
47+
48+
## Fallback Behaviour
49+
50+
If the validator detects inconsistent summary metrics (negative averages, duplication >100%, etc.), the application:
51+
52+
1. Logs a warning about the failure to calculate the health score.
53+
2. Uses `CalculateFallbackScore()`, which applies simple penalties:
54+
- −10 for average complexity above 10,
55+
- −5 if any dead code exists,
56+
- −5 if any high-complexity functions exist.
57+
3. Enforces the same minimum score (10) and derives the grade from the fallback score.
58+
59+
This ensures the CLI still produces a meaningful result even when upstream metrics are incomplete or malformed.

internal/analyzer/cfg_builder.go

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -320,8 +320,33 @@ func (b *CFGBuilder) processStatement(stmt *parser.Node) {
320320
b.buildClass(stmt)
321321

322322
case parser.NodeReturn:
323-
// Add return statement and connect to exit
323+
// Add return statement to current block
324324
b.currentBlock.AddStatement(stmt)
325+
326+
// Find the next finally block that needs to execute before this return completes.
327+
// Walk the exception stack from innermost to outermost, skipping any finally
328+
// blocks we're currently inside (to avoid self-loops), until we find the first
329+
// enclosing finally block that hasn't been entered yet.
330+
var targetFinallyBlock *BasicBlock
331+
for i := len(b.exceptionStack) - 1; i >= 0; i-- {
332+
exceptionCtx := b.exceptionStack[i]
333+
if exceptionCtx.finallyBlock != nil && b.currentBlock != exceptionCtx.finallyBlock {
334+
targetFinallyBlock = exceptionCtx.finallyBlock
335+
break
336+
}
337+
}
338+
339+
if targetFinallyBlock != nil {
340+
// Route through the next outer finally block
341+
b.cfg.ConnectBlocks(b.currentBlock, targetFinallyBlock, EdgeReturn)
342+
// Create unreachable block for any code after return
343+
unreachableBlock := b.createBlock(LabelUnreachable)
344+
b.currentBlock = unreachableBlock
345+
return
346+
}
347+
348+
// No enclosing finally blocks remain - connect directly to exit
349+
// This handles: returns outside try blocks, or returns in the outermost finally
325350
b.cfg.ConnectBlocks(b.currentBlock, b.cfg.Exit, EdgeReturn)
326351
// Create unreachable block for any code following the return statement.
327352
// This block will not be connected to the exit, making it truly unreachable
@@ -942,10 +967,35 @@ func (b *CFGBuilder) processTryStatement(stmt *parser.Node) {
942967
b.processStatement(finallyStmt)
943968
}
944969

945-
// Finally always flows to exit
970+
// Finally flows to exit in the normal case
946971
if !b.hasSuccessor(b.currentBlock, b.cfg.Exit) {
947972
b.cfg.ConnectBlocks(b.currentBlock, exitBlock, EdgeNormal)
948973
}
974+
975+
// Additionally, if this finally can be reached via return from inner code,
976+
// it must propagate that return to the next enclosing finally (if any) or to CFG.Exit.
977+
// This handles nested try-finally where an inner finally returns.
978+
// We look for the next outer finally block by searching the exception stack
979+
// (excluding the current context which is about to be popped).
980+
var nextOuterFinally *BasicBlock
981+
for i := len(b.exceptionStack) - 2; i >= 0; i-- {
982+
if b.exceptionStack[i].finallyBlock != nil {
983+
nextOuterFinally = b.exceptionStack[i].finallyBlock
984+
break
985+
}
986+
}
987+
988+
if nextOuterFinally != nil {
989+
// Connect to next outer finally with return edge
990+
if !b.hasSuccessor(finallyBlock, nextOuterFinally) {
991+
b.cfg.ConnectBlocks(finallyBlock, nextOuterFinally, EdgeReturn)
992+
}
993+
} else {
994+
// No outer finally - connect to CFG.Exit for return propagation
995+
if !b.hasSuccessor(finallyBlock, b.cfg.Exit) {
996+
b.cfg.ConnectBlocks(finallyBlock, b.cfg.Exit, EdgeReturn)
997+
}
998+
}
949999
}
9501000

9511001
// Continue with exit block

0 commit comments

Comments
 (0)