Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
096a89a
fix json output pollution to terminal, logging level set to waring, r…
jier Sep 12, 2026
9e3b025
removing confusing license output and confusing terminal output
jier Sep 12, 2026
1c99788
work in progress for role graph renderer base to be consumed by merma…
jier Sep 12, 2026
f1d7616
Isolated fixes
jier Sep 12, 2026
79cf7e9
updated claims to show role execution graph work in progress
jier Sep 13, 2026
aefa37c
added fix branch to ci step
jier Sep 13, 2026
6c440aa
- Safe nested target resolution:
jier Sep 13, 2026
2f999ae
What changed
jier Sep 13, 2026
f09bf6a
analyze role --output-format json now includes:
jier Sep 13, 2026
4bd34e2
feat: prioritize execution graph insights in generated readmes
jier Sep 13, 2026
8414893
fix: first time using collection
jier Sep 13, 2026
651cdc3
feat: conolidation role analysis for role and colleciton and complexi…
jier Sep 13, 2026
9a51453
claims update
jier Sep 13, 2026
7dee445
missing update of remaning work for duplicate work, collection bounda…
jier Sep 13, 2026
0ae3bbc
fix: consistent collection role discovery and skip empty submodule dirs
jier Sep 13, 2026
9d05a33
help user when using --graph to expect per task file on onlyh simple/…
jier Sep 13, 2026
11ae837
fix: bound grouped execution overview for flat task directories
jier Sep 13, 2026
6ed29ad
perf: scan task variables once instead of per-variable regex then ref…
jier Sep 13, 2026
828526b
refactor: derive include boundary counts from the execution graph (si…
jier Sep 13, 2026
9f1105f
update complexity ownership in claims to pick up later
jier Sep 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: CI

on:
push:
branches: [main, "feature/**"]
branches: [main, "feature/**", "fix/**"]
pull_request:
branches: [main]

Expand Down
366 changes: 342 additions & 24 deletions CLAIMS.md

Large diffs are not rendered by default.

90 changes: 64 additions & 26 deletions docsible/analyzers/complexity_analyzer/analyzers/role_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,17 @@ def analyze_role_complexity(
role_info: dict[str, Any],
include_patterns: bool = False,
min_confidence: float = 0.7,
execution_graph: Any | None = None,
) -> ComplexityReport:
"""Analyze role complexity and generate comprehensive report.

Args:
role_info: Role information dictionary from build_role_info()
include_patterns: Whether to include pattern analysis (requires --simplification-report flag)
min_confidence: Minimum confidence threshold for pattern detection (0.0-1.0)
execution_graph: Prebuilt RoleExecutionGraph to reuse instead of
rebuilding one (build is cheap now, but callers that already hold
one — e.g. analyze_role — pass it to avoid duplicate work)

Returns:
ComplexityReport with metrics, category, recommendations, and optional pattern analysis
Expand Down Expand Up @@ -156,33 +160,32 @@ def analyze_role_complexity(

# Count role dependencies (from meta/main.yml)
role_dependencies = len(role_info.get("meta", {}).get("dependencies", []))
collection_dependencies = len(role_info.get("meta", {}).get("collections", []))

# Count role includes (include_role, import_role)
role_includes = sum(
1
for tf in tasks_data
for task in tf.get("tasks", [])
if task.get("module", "")
in [
"include_role",
"import_role",
"ansible.builtin.include_role",
"ansible.builtin.import_role",
]
)
# Build the execution graph once; it is the authoritative source for
# boundary counts and every graph-derived metric below. This replaces a
# second regex scan of the flattened tasks, which historically missed the
# legacy bare `include:` keyword. Count distinct *source* tasks (not edges)
# so one templated include that fans out to several candidate files is
# still counted as the single boundary statement it is.
from docsible.graphs import EdgeKind, NodeKind, ResolutionStatus, build_role_execution_graph

# Count task includes (include_tasks, import_tasks)
task_includes = sum(
1
for tf in tasks_data
for task in tf.get("tasks", [])
if task.get("module", "")
in [
"include_tasks",
"import_tasks",
"ansible.builtin.include_tasks",
"ansible.builtin.import_tasks",
]
if execution_graph is None:
execution_graph = build_role_execution_graph(role_info)

task_includes = len(
{
edge.source_id
for edge in execution_graph.edges
if edge.kind in {EdgeKind.INCLUDES_TASK_FILE, EdgeKind.IMPORTS_TASK_FILE}
}
)
role_includes = len(
{
edge.source_id
for edge in execution_graph.edges
if edge.kind in {EdgeKind.INCLUDES_ROLE, EdgeKind.IMPORTS_ROLE}
}
)

# Calculate max and average tasks per file
Expand All @@ -202,19 +205,53 @@ def analyze_role_complexity(
# Detect inflection points
inflection_points = detect_inflection_points(role_info, hotspots)

# Create metrics
# Create metrics (execution_graph already built above; reuse it).
phases = execution_graph.execution_phases()
graph_metrics = {
"static_reachable_task_files": sum(
phase["kind"] in {"entrypoint", "static", "conditional"} for phase in phases
),
"dynamic_boundaries": sum(
edge.resolution is ResolutionStatus.DYNAMIC
and edge.kind in {EdgeKind.INCLUDES_TASK_FILE, EdgeKind.IMPORTS_TASK_FILE, EdgeKind.INCLUDES_ROLE, EdgeKind.IMPORTS_ROLE}
for edge in execution_graph.edges
),
"unknown_boundaries": sum(
edge.resolution is ResolutionStatus.UNKNOWN
and edge.kind in {EdgeKind.INCLUDES_TASK_FILE, EdgeKind.IMPORTS_TASK_FILE, EdgeKind.INCLUDES_ROLE, EdgeKind.IMPORTS_ROLE}
for edge in execution_graph.edges
),
"external_role_references": sum(
node.kind is NodeKind.EXTERNAL_ROLE for node in execution_graph.nodes.values()
),
"loop_tasks": sum(
node.kind is NodeKind.TASK and "loop" in node.metadata
for node in execution_graph.nodes.values()
),
"notification_edges": sum(
edge.kind is EdgeKind.NOTIFIES_HANDLER and edge.target_id is not None
for edge in execution_graph.edges
),
"orphan_task_files": sum(phase["kind"] == "unreachable" for phase in phases),
"conditional_decision_points": sum(
node.kind is NodeKind.TASK and "condition" in node.metadata
for node in execution_graph.nodes.values()
),
}
metrics = ComplexityMetrics(
total_tasks=total_tasks,
task_files=task_files,
handlers=handlers,
conditional_tasks=conditional_tasks,
error_handlers=error_handlers,
role_dependencies=role_dependencies,
collection_dependencies=collection_dependencies,
role_includes=role_includes,
task_includes=task_includes,
external_integrations=len(integration_points),
max_tasks_per_file=max_tasks_per_file,
avg_tasks_per_file=avg_tasks_per_file,
**graph_metrics,
)

# Classify complexity
Expand Down Expand Up @@ -274,6 +311,7 @@ def analyze_role_complexity(
integration_points=integration_points,
recommendations=recommendations,
task_files_detail=task_files_detail,
execution_graph=execution_graph.to_dict(),
pattern_analysis=pattern_report,
)

Expand Down
12 changes: 12 additions & 0 deletions docsible/analyzers/complexity_analyzer/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,20 @@ class ComplexityMetrics(BaseModel):

# Internal composition (role orchestration)
role_dependencies: int = Field(default=0, description="Role dependencies from meta/main.yml")
collection_dependencies: int = Field(default=0, description="Collection dependencies from meta/main.yml")
role_includes: int = Field(default=0, description="include_role/import_role count")
task_includes: int = Field(default=0, description="include_tasks/import_tasks count")

# Execution graph metrics (source-backed relationships, not runtime claims)
static_reachable_task_files: int = Field(default=0)
dynamic_boundaries: int = Field(default=0)
unknown_boundaries: int = Field(default=0)
external_role_references: int = Field(default=0)
loop_tasks: int = Field(default=0)
notification_edges: int = Field(default=0)
orphan_task_files: int = Field(default=0)
conditional_decision_points: int = Field(default=0)

# External integrations
external_integrations: int = Field(
default=0, description="Count of external system connections"
Expand Down Expand Up @@ -129,6 +140,7 @@ class ComplexityReport(BaseModel):
integration_points: list[IntegrationPoint] = Field(default_factory=list)
recommendations: list[str] = Field(default_factory=list)
task_files_detail: list[dict[str, Any]] = Field(default_factory=list)
execution_graph: dict[str, Any] = Field(default_factory=dict)

# Pattern analysis (optional)
pattern_analysis: Any | None = Field(
Expand Down
28 changes: 27 additions & 1 deletion docsible/analyzers/recommendations/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
from pathlib import Path

from docsible.models.recommendation import Recommendation
from docsible.models.severity import Severity

from .enhancement import EnhancementRecommendationGenerator
from .quality import QualityRecommendationGenerator
from .security import SecurityRecommendationGenerator


def generate_all_recommendations(role_path: Path) -> list[Recommendation]:
def generate_all_recommendations(role_path: Path, analysis_report=None) -> list[Recommendation]:
"""Generate all recommendations for a role.

Args:
Expand All @@ -30,6 +31,31 @@ def generate_all_recommendations(role_path: Path) -> list[Recommendation]:
enhancement_gen = EnhancementRecommendationGenerator()
all_recommendations.extend(enhancement_gen.analyze_role(role_path))

if analysis_report is not None:
metrics = analysis_report.metrics
if metrics.dynamic_boundaries:
all_recommendations.append(
Recommendation(
severity=Severity.INFO,
category="execution_graph",
message=f"{metrics.dynamic_boundaries} dynamic execution boundaries need runtime review",
rationale="Templated includes cannot be resolved to one static execution path.",
remediation="Review the Execution Graph Summary and validate each dynamic path.",
confidence=1.0,
)
)
if metrics.collection_dependencies:
all_recommendations.append(
Recommendation(
severity=Severity.INFO,
category="execution_graph",
message=f"Role depends on {metrics.collection_dependencies} Ansible collections",
rationale="Collection availability affects portability and runtime module resolution.",
remediation="Pin and document collection requirements.",
confidence=1.0,
)
)

# Sort by severity (critical first)
all_recommendations.sort(key=lambda r: r.severity.priority, reverse=True)

Expand Down
9 changes: 6 additions & 3 deletions docsible/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,21 @@
def setup_logging(verbose: bool = False) -> None:
"""Configure logging for the application.

Logs are routed to stderr so stdout stays clean for data output
(e.g. machine-readable JSON from ``--output-format json``).

Args:
verbose: If True, set log level to DEBUG, otherwise INFO
verbose: If True, set log level to DEBUG, otherwise WARNING

Example:
>>> setup_logging(verbose=True)
>>> logger.debug("This will be shown")
"""
level = logging.DEBUG if verbose else logging.INFO
level = logging.DEBUG if verbose else logging.WARNING
logging.basicConfig(
level=level,
format="%(levelname)s - %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
handlers=[logging.StreamHandler(sys.stderr)],
)


Expand Down
Loading
Loading