archcheck 0.26.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 (50) hide show
  1. archcheck/__init__.py +4 -0
  2. archcheck/__main__.py +6 -0
  3. archcheck/analyzer.py +379 -0
  4. archcheck/architecture_config.py +154 -0
  5. archcheck/ast_facts.py +2601 -0
  6. archcheck/cli.py +287 -0
  7. archcheck/compile_commands.py +300 -0
  8. archcheck/concurrency.py +573 -0
  9. archcheck/contracts.py +219 -0
  10. archcheck/data_dependencies.py +171 -0
  11. archcheck/dependency.py +208 -0
  12. archcheck/dependency_order.py +440 -0
  13. archcheck/dsm_cluster.py +257 -0
  14. archcheck/file_metrics.py +89 -0
  15. archcheck/framework_rules.py +701 -0
  16. archcheck/gui.py +332 -0
  17. archcheck/image_facts.py +458 -0
  18. archcheck/indirection.py +611 -0
  19. archcheck/keil.py +6 -0
  20. archcheck/literals.py +54 -0
  21. archcheck/macro_facts.py +158 -0
  22. archcheck/model.py +1449 -0
  23. archcheck/parallel.py +140 -0
  24. archcheck/path_pruning.py +359 -0
  25. archcheck/paths.py +33 -0
  26. archcheck/profiles/generic/heuristics.yaml +31 -0
  27. archcheck/profiles/mcu/cortex-m-cmsis.yaml +28 -0
  28. archcheck/profiles/mcu/gd32f30x.yaml +17 -0
  29. archcheck/profiles/mcu/stm32-hal.yaml +23 -0
  30. archcheck/profiles/platform/arduino-esp32.yaml +40 -0
  31. archcheck/profiles/platform/esp-idf.yaml +54 -0
  32. archcheck/profiles/rtos/cmsis-rtos2.yaml +57 -0
  33. archcheck/profiles/rtos/freertos.yaml +123 -0
  34. archcheck/profiles/rtos/posix.yaml +37 -0
  35. archcheck/profiles/rtos/protothreads.yaml +43 -0
  36. archcheck/profiles/rtos/zephyr.yaml +61 -0
  37. archcheck/report.py +219 -0
  38. archcheck/runtime_units.py +595 -0
  39. archcheck/semantic.py +2729 -0
  40. archcheck/templates/report.html +908 -0
  41. archcheck/time_base.py +291 -0
  42. archcheck/toolchains/__init__.py +6 -0
  43. archcheck/toolchains/keil.py +521 -0
  44. archcheck/yield_locals.py +114 -0
  45. archcheck-0.26.0.dist-info/METADATA +172 -0
  46. archcheck-0.26.0.dist-info/RECORD +50 -0
  47. archcheck-0.26.0.dist-info/WHEEL +5 -0
  48. archcheck-0.26.0.dist-info/entry_points.txt +3 -0
  49. archcheck-0.26.0.dist-info/licenses/LICENSE +21 -0
  50. archcheck-0.26.0.dist-info/top_level.txt +1 -0
