Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ npx --yes jscpd docsible --pattern "**/*.py"

`jscpd` is informational: its current baseline is 21 clones and 1.30% duplicated lines, not a zero-threshold gate. Update the baseline only after reviewing intentional duplication.

## Verified Baseline (2026-08-27)
## Verified Baseline (2026-09-13)

- `uv run pytest`: 1156 passed, 10 warnings.
- `uv run ruff check .`: 47 findings.
- `uv run mypy docsible`: 3 errors in 2 files.
- `uv run pytest` passes with 1215 tests (3 xpassed).
- `uv run ruff check .` reports no findings.
- `uv run mypy docsible` reports no issues.

Treat these results as a starting point, not permission to introduce additional failures.
68 changes: 41 additions & 27 deletions CLAIMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,28 +261,39 @@ the same fact is never computed twice by two implementations (the
`task_includes`/legacy-`include:` drift was the first instance of this class).

- Derived from the graph (single source of truth): `task_includes`,
`role_includes`, `static_reachable_task_files`, `dynamic_boundaries`,
`unknown_boundaries`, `external_role_references`, `loop_tasks`,
`notification_edges`, `orphan_task_files`, `conditional_decision_points`.
`role_includes`, `conditional_tasks`, `error_handlers`,
`static_reachable_task_files`, `dynamically_reachable_task_files`,
`unreachable_task_files`, `dynamic_boundaries`, `unknown_boundaries`,
`external_role_references`, `loop_tasks`, `notification_edges`,
`conditional_decision_points`.
- Still computed by separate scans of `role_info` (acceptable structural
counts): `total_tasks`, `task_files`, `handlers`, `max_tasks_per_file`,
`avg_tasks_per_file`; meta reads `role_dependencies`,
counts, not graph facts): `total_tasks`, `task_files`, `handlers`,
`max_tasks_per_file`, `avg_tasks_per_file`; meta reads `role_dependencies`,
`collection_dependencies`; and the non-graph analyzers
`external_integrations` (`detect_integrations`), `file_details`
(`analyze_file_complexity`), and the hotspot/inflection detectors.
- Two residual scans are flagged risks, not yet fixed:
- `conditional_tasks` and the graph-derived `conditional_decision_points`
measure the same concept through two implementations. They agree on every
tested role today (CIS: 592 = 592) but can silently drift, exactly like
`task_includes` did before it was made graph-derived. Recommendation:
keep the graph-derived value authoritative and drop or alias the scan.
- `error_handlers` is effectively dead: it counts `task.get("rescue") or
task.get("always")` over the *flattened processed* tasks, but the
flattener emits block/rescue/always as separate rows (with `module`),
never as a `rescue`/`always` key on a task — so it reports **0** even for
a role with ~189 blocks (verified on `UBUNTU22-CIS`). It is both
mis-implemented and a residual scan; the right owner is the graph, which
already walks real block/rescue/always — see Next Graph Milestones #3.
- Resolved this milestone:
- `conditional_tasks` no longer has its own flattened-task scan — it is now
derived from the same graph pass as `conditional_decision_points`, so the
two cannot drift (previously the first duplicate-scan class instance after
`task_includes`).
- `error_handlers` was dead (it scanned flattened tasks for a `rescue`/
`always` key that is never there, so always 0). It is now graph-derived
from real block/rescue/always detection; a rescue block counts as 1
(unit-tested), and roles without rescue/always correctly stay 0.
- Reachability is now a complete, honest partition. Previously a file reached
only through a dynamic boundary was counted as neither "static reachable"
nor "orphan" and silently vanished (openstack `ansible-hardening` showed
"static 2 / orphan 5" for a 20-file role). Now `static_reachable +
dynamically_reachable + unreachable == task_files` (openstack: 2 + 13 + 5
= 20), and the misleading "orphan" field is renamed `unreachable_task_files`
(no inbound edge from any *resolved* boundary), with the graph summary and
README explaining the three tiers.
- Still open (structural depth, not metrics): the graph flags that a block has
rescue/always but does not yet model the block/rescue/always control flow as
first-class nodes/edges, and files reachable only through an *unresolved*
dynamic boundary (e.g. `{{ pkg_mgr }}.yml`) still read as `unreachable`
because the graph deliberately does not guess which concrete file runs.

### Next Graph Milestones

