osscodeiq 0.0.0__py3-none-any.whl

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.
Files changed (183) hide show
  1. osscodeiq/__init__.py +0 -0
  2. osscodeiq/analyzer.py +467 -0
  3. osscodeiq/cache/__init__.py +0 -0
  4. osscodeiq/cache/hasher.py +23 -0
  5. osscodeiq/cache/store.py +300 -0
  6. osscodeiq/classifiers/__init__.py +0 -0
  7. osscodeiq/classifiers/layer_classifier.py +69 -0
  8. osscodeiq/cli.py +721 -0
  9. osscodeiq/config.py +113 -0
  10. osscodeiq/detectors/__init__.py +0 -0
  11. osscodeiq/detectors/auth/__init__.py +0 -0
  12. osscodeiq/detectors/auth/certificate_auth.py +139 -0
  13. osscodeiq/detectors/auth/ldap_auth.py +89 -0
  14. osscodeiq/detectors/auth/session_header_auth.py +120 -0
  15. osscodeiq/detectors/base.py +41 -0
  16. osscodeiq/detectors/config/__init__.py +0 -0
  17. osscodeiq/detectors/config/batch_structure.py +128 -0
  18. osscodeiq/detectors/config/cloudformation.py +183 -0
  19. osscodeiq/detectors/config/docker_compose.py +179 -0
  20. osscodeiq/detectors/config/github_actions.py +150 -0
  21. osscodeiq/detectors/config/gitlab_ci.py +216 -0
  22. osscodeiq/detectors/config/helm_chart.py +187 -0
  23. osscodeiq/detectors/config/ini_structure.py +101 -0
  24. osscodeiq/detectors/config/json_structure.py +72 -0
  25. osscodeiq/detectors/config/kubernetes.py +305 -0
  26. osscodeiq/detectors/config/kubernetes_rbac.py +212 -0
  27. osscodeiq/detectors/config/openapi.py +194 -0
  28. osscodeiq/detectors/config/package_json.py +99 -0
  29. osscodeiq/detectors/config/properties_detector.py +108 -0
  30. osscodeiq/detectors/config/pyproject_toml.py +169 -0
  31. osscodeiq/detectors/config/sql_structure.py +155 -0
  32. osscodeiq/detectors/config/toml_structure.py +93 -0
  33. osscodeiq/detectors/config/tsconfig_json.py +105 -0
  34. osscodeiq/detectors/config/yaml_structure.py +82 -0
  35. osscodeiq/detectors/cpp/__init__.py +0 -0
  36. osscodeiq/detectors/cpp/cpp_structures.py +192 -0
  37. osscodeiq/detectors/csharp/__init__.py +0 -0
  38. osscodeiq/detectors/csharp/csharp_efcore.py +184 -0
  39. osscodeiq/detectors/csharp/csharp_minimal_apis.py +156 -0
  40. osscodeiq/detectors/csharp/csharp_structures.py +317 -0
  41. osscodeiq/detectors/docs/__init__.py +0 -0
  42. osscodeiq/detectors/docs/markdown_structure.py +117 -0
  43. osscodeiq/detectors/frontend/__init__.py +0 -0
  44. osscodeiq/detectors/frontend/angular_components.py +177 -0
  45. osscodeiq/detectors/frontend/frontend_routes.py +259 -0
  46. osscodeiq/detectors/frontend/react_components.py +148 -0
  47. osscodeiq/detectors/frontend/svelte_components.py +84 -0
  48. osscodeiq/detectors/frontend/vue_components.py +150 -0
  49. osscodeiq/detectors/generic/__init__.py +1 -0
  50. osscodeiq/detectors/generic/imports_detector.py +413 -0
  51. osscodeiq/detectors/go/__init__.py +0 -0
  52. osscodeiq/detectors/go/go_orm.py +202 -0
  53. osscodeiq/detectors/go/go_structures.py +162 -0
  54. osscodeiq/detectors/go/go_web.py +157 -0
  55. osscodeiq/detectors/iac/__init__.py +0 -0
  56. osscodeiq/detectors/iac/bicep.py +135 -0
  57. osscodeiq/detectors/iac/dockerfile.py +182 -0
  58. osscodeiq/detectors/iac/terraform.py +188 -0
  59. osscodeiq/detectors/java/__init__.py +0 -0
  60. osscodeiq/detectors/java/azure_functions.py +424 -0
  61. osscodeiq/detectors/java/azure_messaging.py +350 -0
  62. osscodeiq/detectors/java/class_hierarchy.py +349 -0
  63. osscodeiq/detectors/java/config_def.py +82 -0
  64. osscodeiq/detectors/java/cosmos_db.py +105 -0
  65. osscodeiq/detectors/java/graphql_resolver.py +188 -0
  66. osscodeiq/detectors/java/grpc_service.py +142 -0
  67. osscodeiq/detectors/java/ibm_mq.py +178 -0
  68. osscodeiq/detectors/java/jaxrs.py +160 -0
  69. osscodeiq/detectors/java/jdbc.py +196 -0
  70. osscodeiq/detectors/java/jms.py +116 -0
  71. osscodeiq/detectors/java/jpa_entity.py +143 -0
  72. osscodeiq/detectors/java/kafka.py +113 -0
  73. osscodeiq/detectors/java/kafka_protocol.py +70 -0
  74. osscodeiq/detectors/java/micronaut.py +248 -0
  75. osscodeiq/detectors/java/module_deps.py +191 -0
  76. osscodeiq/detectors/java/public_api.py +206 -0
  77. osscodeiq/detectors/java/quarkus.py +176 -0
  78. osscodeiq/detectors/java/rabbitmq.py +150 -0
  79. osscodeiq/detectors/java/raw_sql.py +136 -0
  80. osscodeiq/detectors/java/repository.py +131 -0
  81. osscodeiq/detectors/java/rmi.py +129 -0
  82. osscodeiq/detectors/java/spring_events.py +117 -0
  83. osscodeiq/detectors/java/spring_rest.py +168 -0
  84. osscodeiq/detectors/java/spring_security.py +212 -0
  85. osscodeiq/detectors/java/tibco_ems.py +193 -0
  86. osscodeiq/detectors/java/websocket.py +188 -0
  87. osscodeiq/detectors/kotlin/__init__.py +0 -0
  88. osscodeiq/detectors/kotlin/kotlin_structures.py +124 -0
  89. osscodeiq/detectors/kotlin/ktor_routes.py +163 -0
  90. osscodeiq/detectors/proto/__init__.py +0 -0
  91. osscodeiq/detectors/proto/proto_structure.py +153 -0
  92. osscodeiq/detectors/python/__init__.py +0 -0
  93. osscodeiq/detectors/python/celery_tasks.py +88 -0
  94. osscodeiq/detectors/python/django_auth.py +132 -0
  95. osscodeiq/detectors/python/django_models.py +157 -0
  96. osscodeiq/detectors/python/django_views.py +74 -0
  97. osscodeiq/detectors/python/fastapi_auth.py +143 -0
  98. osscodeiq/detectors/python/fastapi_routes.py +68 -0
  99. osscodeiq/detectors/python/flask_routes.py +67 -0
  100. osscodeiq/detectors/python/kafka_python.py +175 -0
  101. osscodeiq/detectors/python/pydantic_models.py +115 -0
  102. osscodeiq/detectors/python/python_structures.py +234 -0
  103. osscodeiq/detectors/python/sqlalchemy_models.py +82 -0
  104. osscodeiq/detectors/registry.py +100 -0
  105. osscodeiq/detectors/rust/__init__.py +0 -0
  106. osscodeiq/detectors/rust/actix_web.py +234 -0
  107. osscodeiq/detectors/rust/rust_structures.py +174 -0
  108. osscodeiq/detectors/scala/__init__.py +0 -0
  109. osscodeiq/detectors/scala/scala_structures.py +128 -0
  110. osscodeiq/detectors/shell/__init__.py +0 -0
  111. osscodeiq/detectors/shell/bash_detector.py +127 -0
  112. osscodeiq/detectors/shell/powershell_detector.py +118 -0
  113. osscodeiq/detectors/typescript/__init__.py +0 -0
  114. osscodeiq/detectors/typescript/express_routes.py +55 -0
  115. osscodeiq/detectors/typescript/fastify_routes.py +156 -0
  116. osscodeiq/detectors/typescript/graphql_resolvers.py +100 -0
  117. osscodeiq/detectors/typescript/kafka_js.py +164 -0
  118. osscodeiq/detectors/typescript/mongoose_orm.py +151 -0
  119. osscodeiq/detectors/typescript/nestjs_controllers.py +99 -0
  120. osscodeiq/detectors/typescript/nestjs_guards.py +138 -0
  121. osscodeiq/detectors/typescript/passport_jwt.py +133 -0
  122. osscodeiq/detectors/typescript/prisma_orm.py +96 -0
  123. osscodeiq/detectors/typescript/remix_routes.py +160 -0
  124. osscodeiq/detectors/typescript/sequelize_orm.py +136 -0
  125. osscodeiq/detectors/typescript/typeorm_entities.py +86 -0
  126. osscodeiq/detectors/typescript/typescript_structures.py +185 -0
  127. osscodeiq/detectors/utils.py +49 -0
  128. osscodeiq/discovery/__init__.py +11 -0
  129. osscodeiq/discovery/change_detector.py +97 -0
  130. osscodeiq/discovery/file_discovery.py +342 -0
  131. osscodeiq/flow/__init__.py +0 -0
  132. osscodeiq/flow/engine.py +78 -0
  133. osscodeiq/flow/models.py +72 -0
  134. osscodeiq/flow/renderer.py +127 -0
  135. osscodeiq/flow/templates/interactive.html +252 -0
  136. osscodeiq/flow/vendor/cytoscape-dagre.min.js +8 -0
  137. osscodeiq/flow/vendor/cytoscape.min.js +32 -0
  138. osscodeiq/flow/vendor/dagre.min.js +3809 -0
  139. osscodeiq/flow/views.py +357 -0
  140. osscodeiq/graph/__init__.py +0 -0
  141. osscodeiq/graph/backend.py +52 -0
  142. osscodeiq/graph/backends/__init__.py +23 -0
  143. osscodeiq/graph/backends/kuzu.py +576 -0
  144. osscodeiq/graph/backends/networkx.py +135 -0
  145. osscodeiq/graph/backends/sqlite_backend.py +406 -0
  146. osscodeiq/graph/builder.py +297 -0
  147. osscodeiq/graph/query.py +228 -0
  148. osscodeiq/graph/store.py +183 -0
  149. osscodeiq/graph/views.py +231 -0
  150. osscodeiq/models/__init__.py +17 -0
  151. osscodeiq/models/graph.py +116 -0
  152. osscodeiq/output/__init__.py +0 -0
  153. osscodeiq/output/dot.py +171 -0
  154. osscodeiq/output/mermaid.py +160 -0
  155. osscodeiq/output/safety.py +58 -0
  156. osscodeiq/output/serializers.py +42 -0
  157. osscodeiq/parsing/__init__.py +5 -0
  158. osscodeiq/parsing/languages/__init__.py +0 -0
  159. osscodeiq/parsing/languages/base.py +23 -0
  160. osscodeiq/parsing/languages/java.py +68 -0
  161. osscodeiq/parsing/languages/python.py +57 -0
  162. osscodeiq/parsing/languages/typescript.py +95 -0
  163. osscodeiq/parsing/parser_manager.py +125 -0
  164. osscodeiq/parsing/structured/__init__.py +0 -0
  165. osscodeiq/parsing/structured/gradle_parser.py +78 -0
  166. osscodeiq/parsing/structured/json_parser.py +24 -0
  167. osscodeiq/parsing/structured/properties_parser.py +56 -0
  168. osscodeiq/parsing/structured/sql_parser.py +54 -0
  169. osscodeiq/parsing/structured/xml_parser.py +148 -0
  170. osscodeiq/parsing/structured/yaml_parser.py +38 -0
  171. osscodeiq/server/__init__.py +7 -0
  172. osscodeiq/server/app.py +53 -0
  173. osscodeiq/server/mcp_server.py +174 -0
  174. osscodeiq/server/middleware.py +16 -0
  175. osscodeiq/server/routes.py +184 -0
  176. osscodeiq/server/service.py +445 -0
  177. osscodeiq/server/templates/welcome.html +56 -0
  178. osscodeiq-0.0.0.dist-info/METADATA +30 -0
  179. osscodeiq-0.0.0.dist-info/RECORD +183 -0
  180. osscodeiq-0.0.0.dist-info/WHEEL +5 -0
  181. osscodeiq-0.0.0.dist-info/entry_points.txt +2 -0
  182. osscodeiq-0.0.0.dist-info/licenses/LICENSE +21 -0
  183. osscodeiq-0.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,97 @@