archcheck/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Architecture health scanner for C and C++ projects."""
2
+
3
+ __version__ = "0.26.0"
4
+
archcheck/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ from archcheck.cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
6
+
archcheck/analyzer.py ADDED
@@ -0,0 +1,379 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+ import os
5
+ from pathlib import Path
6
+ import re
7
+ from typing import Any
8
+
9
+ from archcheck.architecture_config import ArchitectureConfig, load_architecture_config
10
+ from archcheck.compile_commands import (
11
+ find_compile_commands,
12
+ infer_path_mapping,
13
+ load_compile_commands,
14
+ map_compilation_path,
15
+ )
16
+ from archcheck.dependency import DependencyAnalysis, analyze_include_dependencies
17
+ from archcheck.file_metrics import build_file_metrics
18
+ from archcheck.model import (
19
+ AnalysisResult,
20
+ ArchitectureMetrics,
21
+ CompileCommand,
22
+ Coverage,
23
+ DirectoryCoverage,
24
+ ExcludedFile,
25
+ PathMapping,
26
+ )
27
+
28
+
29
+ INCLUDE_FLAGS = {"-I", "/I", "-isystem", "-iquote", "-idirafter"}
30
+ SOURCE_EXTENSIONS = {".c", ".cc", ".cpp", ".cxx", ".ino", ".s", ".asm"}
31
+ IGNORED_DIRECTORIES = {".git", ".arch-report", ".venv", "build", "dist"}
32
+
33
+
34
+ def analyze_project(
35
+ project: Path,
36
+ compile_commands_path: Path | None = None,
37
+ architecture_path: Path | None = None,
38
+ target: str | None = None,
39
+ ) -> AnalysisResult:
40
+ project = project.expanduser().resolve()
41
+ database = (
42
+ compile_commands_path.expanduser().resolve()
43
+ if compile_commands_path is not None
44
+ else find_compile_commands(project)
45
+ )
46
+ path_mapping = infer_path_mapping(database, project)
47
+ architecture = _load_optional_architecture(project, architecture_path)
48
+ commands = load_compile_commands(database, path_mapping)
49
+ coverage = build_coverage(project, commands, target or _default_target(database, project))
50
+
51
+ source_files = {command.file for command in commands}
52
+ extension_counts = Counter(path.suffix.lower() or "<none>" for path in source_files)
53
+ include_directories = sorted(
54
+ {
55
+ include_path
56
+ for command in commands
57
+ for include_path in _extract_include_directories(command, path_mapping)
58
+ },
59
+ key=lambda path: str(path).casefold(),
60
+ )
61
+ command_contexts = tuple(
62
+ (
63
+ command,
64
+ tuple(
65
+ sorted(
66
+ _extract_include_directories(command, path_mapping),
67
+ key=lambda path: str(path).casefold(),
68
+ )
69
+ ),
70
+ )
71
+ for command in commands
72
+ )
73
+ dependencies = analyze_include_dependencies(project, command_contexts)
74
+ file_metrics = build_file_metrics(project, dependencies, architecture)
75
+ files_by_module = Counter(
76
+ _node_for_source(path, project, architecture) for path in source_files
77
+ )
78
+
79
+ metrics = ArchitectureMetrics(
80
+ translation_units=len(commands),
81
+ source_files=len(source_files),
82
+ analyzed_files=dependencies.analyzed_files,
83
+ include_directories=len(include_directories),
84
+ include_edges=len(dependencies.edges),
85
+ dependency_cycle_groups=len(dependencies.cycles),
86
+ global_variables=0,
87
+ cross_file_global_variables=0,
88
+ functions=0,
89
+ variables=0,
90
+ function_calls=0,
91
+ variable_references=0,
92
+ total_lines=sum(item.total_lines for item in file_metrics),
93
+ code_lines=sum(item.code_lines for item in file_metrics),
94
+ high_risk_files=sum(item.risk_score >= 60 for item in file_metrics),
95
+ unassigned_files=sum(item.architecture_node == "unassigned" for item in file_metrics),
96
+ files_by_extension=dict(sorted(extension_counts.items())),
97
+ files_by_module=dict(sorted(files_by_module.items())),
98
+ )
99
+ return AnalysisResult(
100
+ project=project,
101
+ analysis_mode="compilation-database",
102
+ compile_commands=database,
103
+ architecture_config=architecture.path if architecture else None,
104
+ path_mapping=path_mapping,
105
+ include_directories=tuple(include_directories),
106
+ dependency_edges=dependencies.edges,
107
+ dependency_cycles=dependencies.cycles,
108
+ coupling_hotspots=dependencies.hotspots,
109
+ global_variables=(),
110
+ functions=(),
111
+ variables=(),
112
+ semantic_edges=(),
113
+ architecture_modules=architecture.modules if architecture else (),
114
+ file_metrics=file_metrics,
115
+ semantic_warnings=(),
116
+ metrics=metrics,
117
+ coverage=coverage,
118
+ )
119
+
120
+
121
+ def analyze_source_tree(project: Path) -> AnalysisResult:
122
+ project = project.expanduser().resolve()
123
+ source_files = _source_files_on_disk(project)
124
+
125
+ extension_counts = Counter(path.suffix.lower() for path in source_files)
126
+ files_by_module = Counter(_module_name(path, project) for path in source_files)
127
+ relative_files = tuple(sorted(path.resolve().relative_to(project).as_posix() for path in source_files))
128
+ dependencies = DependencyAnalysis(
129
+ analyzed_files=len(relative_files),
130
+ files=relative_files,
131
+ edges=(),
132
+ cycles=(),
133
+ hotspots=(),
134
+ )
135
+ file_metrics = build_file_metrics(project, dependencies, None)
136
+ metrics = ArchitectureMetrics(
137
+ translation_units=len(source_files),
138
+ source_files=len(source_files),
139
+ analyzed_files=len(source_files),
140
+ include_directories=0,
141
+ include_edges=0,
142
+ dependency_cycle_groups=0,
143
+ global_variables=0,
144
+ cross_file_global_variables=0,
145
+ functions=0,
146
+ variables=0,
147
+ function_calls=0,
148
+ variable_references=0,
149
+ total_lines=sum(item.total_lines for item in file_metrics),
150
+ code_lines=sum(item.code_lines for item in file_metrics),
151
+ high_risk_files=0,
152
+ unassigned_files=0,
153
+ files_by_extension=dict(sorted(extension_counts.items())),
154
+ files_by_module=dict(sorted(files_by_module.items())),
155
+ )
156
+ return AnalysisResult(
157
+ project=project,
158
+ analysis_mode="source-tree",
159
+ compile_commands=None,
160
+ architecture_config=None,
161
+ path_mapping=None,
162
+ include_directories=(),
163
+ dependency_edges=(),
164
+ dependency_cycles=(),
165
+ coupling_hotspots=(),
166
+ global_variables=(),
167
+ functions=(),
168
+ variables=(),
169
+ semantic_edges=(),
170
+ architecture_modules=(),
171
+ file_metrics=file_metrics,
172
+ semantic_warnings=(),
173
+ metrics=metrics,
174
+ coverage=Coverage(
175
+ target="source-tree",
176
+ source_files_on_disk=len(source_files),
177
+ translation_units=len(source_files),
178
+ files_analyzed=len(source_files),
179
+ excluded=(),
180
+ ),
181
+ )
182
+
183
+
184
+ def build_coverage(
185
+ project: Path,
186
+ commands: list[CompileCommand],
187
+ target: str,
188
+ ) -> Coverage:
189
+ """Compare the compilation database against the source files present on disk."""
190
+
191
+ on_disk = {
192
+ path.resolve().relative_to(project).as_posix()
193
+ for path in _source_files_on_disk(project)
194
+ }
195
+ analyzed: set[str] = set()
196
+ excluded: list[ExcludedFile] = []
197
+ seen_outside: set[str] = set()
198
+ for command in commands:
199
+ try:
200
+ relative = command.file.resolve().relative_to(project).as_posix()
201
+ except ValueError:
202
+ key = command.file.as_posix()
203
+ if key not in seen_outside:
204
+ seen_outside.add(key)
205
+ excluded.append(ExcludedFile(path=key, reason="outside-project-root"))
206
+ continue
207
+ if not command.file.is_file():
208
+ if relative not in {item.path for item in excluded}:
209
+ excluded.append(ExcludedFile(path=relative, reason="missing-on-disk"))
210
+ continue
211
+ analyzed.add(relative)
212
+ excluded.extend(
213
+ ExcludedFile(path=path, reason="not-in-compile-database")
214
+ for path in sorted(on_disk - analyzed)
215
+ )
216
+ return Coverage(
217
+ target=target,
218
+ source_files_on_disk=len(on_disk),
219
+ translation_units=len(commands),
220
+ files_analyzed=len(analyzed),
221
+ excluded=tuple(excluded),
222
+ by_directory=_coverage_by_directory(on_disk, analyzed),
223
+ )
224
+
225
+
226
+ def _coverage_by_directory(on_disk: set[str], analyzed: set[str]) -> dict[str, DirectoryCoverage]:
227
+ """Cumulative on-disk / analyzed / excluded counts for every directory prefix."""
228
+
229
+ counters: dict[str, list[int]] = {}
230
+ for path in on_disk:
231
+ parts = path.split("/")[:-1]
232
+ prefixes = ["."] + ["/".join(parts[: index + 1]) for index in range(len(parts))]
233
+ is_analyzed = path in analyzed
234
+ for prefix in prefixes:
235
+ counter = counters.setdefault(prefix, [0, 0, 0])
236
+ counter[0] += 1
237
+ counter[1 if is_analyzed else 2] += 1
238
+ return {
239
+ prefix: DirectoryCoverage(source_files_on_disk=total, files_analyzed=done, excluded=missing)
240
+ for prefix, (total, done, missing) in counters.items()
241
+ }
242
+
243
+
244
+ def coverage_for_focus(coverage: Coverage, focus_patterns: list[str]) -> dict[str, Any]:
245
+ """Coverage counted only over files matching ``focus_patterns`` (``**`` and ``*`` globs).
246
+
247
+ This is what a partition should report instead of the whole-project numbers.
248
+ """
249
+
250
+ matchers = [_glob_to_regex(pattern) for pattern in focus_patterns]
251
+
252
+ def in_focus(path: str) -> bool:
253
+ return any(matcher.match(path) for matcher in matchers)
254
+
255
+ excluded = [item for item in coverage.excluded if in_focus(item.path)]
256
+ on_disk_in_focus = 0
257
+ analyzed_in_focus = 0
258
+ if coverage.by_directory:
259
+ # Files are not listed individually; derive from the deepest matching directories.
260
+ roots = _focus_roots(focus_patterns)
261
+ for root in roots:
262
+ item = coverage.by_directory.get(root)
263
+ if item is not None:
264
+ on_disk_in_focus += item.source_files_on_disk
265
+ analyzed_in_focus += item.files_analyzed
266
+ return {
267
+ "target": coverage.target,
268
+ "focus": list(focus_patterns),
269
+ "sourceFilesOnDisk": on_disk_in_focus,
270
+ "filesAnalyzed": analyzed_in_focus,
271
+ "excluded": [item.to_dict() for item in excluded],
272
+ "ratio": round(analyzed_in_focus / on_disk_in_focus, 4) if on_disk_in_focus else None,
273
+ }
274
+
275
+
276
+ def _focus_roots(patterns: list[str]) -> list[str]:
277
+ roots: list[str] = []
278
+ for pattern in patterns:
279
+ normalized = pattern.replace("\\", "/").lstrip("./")
280
+ root = re.sub(r"/\*\*.*$", "", normalized).rstrip("/")
281
+ if root and "*" not in root and root not in roots:
282
+ roots.append(root)
283
+ return roots
284
+
285
+
286
+ def _glob_to_regex(pattern: str) -> re.Pattern[str]:
287
+ normalized = pattern.replace("\\", "/")
288
+ if normalized.startswith("./"):
289
+ normalized = normalized[2:]
290
+ escaped = re.escape(normalized).replace(r"\*\*", "\0").replace(r"\*", "[^/]*").replace("\0", ".*")
291
+ return re.compile(f"^{escaped}$", re.IGNORECASE)
292
+
293
+
294
+ def _default_target(database: Path, project: Path) -> str:
295
+ try:
296
+ return f"compile-commands:{database.relative_to(project).as_posix()}"
297
+ except ValueError:
298
+ return f"compile-commands:{database.as_posix()}"
299
+
300
+
301
+ def _source_files_on_disk(project: Path) -> list[Path]:
302
+ source_files: list[Path] = []
303
+ for directory, directory_names, file_names in os.walk(project):
304
+ directory_names[:] = [
305
+ name for name in directory_names if name not in IGNORED_DIRECTORIES
306
+ ]
307
+ current_directory = Path(directory)
308
+ for file_name in file_names:
309
+ path = current_directory / file_name
310
+ if path.suffix.lower() in SOURCE_EXTENSIONS:
311
+ source_files.append(path)
312
+ return source_files
313
+
314
+
315
+ def _extract_include_directories(
316
+ command: CompileCommand,
317
+ path_mapping: PathMapping | None,
318
+ ) -> set[Path]:
319
+ paths: set[Path] = set()
320
+ arguments = command.arguments
321
+ index = 0
322
+
323
+ while index < len(arguments):
324
+ argument = arguments[index]
325
+ include_value: str | None = None
326
+
327
+ if argument in INCLUDE_FLAGS and index + 1 < len(arguments):
328
+ index += 1
329
+ include_value = arguments[index]
330
+ elif argument.startswith("-I") and len(argument) > 2:
331
+ include_value = argument[2:]
332
+ elif argument.startswith("/I") and len(argument) > 2:
333
+ include_value = argument[2:]
334
+ elif argument.startswith("-isystem") and len(argument) > len("-isystem"):
335
+ include_value = argument[len("-isystem") :]
336
+
337
+ if include_value:
338
+ paths.add(
339
+ map_compilation_path(
340
+ include_value,
341
+ command.raw_directory,
342
+ path_mapping,
343
+ )
344
+ )
345
+ index += 1
346
+
347
+ return paths
348
+
349
+
350
+ def _module_name(path: Path, project: Path) -> str:
351
+ try:
352
+ parts = path.resolve().relative_to(project).parts
353
+ except ValueError:
354
+ return "external"
355
+ if len(parts) >= 2:
356
+ return "/".join(parts[:2])
357
+ return parts[0] if parts else "."
358
+
359
+
360
+ def _load_optional_architecture(
361
+ project: Path,
362
+ architecture_path: Path | None,
363
+ ) -> ArchitectureConfig | None:
364
+ candidate = architecture_path or project / "architecture.yaml"
365
+ return load_architecture_config(candidate) if candidate.is_file() else None
366
+
367
+
368
+ def _node_for_source(
369
+ path: Path,
370
+ project: Path,
371
+ architecture: ArchitectureConfig | None,
372
+ ) -> str:
373
+ try:
374
+ relative = path.resolve().relative_to(project).as_posix()
375
+ except ValueError:
376
+ return "external"
377
+ if architecture is None:
378
+ return _module_name(path, project)
379
+ return architecture.match_module(relative) or "unassigned"
@@ -0,0 +1,154 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ import re
6
+ from typing import Any
7
+
8
+ import yaml
9
+
10
+ from archcheck.model import ArchitectureModule
11
+
12
+
13
+ class ArchitectureConfigError(ValueError):
14
+ """Raised when architecture.yaml is malformed."""
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ArchitectureConfig:
19
+ path: Path
20
+ modules: tuple[ArchitectureModule, ...]
21
+
22
+ def match_module(self, file_path: str) -> str | None:
23
+ normalized = file_path.replace("\\", "/")
24
+ matches = [
25
+ (len(pattern), module.module_id)
26
+ for module in self.modules
27
+ for pattern in module.paths
28
+ if _matches_path(normalized, pattern)
29
+ ]
30
+ return max(matches)[1] if matches else None
31
+
32
+
33
+ def load_architecture_config(path: Path) -> ArchitectureConfig:
34
+ try:
35
+ raw = yaml.safe_load(path.read_text(encoding="utf-8"))
36
+ except FileNotFoundError as exc:
37
+ raise ArchitectureConfigError(f"架构配置不存在:{path}") from exc
38
+ except yaml.YAMLError as exc:
39
+ raise ArchitectureConfigError(f"架构配置 YAML 无效:{exc}") from exc
40
+
41
+ if not isinstance(raw, dict):
42
+ raise ArchitectureConfigError("architecture.yaml 根节点必须是对象")
43
+ if raw.get("version") != 1:
44
+ raise ArchitectureConfigError("architecture.yaml version 必须为 1")
45
+ raw_modules = raw.get("modules")
46
+ if not isinstance(raw_modules, dict) or not raw_modules:
47
+ raise ArchitectureConfigError("architecture.yaml 至少需要一个 modules 节点")
48
+
49
+ modules: list[ArchitectureModule] = []
50
+ for module_id, value in raw_modules.items():
51
+ if not isinstance(module_id, str) or not isinstance(value, dict):
52
+ raise ArchitectureConfigError("modules 必须使用字符串 ID 和对象配置")
53
+ paths = _string_tuple(value.get("paths"), f"modules.{module_id}.paths", required=True)
54
+ modules.append(
55
+ ArchitectureModule(
56
+ module_id=module_id,
57
+ name=str(value.get("name") or module_id),
58
+ paths=paths,
59
+ public_paths=_string_tuple(value.get("public"), f"modules.{module_id}.public"),
60
+ may_depend_on=_string_tuple(
61
+ value.get("may_depend_on"),
62
+ f"modules.{module_id}.may_depend_on",
63
+ ),
64
+ owns_state=_string_tuple(
65
+ value.get("owns_state"),
66
+ f"modules.{module_id}.owns_state",
67
+ ),
68
+ )
69
+ )
70
+ return ArchitectureConfig(path=path.resolve(), modules=tuple(modules))
71
+
72
+
73
+ def write_draft_architecture(
74
+ destination: Path,
75
+ file_paths: tuple[str, ...],
76
+ ) -> Path:
77
+ modules: dict[str, dict[str, Any]] = {}
78
+ node_paths = sorted({_candidate_node(path) for path in file_paths})
79
+ for node_path in node_paths:
80
+ module_id = _unique_module_id(node_path, modules)
81
+ has_child_node = any(
82
+ other.startswith(node_path + "/") for other in node_paths
83
+ )
84
+ modules[module_id] = {
85
+ "name": _display_name(node_path),
86
+ "paths": [f"{node_path}/*" if has_child_node else f"{node_path}/**"],
87
+ "public": [],
88
+ "may_depend_on": [],
89
+ "owns_state": [],
90
+ }
91
+
92
+ document = {
93
+ "version": 1,
94
+ "status": "draft",
95
+ "modules": modules,
96
+ }
97
+ destination.parent.mkdir(parents=True, exist_ok=True)
98
+ destination.write_text(
99
+ yaml.safe_dump(document, allow_unicode=True, sort_keys=False),
100
+ encoding="utf-8",
101
+ )
102
+ return destination.resolve()
103
+
104
+
105
+ def default_node_for_path(file_path: str) -> str:
106
+ return _candidate_node(file_path)
107
+
108
+
109
+ def _candidate_node(file_path: str) -> str:
110
+ parts = file_path.replace("\\", "/").split("/")
111
+ if parts and parts[0] == "components" and len(parts) == 3:
112
+ return "/".join(parts[:2])
113
+ if parts and parts[0] == "components" and len(parts) > 3:
114
+ return "/".join(parts[: min(3, len(parts) - 1)])
115
+ if parts and parts[0] == "third_party" and len(parts) >= 2:
116
+ return "/".join(parts[:2])
117
+ if len(parts) >= 2:
118
+ return "/".join(parts[:2])
119
+ return parts[0] if parts else "unassigned"
120
+
121
+
122
+ def _unique_module_id(node_path: str, modules: dict[str, Any]) -> str:
123
+ base = node_path.split("/")[-1].replace("-", "_") or "module"
124
+ candidate = base
125
+ prefix_index = -2
126
+ while candidate in modules:
127
+ parts = node_path.split("/")
128
+ prefix = parts[prefix_index] if len(parts) >= abs(prefix_index) else "module"
129
+ candidate = f"{prefix}_{base}".replace("-", "_")
130
+ prefix_index -= 1
131
+ return candidate
132
+
133
+
134
+ def _display_name(node_path: str) -> str:
135
+ return node_path.split("/")[-1]
136
+
137
+
138
+ def _string_tuple(value: Any, field: str, required: bool = False) -> tuple[str, ...]:
139
+ if value is None and not required:
140
+ return ()
141
+ if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
142
+ raise ArchitectureConfigError(f"{field} 必须是字符串数组")
143
+ if required and not value:
144
+ raise ArchitectureConfigError(f"{field} 不能为空")
145
+ return tuple(value)
146
+
147
+
148
+ def _matches_path(path: str, pattern: str) -> bool:
149
+ token = "\0DOUBLE_STAR\0"
150
+ expression = re.escape(pattern.replace("**", token))
151
+ expression = expression.replace(re.escape(token), ".*")
152
+ expression = expression.replace(r"\*", "[^/]*")
153
+ expression = expression.replace(r"\?", "[^/]")
154
+ return re.fullmatch(expression, path) is not None