Expand All @@ -303,10 +314,11 @@ the same fact is never computed twice by two implementations (the
per-role graphs via those role edges. This is the concrete building block
for the collection milestone and is incremental on the existing model, not
a new subsystem.
3. Add graph projections for blocks, rescue/always, and source-linked
variable scopes without claiming static certainty where Ansible defers
resolution. Fixing block/rescue/always representation here also repairs
the dead `error_handlers` metric (see Complexity ownership above).
3. Model block/rescue/always as first-class graph structure (nodes/edges), and
add source-linked variable scopes, without claiming static certainty where
Ansible defers resolution. (The `error_handlers` metric is already
graph-derived from real rescue/always detection; what remains is exposing
the block control flow itself, not just the count.)
4. Make `graph_visualisation` a renderer adapter over this contract, using
NetworkX only for renderer-specific layout work.
5. Extend the pinned external corpus before treating the graph contract as
Expand Down Expand Up @@ -391,11 +403,13 @@ duplication is prioritized by ownership and behavior rather than percentage.
and include/role boundary counts are graph-derived (milestone 14). Used
identically by `document role`, `document role --collection`, and
`scan collection`.
2. Collapse the remaining duplicate complexity scans into the graph so a fact
is computed once: `conditional_tasks` (alias/derive from
`conditional_decision_points`) and `error_handlers` (via real
block/rescue/always projection, Next Graph Milestones #3). Until then they
are two implementations of one concept and can drift.
2. **Resolved.** The duplicate complexity scans are collapsed into the graph:
`conditional_tasks` is now derived from the same graph pass as
`conditional_decision_points` (one source, cannot drift), and
`error_handlers` is graph-derived from real block/rescue/always detection
(was always 0). Reachability was also made a complete partition
(`static + dynamic-only + unreachable == task_files`) with the misleading
`orphan` renamed to `unreachable`.
3. Role-information *loading* still has one remaining duplicate: the deprecated
`RoleInfoBuilder` alongside `RoleInfoLoader` (see Known Limitations).
Retire `RoleInfoBuilder` and the deprecated `docsible role` command, and
Expand Down
26 changes: 22 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ Project home: https://github.com/jier/docsible
- Preset system — four built-in presets covering personal, team, enterprise, and consulting use cases
- Suppression system — silence false-positive recommendations with audit trail and optional expiry
- Interactive setup wizard (`docsible init`) with optional CI/CD workflow generation
- Source-backed `RoleExecutionGraph` — typed include/import, cross-role, notification and variable edges, each with static / dynamic / unknown resolution and a source location
- README "Execution Routes" + "Execution Graph Summary" — role entry point, statically vs dynamically reachable and unreachable files, and dynamic boundaries, instead of filesystem-order phases
- Collections get per-role documentation plus a collection-level complexity overview and a role index sorted by complexity
- Machine-readable `--output-format json` exposes the complexity metrics and the serialized execution graph for CI and downstream renderers

## Installation

Expand Down Expand Up @@ -125,7 +129,8 @@ docsible scan collection . --fail-on warning --output-format json

### `--output-format json`

Use `--output-format json` with `docsible analyze role` for machine-readable output:
Use `--output-format json` with `docsible analyze role` (also supported by
`validate role` and `document role`) for machine-readable output:

```bash
docsible analyze role --role . --output-format json
Expand All @@ -137,13 +142,26 @@ Output schema:
{
"role": "my-role",
"findings": [
{ "severity": "WARNING", "message": "No example playbook found", "category": "documentation" }
{ "severity": "warning", "message": "No example playbook found", "category": "documentation" }
],
"summary": { "total": 3, "critical": 0, "warning": 2, "info": 1 },
"truncated": false
"summary": { "total": 3, "shown": 3, "critical": 0, "warning": 2, "info": 1 },
"truncated": false,
"complexity": {
"total_tasks": 3, "task_files": 1, "handlers": 0,
"task_includes": 0, "conditional_tasks": 1, "error_handlers": 0,
"static_reachable_task_files": 1, "dynamically_reachable_task_files": 0,
"unreachable_task_files": 0, "dynamic_boundaries": 0, "loop_tasks": 0,
"notification_edges": 0, "collection_dependencies": 0
},
"execution_graph": { "role_id": "role:my-role", "nodes": ["..."], "edges": ["..."] }
}
```

`complexity` is derived from the `RoleExecutionGraph` (the single source of
truth for boundary, loop, notification and reachability counts), and
`execution_graph` is the full serialized node/edge model. `truncated` applies
to `findings`; the graph itself is never truncated.

### Ready-to-use CI examples

See [`examples/ci_pipeline/`](examples/ci_pipeline/) for complete, ready-to-use configurations:
Expand Down
26 changes: 11 additions & 15 deletions docsible/analyzers/complexity_analyzer/analyzers/role_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,18 +145,10 @@ def analyze_role_complexity(
# Count handlers
handlers = len(role_info.get("handlers", []))

# Count conditional tasks
conditional_tasks = sum(
1 for tf in tasks_data for task in tf.get("tasks", []) if task.get("when")
)

# Count tasks with error handling (rescue or always blocks)
error_handlers = sum(
1
for tf in tasks_data
for task in tf.get("tasks", [])
if task.get("rescue") or task.get("always")
)
# conditional_tasks and error_handlers are derived from the
# RoleExecutionGraph below (single source of truth), instead of a second
# flattened-task scan. The old error_handlers scan read a `rescue`/`always`
# key that flattened tasks never carry, so it was always 0.

# Count role dependencies (from meta/main.yml)
role_dependencies = len(role_info.get("meta", {}).get("dependencies", []))
Expand Down Expand Up @@ -207,10 +199,16 @@ def analyze_role_complexity(

# Create metrics (execution_graph already built above; reuse it).
phases = execution_graph.execution_phases()
task_nodes = [node for node in execution_graph.nodes.values() if node.kind is NodeKind.TASK]
graph_metrics = {
"conditional_tasks": sum("condition" in node.metadata for node in task_nodes),
"error_handlers": sum("error_handling" in node.metadata for node in task_nodes),
"static_reachable_task_files": sum(
phase["kind"] in {"entrypoint", "static", "conditional"} for phase in phases
),
"dynamically_reachable_task_files": sum(
phase["kind"] == "dynamic" 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}
Expand All @@ -232,7 +230,7 @@ def analyze_role_complexity(
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),
"unreachable_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()
Expand All @@ -242,8 +240,6 @@ def analyze_role_complexity(
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,
Expand Down
11 changes: 9 additions & 2 deletions docsible/analyzers/complexity_analyzer/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,20 @@ class ComplexityMetrics(BaseModel):
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)
static_reachable_task_files: int = Field(
default=0, description="Files reachable via static/conditional boundaries only"
)
dynamically_reachable_task_files: int = Field(
default=0, description="Files reachable only through an unresolved dynamic boundary"
)
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)
unreachable_task_files: int = Field(
default=0, description="Files with no inbound boundary from any resolved edge"
)
conditional_decision_points: int = Field(default=0)

# External integrations
Expand Down
3 changes: 2 additions & 1 deletion docsible/formatters/text/dry_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,9 @@ def _format_complexity(self, analysis_report, role_info: dict) -> str:
lines.append(
" Execution graph: "
f"{metrics.static_reachable_task_files} static files, "
f"{metrics.dynamically_reachable_task_files} dynamic-only, "
f"{metrics.dynamic_boundaries} dynamic boundaries, "
f"{metrics.orphan_task_files} orphans"
f"{metrics.unreachable_task_files} unreachable"
)

return "\n".join(lines)
Expand Down
15 changes: 15 additions & 0 deletions docsible/graphs/role_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,8 @@ def _add_tasks(graph: RoleExecutionGraph, role_name: str, task_file: dict[str, A
metadata["loop"] = loop
if loop_control := extract_loop_control(task):
metadata["loop_control"] = loop_control
if error_handling := _error_handling(task):
metadata["error_handling"] = error_handling
graph.add_node(GraphNode(task_id, NodeKind.TASK, str(task.get("name", "Unnamed")), source, metadata))
graph.add_edge(GraphEdge(EdgeKind.CONTAINS, file_ids[file_name], task_id, ResolutionStatus.STATIC, source))
_add_variable_edges(graph, task_id, task, variables, source)
Expand Down Expand Up @@ -365,3 +367,16 @@ def _loop(task: dict[str, Any]) -> str | None:
if "loop" in task:
return "loop"
return next((key for key in task if key.startswith("with_")), None)


def _error_handling(task: dict[str, Any]) -> str | None:
"""Return the block error-handling shape ('rescue'/'always'/both) if any."""
has_rescue = isinstance(task.get("rescue"), list)
has_always = isinstance(task.get("always"), list)
if has_rescue and has_always:
return "rescue + always"
if has_rescue:
return "rescue"
if has_always:
return "always"
return None
7 changes: 6 additions & 1 deletion docsible/templates/role/sections/adaptive_diagrams.jinja2
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,17 @@ This role contains **{{ complexity_report.metrics.total_tasks }} tasks** across
### Execution Graph Summary
- Collection dependencies: {{ complexity_report.metrics.collection_dependencies }}
- Conditional decision points: {{ complexity_report.metrics.conditional_decision_points }}
- Error handlers (blocks with rescue/always): {{ complexity_report.metrics.error_handlers }}
- Statically reachable task files: {{ complexity_report.metrics.static_reachable_task_files }}
- Dynamically reachable task files: {{ complexity_report.metrics.dynamically_reachable_task_files }}
- Unreachable task files: {{ complexity_report.metrics.unreachable_task_files }}
- Dynamic boundaries: {{ complexity_report.metrics.dynamic_boundaries }}
- Unknown boundaries: {{ complexity_report.metrics.unknown_boundaries }}
- Handler notification edges: {{ complexity_report.metrics.notification_edges }}
- Loop-bearing tasks: {{ complexity_report.metrics.loop_tasks }}
- Orphan task files: {{ complexity_report.metrics.orphan_task_files }}

_Dynamically reachable files are reached only through a boundary whose target is
templated; unreachable files have no inbound edge from any resolved boundary._

{% if architecture_diagram %}
### Component Architecture
Expand Down
34 changes: 34 additions & 0 deletions tests/analyzers/complexity/test_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ def test_analyze_conditional_percentage():
{"name": "Task 3", "module": "debug"}, # No condition
{"name": "Task 4", "module": "debug"}, # No condition
],
"mermaid": [
{"name": "Task 1", "debug": {}, "when": "condition1"},
{"name": "Task 2", "debug": {}, "when": "condition2"},
{"name": "Task 3", "debug": {}},
{"name": "Task 4", "debug": {}},
],
}
],
"handlers": [],
Expand Down Expand Up @@ -127,3 +133,31 @@ def test_task_includes_is_graph_authoritative_and_counts_legacy_include():
metrics = analyze_role_complexity(role_info).metrics
assert metrics.task_includes == 1 # bare include: counted via the graph
assert metrics.role_includes == 0


def test_error_handlers_is_graph_derived_from_rescue_blocks():
"""Regression: error_handlers used to scan flattened tasks for a
`rescue`/`always` key they never carry (always 0). Now derived from the
execution graph's block/rescue detection."""
role_info = {
"name": "guarded",
"defaults": [],
"vars": [],
"handlers": [],
"meta": {"dependencies": []},
"tasks": [
{
"file": "main.yml",
"tasks": [{"name": "Guarded", "module": "block"}],
"mermaid": [
{
"name": "Guarded",
"block": [{"name": "Try", "debug": {}}],
"rescue": [{"name": "Fallback", "debug": {}}],
}
],
}
],
}
metrics = analyze_role_complexity(role_info).metrics
assert metrics.error_handlers == 1
8 changes: 4 additions & 4 deletions tests/defaults/test_smart_defaults_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,13 @@ def test_complex_role_gets_graphs_by_default(self, complex_role_fixture, tmp_pat
# Complex role should have visualization enabled (smart default)
content = output_file.read_text()

# Complex roles may use either Mermaid diagrams OR execution phases
# Complex roles may use either Mermaid diagrams OR execution routes
has_mermaid = "```mermaid" in content
has_execution_phases = "Execution Phases" in content
has_execution_routes = "Execution Routes" in content
has_architecture = "Architecture Overview" in content

assert has_mermaid or (has_execution_phases and has_architecture), \
f"Complex role should have visualization (mermaid: {has_mermaid}, phases: {has_execution_phases}, arch: {has_architecture})"
assert has_mermaid or (has_execution_routes and has_architecture), \
f"Complex role should have visualization (mermaid: {has_mermaid}, routes: {has_execution_routes}, arch: {has_architecture})"

def test_user_override_respected(self, simple_role, tmp_path):
"""User --graph flag should override smart default."""
Expand Down
Loading
Loading