Skip to content

Commit 2c2562f

Browse files
aksOpsclaude
andcommitted
Phase 3: Flow generator with interactive HTML drill-down UI
New features: - FlowEngine core library (CLI/API/MCP/UI all call same methods) - 5 views: overview, ci, deploy, runtime, auth - 3 renderers: Mermaid, JSON, interactive HTML - Self-contained HTML with dark/light theme, click-to-drill navigation - CLI: code-intelligence flow [--view] [--format mermaid|json|html] - Bundle integration: flow.html auto-included in bundles New detectors: - GitLab CI (.gitlab-ci.yml): stages, jobs, needs, extends, tools - Enhanced Dockerfile: multi-stage builds, COPY --from, ARG Output consistency: FlowDiagram is single source of truth. All consumers get identical data, only format changes. 76 detectors, 639 tests, 32KB interactive HTML, zero server needed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e442189 commit 2c2562f

17 files changed

Lines changed: 1788 additions & 5 deletions

File tree

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@
2020
<a href="https://github.com/RandomCodeSpace/code-iq/security/dependabot"><img src="https://img.shields.io/badge/dependabot-enabled-brightgreen?style=flat-square&logo=dependabot&logoColor=white" alt="Dependabot enabled"></a>
2121
<a href="https://github.com/RandomCodeSpace/code-iq/security/code-scanning"><img src="https://img.shields.io/badge/CodeQL-enabled-brightgreen?style=flat-square&logo=github&logoColor=white" alt="CodeQL"></a>
2222
<!-- DYNAMIC:vulnerabilities --><a href="https://github.com/RandomCodeSpace/code-iq/security/dependabot"><img src="https://img.shields.io/badge/vulnerabilities-0-brightgreen?style=flat-square&logo=hackthebox&logoColor=white" alt="0 Vulnerabilities"></a><!-- /DYNAMIC:vulnerabilities -->
23-
<!-- DYNAMIC:detectors --><a href="https://github.com/RandomCodeSpace/code-iq"><img src="https://img.shields.io/badge/detectors-75-brightgreen?style=flat-square&logo=codefactor&logoColor=white" alt="75 Detectors"></a><!-- /DYNAMIC:detectors -->
23+
<!-- DYNAMIC:detectors --><a href="https://github.com/RandomCodeSpace/code-iq"><img src="https://img.shields.io/badge/detectors-76-brightgreen?style=flat-square&logo=codefactor&logoColor=white" alt="76 Detectors"></a><!-- /DYNAMIC:detectors -->
2424
<!-- DYNAMIC:languages --><a href="https://github.com/RandomCodeSpace/code-iq"><img src="https://img.shields.io/badge/languages-35-blue?style=flat-square&logo=stackblitz&logoColor=white" alt="35 Languages"></a><!-- /DYNAMIC:languages -->
25-
<!-- DYNAMIC:tests --><a href="https://github.com/RandomCodeSpace/code-iq"><img src="https://img.shields.io/badge/tests-565%20passed-brightgreen?style=flat-square&logo=pytest&logoColor=white" alt="565 passed Tests"></a><!-- /DYNAMIC:tests -->
26-
<!-- DYNAMIC:files --><a href="https://github.com/RandomCodeSpace/code-iq"><img src="https://img.shields.io/badge/files-226-informational?style=flat-square&logo=files&logoColor=white" alt="226 Files"></a><!-- /DYNAMIC:files -->
27-
<!-- DYNAMIC:loc --><a href="https://github.com/RandomCodeSpace/code-iq"><img src="https://img.shields.io/badge/LOC-25%2C736-informational?style=flat-square&logo=codacy&logoColor=white" alt="25,736 Loc"></a><!-- /DYNAMIC:loc -->
25+
<!-- DYNAMIC:tests --><a href="https://github.com/RandomCodeSpace/code-iq"><img src="https://img.shields.io/badge/tests-639%20passed-brightgreen?style=flat-square&logo=pytest&logoColor=white" alt="639 passed Tests"></a><!-- /DYNAMIC:tests -->
26+
<!-- DYNAMIC:files --><a href="https://github.com/RandomCodeSpace/code-iq"><img src="https://img.shields.io/badge/files-238-informational?style=flat-square&logo=files&logoColor=white" alt="238 Files"></a><!-- /DYNAMIC:files -->
27+
<!-- DYNAMIC:loc --><a href="https://github.com/RandomCodeSpace/code-iq"><img src="https://img.shields.io/badge/LOC-27%2C365-informational?style=flat-square&logo=codacy&logoColor=white" alt="27,365 Loc"></a><!-- /DYNAMIC:loc -->
2828
</p>
2929