1
+ """Git diff-based incremental change detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ from osscodeiq.discovery.file_discovery import (
10
+ ChangeType,
11
+ DiscoveredFile,
12
+ _compute_sha256,
13
+ _map_extension_to_language,
14
+ )
15
+
16
+
17
+ # Mapping from git status letters to ChangeType.
18
+ _GIT_STATUS_MAP: dict[str, ChangeType] = {
19
+ "A": ChangeType.ADDED,
20
+ "M": ChangeType.MODIFIED,
21
+ "D": ChangeType.DELETED,
22
+ "R": ChangeType.MODIFIED, # Rename treated as modified
23
+ }
24
+
25
+
26
+ class ChangeDetector:
27
+ """Detects file changes between two git commits."""
28
+
29
+ def detect_changes(
30
+ self, repo_path: Path, last_commit: str
31
+ ) -> list[DiscoveredFile]:
32
+ """Return files that changed between *last_commit* and HEAD.
33
+
34
+ Uses ``git diff --name-status <last_commit>..HEAD``.
35
+ """
36
+ repo_path = repo_path.resolve()
37
+
38
+ if not re.fullmatch(r'[0-9a-fA-F]{4,40}', last_commit):
39
+ raise ValueError(f"Invalid commit SHA: {last_commit}")
40
+
41
+ result = subprocess.run(
42
+ ["git", "diff", "--name-status", f"{last_commit}..HEAD"],
43
+ cwd=repo_path,
44
+ capture_output=True,
45
+ text=True,
46
+ check=True,
47
+ )
48
+
49
+ discovered: list[DiscoveredFile] = []
50
+ for line in result.stdout.splitlines():
51
+ if not line.strip():
52
+ continue
53
+ parts = line.split("\t")
54
+ if len(parts) < 2:
55
+ continue
56
+
57
+ status_letter = parts[0][0] # First char handles R100 etc.
58
+ # For renames the *destination* path is the last element.
59
+ file_rel = parts[-1]
60
+ change_type = _GIT_STATUS_MAP.get(status_letter)
61
+ if change_type is None:
62
+ continue
63
+
64
+ rel_path = Path(file_rel)
65
+ lang = _map_extension_to_language(rel_path)
66
+ if lang is None:
67
+ continue
68
+
69
+ abs_path = repo_path / rel_path
70
+
71
+ if change_type is ChangeType.DELETED:
72
+ discovered.append(
73
+ DiscoveredFile(
74
+ path=rel_path,
75
+ language=lang,
76
+ content_hash="",
77
+ size_bytes=0,
78
+ change_type=change_type,
79
+ )
80
+ )
81
+ else:
82
+ try:
83
+ size = abs_path.stat().st_size
84
+ content_hash = _compute_sha256(abs_path)
85
+ except OSError:
86
+ continue
87
+ discovered.append(
88
+ DiscoveredFile(
89
+ path=rel_path,
90
+ language=lang,
91
+ content_hash=content_hash,
92
+ size_bytes=size,
93
+ change_type=change_type,
94
+ )
95
+ )
96
+
97
+ return discovered
@@ -0,0 +1,342 @@
1
+ """Git-aware file discovery for OSSCodeIQ."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import hashlib
7
+ import logging
8
+ import os
9
+ import re
10
+ import subprocess
11
+ from dataclasses import dataclass
12
+ from enum import Enum
13
+ from pathlib import Path
14
+
15
+ import pathspec
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ from osscodeiq.config import Config
20
+
21
+
22
+ class ChangeType(Enum):
23
+ """Type of file change detected by git."""
24
+
25
+ ADDED = "added"
26
+ MODIFIED = "modified"
27
+ DELETED = "deleted"
28
+
29
+
30
+ # Map file extensions to language identifiers.
31
+ _EXTENSION_MAP: dict[str, str] = {
32
+ ".java": "java",
33
+ ".py": "python",
34
+ ".ts": "typescript",
35
+ ".tsx": "typescript",
36
+ ".js": "javascript",
37
+ ".jsx": "javascript",
38
+ ".xml": "xml",
39
+ ".yaml": "yaml",
40
+ ".yml": "yaml",
41
+ ".json": "json",
42
+ ".properties": "properties",
43
+ ".gradle": "gradle",
44
+ ".gradle.kts": "gradle",
45
+ ".sql": "sql",
46
+ ".graphql": "graphql",
47
+ ".gql": "graphql",
48
+ ".proto": "proto",
49
+ ".md": "markdown",
50
+ ".markdown": "markdown",
51
+ ".bicep": "bicep",
52
+ ".tf": "terraform",
53
+ ".tfvars": "terraform",
54
+ ".cs": "csharp",
55
+ ".go": "go",
56
+ ".cpp": "cpp",
57
+ ".cc": "cpp",
58
+ ".cxx": "cpp",
59
+ ".hpp": "cpp",
60
+ ".c": "c",
61
+ ".h": "c",
62
+ ".sh": "bash",
63
+ ".bash": "bash",
64
+ ".zsh": "bash",
65
+ ".ps1": "powershell",
66
+ ".psm1": "powershell",
67
+ ".psd1": "powershell",
68
+ ".bat": "batch",
69
+ ".cmd": "batch",
70
+ ".rb": "ruby",
71
+ ".rs": "rust",
72
+ ".kt": "kotlin",
73
+ ".kts": "kotlin",
74
+ ".scala": "scala",
75
+ ".swift": "swift",
76
+ ".r": "r",
77
+ ".R": "r",
78
+ ".pl": "perl",
79
+ ".pm": "perl",
80
+ ".lua": "lua",
81
+ ".dart": "dart",
82
+ ".hcl": "terraform",
83
+ ".dockerfile": "dockerfile",
84
+ ".toml": "toml",
85
+ ".ini": "ini",
86
+ ".cfg": "ini",
87
+ ".conf": "ini",
88
+ ".env": "dotenv",
89
+ ".csv": "csv",
90
+ ".vue": "vue",
91
+ ".svelte": "svelte",
92
+ ".html": "html",
93
+ ".htm": "html",
94
+ ".css": "css",
95
+ ".scss": "scss",
96
+ ".less": "less",
97
+ ".mjs": "javascript",
98
+ ".cjs": "javascript",
99
+ ".mts": "typescript",
100
+ ".cts": "typescript",
101
+ ".jsonc": "json",
102
+ ".groovy": "groovy",
103
+ ".pyi": "python",
104
+ ".razor": "razor",
105
+ ".cshtml": "cshtml",
106
+ ".adoc": "asciidoc",
107
+ }
108
+
109
+
110
+ _FILENAME_MAP: dict[str, str] = {
111
+ "Dockerfile": "dockerfile",
112
+ "Makefile": "makefile",
113
+ "GNUmakefile": "makefile",
114
+ "Jenkinsfile": "groovy",
115
+ "Vagrantfile": "ruby",
116
+ "Gemfile": "ruby",
117
+ "Rakefile": "ruby",
118
+ "go.mod": "gomod",
119
+ "go.sum": "gosum",
120
+ }
121
+
122
+
123
+ @dataclass(frozen=True, slots=True)
124
+ class DiscoveredFile:
125
+ """A file discovered during repository scanning."""
126
+
127
+ path: Path
128
+ language: str
129
+ content_hash: str
130
+ size_bytes: int
131
+ change_type: ChangeType | None = None
132
+
133
+
134
+ def _map_extension_to_language(file_path: Path) -> str | None:
135
+ """Map a file's extension to a language string.
136
+
137
+ Falls back to :data:`_FILENAME_MAP` when no extension matches, using the
138
+ basename of *file_path* (e.g. ``"Dockerfile"`` from ``"app/Dockerfile"``).
139
+ """
140
+ name = file_path.name
141
+ # Check compound extensions first (e.g. .gradle.kts)
142
+ for ext, lang in _EXTENSION_MAP.items():
143
+ if name.endswith(ext):
144
+ return lang
145
+ # Fallback: match the full filename (extensionless files like Dockerfile)
146
+ return _FILENAME_MAP.get(name)
147
+
148
+
149
+ def _matches_any_pattern(path_str: str, patterns: list[str]) -> bool:
150
+ """Check if a path matches any of the given glob patterns."""
151
+ for pattern in patterns:
152
+ if fnmatch.fnmatch(path_str, pattern):
153
+ return True
154
+ return False
155
+
156
+
157
+ def _compile_exclude_patterns(patterns: list[str]) -> re.Pattern[str] | None:
158
+ """Compile a list of glob patterns into a single regex for fast matching."""
159
+ if not patterns:
160
+ return None
161
+ return re.compile("|".join(fnmatch.translate(p) for p in patterns))
162
+
163
+
164
+ def _build_ignore_spec(repo_path: Path, config_patterns: list[str]) -> pathspec.PathSpec:
165
+ """Build a combined ignore spec from config patterns + ignore files.
166
+
167
+ Reads .codeignore and .gitignore files from the repo root and any
168
+ subdirectory, combining them with the config exclude_patterns.
169
+ Uses gitignore-style matching (handles node_modules at any depth).
170
+ """
171
+ all_patterns: list[str] = []
172
+
173
+ # 1. Config exclude patterns (convert ** glob to gitignore style)
174
+ for p in config_patterns:
175
+ # Strip leading **/ — gitignore patterns match at any depth by default
176
+ cleaned = p.replace("**/", "").rstrip("/**")
177
+ all_patterns.append(cleaned)
178
+ # Also keep original for explicit **/ matching
179
+ all_patterns.append(p)
180
+
181
+ # 2. Read .codeignore from repo root
182
+ codeignore = repo_path / ".codeignore"
183
+ if codeignore.is_file():
184
+ try:
185
+ lines = codeignore.read_text().splitlines()
186
+ for line in lines:
187
+ line = line.strip()
188
+ if line and not line.startswith("#"):
189
+ all_patterns.append(line)
190
+ logger.debug("Loaded %d patterns from .codeignore", len(lines))
191
+ except OSError:
192
+ pass
193
+
194
+ # 3. Read .gitignore from repo root (supplementary)
195
+ gitignore = repo_path / ".gitignore"
196
+ if gitignore.is_file():
197
+ try:
198
+ lines = gitignore.read_text().splitlines()
199
+ for line in lines:
200
+ line = line.strip()
201
+ if line and not line.startswith("#"):
202
+ all_patterns.append(line)
203
+ logger.debug("Loaded %d patterns from .gitignore", len(lines))
204
+ except OSError:
205
+ pass
206
+
207
+ return pathspec.PathSpec.from_lines("gitwildmatch", all_patterns)
208
+
209
+
210
+ def _compute_sha256(file_path: Path) -> str:
211
+ """Compute SHA-256 hex digest for a file.
212
+
213
+ Delegates to :func:`osscodeiq.cache.hasher.hash_file` for
214
+ consistency, falling back to a local implementation if the import fails.
215
+ """
216
+ from osscodeiq.cache.hasher import hash_file
217
+
218
+ return hash_file(file_path)
219
+ return h.hexdigest()
220
+
221
+
222
+ class FileDiscovery:
223
+ """Discovers files in a repository using git or filesystem walk."""
224
+
225
+ def __init__(self, config: Config | None = None) -> None:
226
+ self._config = config or Config()
227
+ self._current_commit: str | None = None
228
+
229
+ @property
230
+ def current_commit(self) -> str | None:
231
+ """The HEAD commit hash from the last discovery run."""
232
+ return self._current_commit
233
+
234
+ def discover(
235
+ self, repo_path: Path, incremental: bool = True
236
+ ) -> list[DiscoveredFile]:
237
+ """Discover tracked files in a repository.
238
+
239
+ Uses ``git ls-files`` for git repos (fast, ~50ms for large repos).
240
+ Falls back to ``os.walk`` for non-git directories.
241
+ """
242
+ repo_path = repo_path.resolve()
243
+ discovery_cfg = self._config.discovery
244
+
245
+ if self._is_git_repo(repo_path):
246
+ self._current_commit = self._git_head(repo_path)
247
+ relative_paths = self._git_ls_files(repo_path)
248
+ else:
249
+ self._current_commit = None
250
+ relative_paths = self._walk_files(repo_path)
251
+
252
+ ignore_spec = _build_ignore_spec(repo_path, discovery_cfg.exclude_patterns)
253
+
254
+ result: list[DiscoveredFile] = []
255
+ for rel in relative_paths:
256
+ abs_path = repo_path / rel
257
+ rel_path = Path(rel)
258
+
259
+ # Check ignore patterns first (fastest rejection)
260
+ if ignore_spec.match_file(str(rel_path)):
261
+ continue
262
+
263
+ # Extension filter
264
+ lang = _map_extension_to_language(rel_path)
265
+ if lang is None:
266
+ continue
267
+
268
+ # Check include extensions (skip for extensionless filename matches)
269
+ is_filename_match = rel_path.name in _FILENAME_MAP
270
+ if not is_filename_match and not any(
271
+ rel.endswith(ext) for ext in discovery_cfg.include_extensions
272
+ ):
273
+ continue
274
+
275
+ # Size guard
276
+ try:
277
+ size = abs_path.stat().st_size
278
+ except OSError:
279
+ continue
280
+ if size > discovery_cfg.max_file_size_bytes:
281
+ continue
282
+
283
+ content_hash = _compute_sha256(abs_path)
284
+ result.append(
285
+ DiscoveredFile(
286
+ path=rel_path,
287
+ language=lang,
288
+ content_hash=content_hash,
289
+ size_bytes=size,
290
+ )
291
+ )
292
+
293
+ return result
294
+
295
+ # ------------------------------------------------------------------
296
+ # Internal helpers
297
+ # ------------------------------------------------------------------
298
+
299
+ @staticmethod
300
+ def _is_git_repo(path: Path) -> bool:
301
+ try:
302
+ subprocess.run(
303
+ ["git", "rev-parse", "--git-dir"],
304
+ cwd=path,
305
+ capture_output=True,
306
+ check=True,
307
+ )
308
+ return True
309
+ except (subprocess.CalledProcessError, FileNotFoundError):
310
+ return False
311
+
312
+ @staticmethod
313
+ def _git_head(repo_path: Path) -> str:
314
+ result = subprocess.run(
315
+ ["git", "rev-parse", "HEAD"],
316
+ cwd=repo_path,
317
+ capture_output=True,
318
+ text=True,
319
+ check=True,
320
+ )
321
+ return result.stdout.strip()
322
+
323
+ @staticmethod
324
+ def _git_ls_files(repo_path: Path) -> list[str]:
325
+ result = subprocess.run(
326
+ ["git", "ls-files"],
327
+ cwd=repo_path,
328
+ capture_output=True,
329
+ text=True,
330
+ check=True,
331
+ )
332
+ return [line for line in result.stdout.splitlines() if line]
333
+
334
+ @staticmethod
335
+ def _walk_files(root: Path) -> list[str]:
336
+ paths: list[str] = []
337
+ for dirpath, _dirnames, filenames in os.walk(root):
338
+ for fname in filenames:
339
+ abs_p = Path(dirpath) / fname
340
+ rel = str(abs_p.relative_to(root))
341
+ paths.append(rel)
342
+ return paths
File without changes
@@ -0,0 +1,78 @@
1
+ """FlowEngine — core library for generating architecture flow diagrams.
2
+
3
+ All consumers (CLI, HTTP API, MCP tool, HTML UI) call the same methods.
4
+ FlowDiagram is the single source of truth — renderers only change format, never data.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from osscodeiq.flow.models import FlowDiagram
10
+ from osscodeiq.flow.renderer import render_html, render_json, render_mermaid
11
+ from osscodeiq.flow.views import (
12
+ build_auth_view,
13
+ build_ci_view,
14
+ build_deploy_view,
15
+ build_overview,
16
+ build_runtime_view,
17
+ )
18
+ from osscodeiq.graph.store import GraphStore
19
+
20
+ _VIEWS = {
21
+ "overview": build_overview,
22
+ "ci": build_ci_view,
23
+ "deploy": build_deploy_view,
24
+ "runtime": build_runtime_view,
25
+ "auth": build_auth_view,
26
+ }
27
+
28
+ AVAILABLE_VIEWS = tuple(_VIEWS.keys())
29
+
30
+
31
+ class FlowEngine:
32
+ """Generate and render architecture flow diagrams from a OSSCodeIQ graph.
33
+
34
+ Usage::
35
+
36
+ engine = FlowEngine(store)
37
+ diagram = engine.generate("overview")
38
+ print(engine.render(diagram, "mermaid"))
39
+ # Or generate interactive HTML with all views:
40
+ html = engine.render_interactive()
41
+ """
42
+
43
+ def __init__(self, store: GraphStore) -> None:
44
+ self._store = store
45
+
46
+ def generate(self, view: str = "overview") -> FlowDiagram:
47
+ """Generate a single flow view diagram."""
48
+ builder = _VIEWS.get(view)
49
+ if builder is None:
50
+ raise ValueError(f"Unknown view: {view}. Available: {', '.join(AVAILABLE_VIEWS)}")
51
+ return builder(self._store)
52
+
53
+ def generate_all(self) -> dict[str, FlowDiagram]:
54
+ """Generate all views. Used for HTML interactive output."""
55
+ return {name: self.generate(name) for name in AVAILABLE_VIEWS}
56
+
57
+ def render(self, diagram: FlowDiagram, format: str = "mermaid") -> str:
58
+ """Render a diagram to string.
59
+
60
+ Args:
61
+ diagram: The FlowDiagram to render.
62
+ format: Output format — "mermaid" or "json".
63
+ """
64
+ if format == "mermaid":
65
+ return render_mermaid(diagram)
66
+ elif format == "json":
67
+ return render_json(diagram)
68
+ else:
69
+ raise ValueError(f"Unknown format: {format}. Available: mermaid, json")
70
+
71
+ def render_interactive(self, project_name: str = "Project") -> str:
72
+ """Generate all views and bake into a self-contained interactive HTML file."""
73
+ all_views = self.generate_all()
74
+ stats = {
75
+ "total_nodes": self._store.node_count,
76
+ "total_edges": self._store.edge_count,
77
+ }
78
+ return render_html(all_views, stats, project_name=project_name)
@@ -0,0 +1,72 @@
1
+ """Data models for flow diagrams — the single source of truth for all renderers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass
9
+ class FlowNode:
10
+ """A node in a flow diagram (collapsed/summarized from graph nodes)."""
11
+ id: str
12
+ label: str
13
+ kind: str # "trigger", "job", "service", "endpoint", "database", "guard", etc.
14
+ properties: dict = field(default_factory=dict)
15
+ style: str = "default" # "default", "success", "warning", "danger"
16
+
17
+
18
+ @dataclass
19
+ class FlowSubgraph:
20
+ """A labeled group of nodes in a flow diagram."""
21
+ id: str
22
+ label: str
23
+ nodes: list[FlowNode] = field(default_factory=list)
24
+ drill_down_view: str | None = None # "ci", "deploy", "runtime", "auth"
25
+
26
+
27
+ @dataclass
28
+ class FlowEdge:
29
+ """An edge in a flow diagram."""
30
+ source: str
31
+ target: str
32
+ label: str | None = None
33
+ style: str = "solid" # "solid", "dotted", "thick"
34
+
35
+
36
+ @dataclass
37
+ class FlowDiagram:
38
+ """A complete flow diagram — the single source of truth for all renderers."""
39
+ title: str
40
+ view: str # "overview", "ci", "deploy", "runtime", "auth"
41
+ direction: str = "LR"
42
+ subgraphs: list[FlowSubgraph] = field(default_factory=list)
43
+ loose_nodes: list[FlowNode] = field(default_factory=list)
44
+ edges: list[FlowEdge] = field(default_factory=list)
45
+ stats: dict = field(default_factory=dict)
46
+
47
+ def all_nodes(self) -> list[FlowNode]:
48
+ """Return all nodes across subgraphs and loose nodes."""
49
+ result = list(self.loose_nodes)
50
+ for sg in self.subgraphs:
51
+ result.extend(sg.nodes)
52
+ return result
53
+
54
+ def to_dict(self) -> dict:
55
+ """Serialize to a plain dict (for JSON renderer and API responses)."""
56
+ return {
57
+ "title": self.title,
58
+ "view": self.view,
59
+ "direction": self.direction,
60
+ "subgraphs": [
61
+ {
62
+ "id": sg.id,
63
+ "label": sg.label,
64
+ "drill_down_view": sg.drill_down_view,
65
+ "nodes": [{"id": n.id, "label": n.label, "kind": n.kind, "properties": n.properties, "style": n.style} for n in sg.nodes],
66
+ }
67
+ for sg in self.subgraphs
68
+ ],
69
+ "loose_nodes": [{"id": n.id, "label": n.label, "kind": n.kind, "properties": n.properties, "style": n.style} for n in self.loose_nodes],
70
+ "edges": [{"source": e.source, "target": e.target, "label": e.label, "style": e.style} for e in self.edges],
71
+ "stats": self.stats,
72
+ }