3030
---

src/code_intelligence/cli.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,14 @@ def bundle(
386386
from code_intelligence.output.serializers import JsonSerializer
387387
zf.writestr("graph/graph.json", JsonSerializer().serialize(model))
388388

389+
# Include interactive flow HTML
390+
try:
391+
from code_intelligence.flow.engine import FlowEngine
392+
flow_html = FlowEngine(result.graph).render_interactive()
393+
zf.writestr("flow.html", flow_html)
394+
except Exception:
395+
pass # Flow generation is optional in bundles
396+
389397
result.graph.close()
390398

391399
console.print(f"Bundle created: [bold]{output}[/bold]")
@@ -617,5 +625,39 @@ def _unprotected_fallback(store) -> list[dict]:
617625
]
618626

619627

628+
@app.command()
629+
def flow(
630+
path: Annotated[Path, typer.Argument(help="Path to analyzed codebase")] = Path("."),
631+
view: Annotated[str, typer.Option("--view", "-v", help="View: overview, ci, deploy, runtime, auth")] = "overview",
632+
format: Annotated[str, typer.Option("--format", "-f", help="Format: mermaid, json, html")] = "mermaid",
633+
backend: Annotated[str, typer.Option("--backend", "-b", help="Graph backend")] = "networkx",
634+
output: Annotated[Optional[Path], typer.Option("--output", "-o", help="Output file path")] = None,
635+
config: Annotated[Optional[Path], typer.Option("--config", "-c")] = None,
636+
) -> None:
637+
"""Generate architecture flow diagrams."""
638+
store = _load_graph_backend(path, backend, config)
639+
640+
from code_intelligence.flow.engine import FlowEngine
641+
engine = FlowEngine(store)
642+
643+
if format == "html":
644+
content = engine.render_interactive()
645+
out_path = output or Path("flow.html")
646+
out_path.write_text(content)
647+
console.print(f"Interactive flow diagram saved to [bold]{out_path}[/bold]")
648+
size_kb = out_path.stat().st_size / 1024
649+
console.print(f" Size: {size_kb:.1f} KB — open in any browser, no server needed")
650+
else:
651+
diagram = engine.generate(view)
652+
content = engine.render(diagram, format)
653+
if output:
654+
output.write_text(content)
655+
console.print(f"Flow diagram ({view}) saved to [bold]{output}[/bold]")
656+
else:
657+
console.print(content)
658+
659+
store.close()
660+
661+
620662
if __name__ == "__main__":
621663
app()
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
"""GitLab CI pipeline detector for .gitlab-ci.yml definitions."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from code_intelligence.detectors.base import DetectorContext, DetectorResult
8+
from code_intelligence.models.graph import (
9+
EdgeKind,
10+
GraphEdge,
11+
GraphNode,
12+
NodeKind,
13+
SourceLocation,
14+
)
15+
16+
_GITLAB_CI_KEYWORDS = frozenset({
17+
"stages",
18+
"variables",
19+
"default",
20+
"workflow",
21+
"include",
22+
"image",
23+
"services",
24+
"before_script",
25+
"after_script",
26+
"cache",
27+
})
28+
29+
_TOOL_KEYWORDS = (
30+
"docker",
31+
"helm",
32+
"kubectl",
33+
"terraform",
34+
"maven",
35+
"gradle",
36+
"npm",
37+
"pip",
38+
)
39+
40+
41+
def _is_gitlab_ci_file(ctx: DetectorContext) -> bool:
42+
"""Check whether the file is a GitLab CI configuration file."""
43+
return ctx.file_path.endswith(".gitlab-ci.yml")
44+
45+
46+
def _detect_tools(scripts: list[Any]) -> list[str]:
47+
"""Scan script lines for known tool keywords."""
48+
tools: list[str] = []
49+
for line in scripts:
50+
line_str = str(line)
51+
for tool in _TOOL_KEYWORDS:
52+
if tool in line_str and tool not in tools:
53+
tools.append(tool)
54+
return tools
55+
56+
57+
class GitLabCIDetector:
58+
"""Detects stages, jobs, dependencies, and tool usage from GitLab CI YAML files."""
59+
60+
name: str = "gitlab_ci"
61+
supported_languages: tuple[str, ...] = ("yaml",)
62+
63+
def detect(self, ctx: DetectorContext) -> DetectorResult:
64+
result = DetectorResult()
65+
66+
if not _is_gitlab_ci_file(ctx):
67+
return result
68+
69+
if not ctx.parsed_data:
70+
return result
71+
72+
data = ctx.parsed_data.get("data")
73+
if not isinstance(data, dict):
74+
return result
75+
76+
fp = ctx.file_path
77+
pipeline_id = f"gitlab:{fp}:pipeline"
78+
79+
# Pipeline MODULE node
80+
result.nodes.append(GraphNode(
81+
id=pipeline_id,
82+
kind=NodeKind.MODULE,
83+
label=f"pipeline:{fp}",
84+
fqn=pipeline_id,
85+
module=ctx.module_name,
86+
location=SourceLocation(file_path=fp),
87+
properties={"pipeline_file": fp},
88+
))
89+
90+
# Stages
91+
stages = data.get("stages")
92+
if isinstance(stages, list):
93+
for stage_name in stages:
94+
stage_str = str(stage_name)
95+
result.nodes.append(GraphNode(
96+
id=f"gitlab:{fp}:stage:{stage_str}",
97+
kind=NodeKind.CONFIG_KEY,
98+
label=f"stage:{stage_str}",
99+
module=ctx.module_name,
100+
location=SourceLocation(file_path=fp),
101+
properties={"stage": stage_str},
102+
))
103+
104+
# Include directives
105+
includes = data.get("include")
106+
if includes is not None:
107+
if isinstance(includes, str):
108+
includes = [includes]
109+
if isinstance(includes, list):
110+
for inc in includes:
111+
if isinstance(inc, str):
112+
target = inc
113+
elif isinstance(inc, dict):
114+
target = inc.get("local") or inc.get("file") or inc.get("template") or str(inc)
115+
else:
116+
target = str(inc)
117+
result.edges.append(GraphEdge(
118+
source=pipeline_id,
119+
target=str(target),
120+
kind=EdgeKind.IMPORTS,
121+
label=f"includes {target}",
122+
))
123+
124+
# Collect job names first for edge resolution
125+
job_names: list[str] = []
126+
for key in data:
127+
key_str = str(key)
128+
if key_str in _GITLAB_CI_KEYWORDS:
129+
continue
130+
val = data[key]
131+
if isinstance(val, dict):
132+
job_names.append(key_str)
133+
134+
job_ids: dict[str, str] = {}
135+
for name in job_names:
136+
job_ids[name] = f"gitlab:{fp}:job:{name}"
137+
138+
# Process each job
139+
for job_name in job_names:
140+
job_def = data[job_name]
141+
job_id = job_ids[job_name]
142+
143+
props: dict[str, Any] = {}
144+
145+
# Stage property
146+
stage_val = job_def.get("stage")
147+
if stage_val is not None:
148+
props["stage"] = str(stage_val)
149+
150+
# Image property
151+
image_val = job_def.get("image")
152+
if image_val is not None:
153+
props["image"] = str(image_val)
154+
155+
# Script tool detection
156+
scripts = job_def.get("script")
157+
if isinstance(scripts, list):
158+
tools = _detect_tools(scripts)
159+
if tools:
160+
props["tools"] = tools
161+
162+
# Job METHOD node
163+
result.nodes.append(GraphNode(
164+
id=job_id,
165+
kind=NodeKind.METHOD,
166+
label=job_name,
167+
fqn=job_id,
168+
module=ctx.module_name,
169+
location=SourceLocation(file_path=fp),
170+
properties=props,
171+
))
172+
173+
# CONTAINS edge: pipeline -> job
174+
result.edges.append(GraphEdge(
175+
source=pipeline_id,
176+
target=job_id,
177+
kind=EdgeKind.CONTAINS,
178+
label=f"pipeline contains job {job_name}",
179+
))
180+
181+
# needs: dependencies
182+
needs = job_def.get("needs")
183+
if isinstance(needs, str):
184+
needs = [needs]
185+
if isinstance(needs, list):
186+
for dep in needs:
187+
# needs can be a string or a dict with "job" key
188+
if isinstance(dep, dict):
189+
dep_str = str(dep.get("job", ""))
190+
else:
191+
dep_str = str(dep)
192+
if dep_str and dep_str in job_ids:
193+
result.edges.append(GraphEdge(
194+
source=job_id,
195+
target=job_ids[dep_str],
196+
kind=EdgeKind.DEPENDS_ON,
197+
label=f"job {job_name} needs {dep_str}",
198+
))
199+
200+
# extends: template inheritance
201+
extends = job_def.get("extends")
202+
if extends is not None:
203+
if isinstance(extends, str):
204+
extends = [extends]
205+
if isinstance(extends, list):
206+
for parent in extends:
207+
parent_str = str(parent)
208+
if parent_str in job_ids:
209+
result.edges.append(GraphEdge(
210+
source=job_id,
211+
target=job_ids[parent_str],
212+
kind=EdgeKind.EXTENDS,
213+
label=f"job {job_name} extends {parent_str}",
214+
))
215+
216+
return result

src/code_intelligence/detectors/iac/dockerfile.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
_EXPOSE_RE = re.compile(r'^EXPOSE\s+(\d+)', re.MULTILINE)
1919
_ENV_RE = re.compile(r'^ENV\s+(\w+)[=\s]', re.MULTILINE)
2020
_LABEL_RE = re.compile(r'^LABEL\s+(\S+)=', re.MULTILINE)
21+
_COPY_FROM_RE = re.compile(r'COPY\s+--from=(\w+)', re.MULTILINE | re.IGNORECASE)
22+
_ARG_RE = re.compile(r'^ARG\s+(\w+)', re.MULTILINE)
2123

2224

2325

@@ -32,6 +34,10 @@ def detect(self, ctx: DetectorContext) -> DetectorResult:
3234
text = decode_text(ctx)
3335

3436
stages: dict[str, str] = {} # stage alias -> base image
37+
stage_node_ids: dict[str, str] = {} # stage alias -> node id
38+
# Ordered list of (start_offset, node_id) for determining current stage
39+
from_offsets: list[tuple[int, str]] = []
40+
stage_order = 0
3541

3642
# Detect FROM instructions (base image dependencies)
3743
for m in _FROM_RE.finditer(text):
@@ -44,7 +50,7 @@ def detect(self, ctx: DetectorContext) -> DetectorResult:
4450
stages[alias] = image
4551

4652
# Parse image name and tag
47-
props: dict[str, str] = {"image": image}
53+
props: dict[str, str | int] = {"image": image}
4854
if ":" in image and not image.startswith("$"):
4955
img_name, tag = image.rsplit(":", 1)
5056
props["image_name"] = img_name
@@ -54,10 +60,18 @@ def detect(self, ctx: DetectorContext) -> DetectorResult:
5460

5561
if alias:
5662
props["stage_alias"] = alias
63+
props["build_stage"] = alias
64+
65+
props["stage_order"] = stage_order
66+
stage_order += 1
5767

5868
node_id = f"docker:{ctx.file_path}:from:{image}"
5969
label = f"FROM {image}" + (f" AS {alias}" if alias else "")
6070

71+
if alias:
72+
stage_node_ids[alias] = node_id
73+
from_offsets.append((m.start(), node_id))
74+
6175
result.nodes.append(GraphNode(
6276
id=node_id,
6377
kind=NodeKind.INFRA_RESOURCE,
@@ -79,6 +93,24 @@ def detect(self, ctx: DetectorContext) -> DetectorResult:
7993
label=f"{ctx.file_path} depends on image {image}",
8094
))
8195

96+
# Detect COPY --from instructions (multi-stage build dependencies)
97+
for m in _COPY_FROM_RE.finditer(text):
98+
source_stage = m.group(1)
99+
if source_stage in stage_node_ids:
100+
# Find the current stage (most recent FROM before this COPY)
101+
current_node_id = None
102+
for offset, nid in reversed(from_offsets):
103+
if offset < m.start():
104+
current_node_id = nid
105+
break
106+
if current_node_id and current_node_id != stage_node_ids[source_stage]:
107+
result.edges.append(GraphEdge(
108+
source=current_node_id,
109+
target=stage_node_ids[source_stage],
110+
kind=EdgeKind.DEPENDS_ON,
111+
label=f"COPY --from={source_stage}",
112+
))
113+
82114
# Detect EXPOSE instructions (exposed ports)
83115
for m in _EXPOSE_RE.finditer(text):
84116
port = m.group(1)
@@ -130,4 +162,21 @@ def detect(self, ctx: DetectorContext) -> DetectorResult:
130162
properties={"label_key": label_key},
131163
))
132164

165+
# Detect ARG instructions (build arguments)
166+
for m in _ARG_RE.finditer(text):
167+
arg_name = m.group(1)
168+
line = find_line_number(text, m.start())
169+
170+
result.nodes.append(GraphNode(
171+
id=f"docker:{ctx.file_path}:arg:{arg_name}",
172+
kind=NodeKind.CONFIG_DEFINITION,
173+
label=f"ARG {arg_name}",
174+
module=ctx.module_name,
175+
location=SourceLocation(
176+
file_path=ctx.file_path,
177+
line_start=line,
178+
),
179+
properties={"arg_name": arg_name},
180+
))
181+
133182
return result

src/code_intelligence/flow/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)