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.
- archcheck/__init__.py +4 -0
- archcheck/__main__.py +6 -0
- archcheck/analyzer.py +379 -0
- archcheck/architecture_config.py +154 -0
- archcheck/ast_facts.py +2601 -0
- archcheck/cli.py +287 -0
- archcheck/compile_commands.py +300 -0
- archcheck/concurrency.py +573 -0
- archcheck/contracts.py +219 -0
- archcheck/data_dependencies.py +171 -0
- archcheck/dependency.py +208 -0
- archcheck/dependency_order.py +440 -0
- archcheck/dsm_cluster.py +257 -0
- archcheck/file_metrics.py +89 -0
- archcheck/framework_rules.py +701 -0
- archcheck/gui.py +332 -0
- archcheck/image_facts.py +458 -0
- archcheck/indirection.py +611 -0
- archcheck/keil.py +6 -0
- archcheck/literals.py +54 -0
- archcheck/macro_facts.py +158 -0
- archcheck/model.py +1449 -0
- archcheck/parallel.py +140 -0
- archcheck/path_pruning.py +359 -0
- archcheck/paths.py +33 -0
- archcheck/profiles/generic/heuristics.yaml +31 -0
- archcheck/profiles/mcu/cortex-m-cmsis.yaml +28 -0
- archcheck/profiles/mcu/gd32f30x.yaml +17 -0
- archcheck/profiles/mcu/stm32-hal.yaml +23 -0
- archcheck/profiles/platform/arduino-esp32.yaml +40 -0
- archcheck/profiles/platform/esp-idf.yaml +54 -0
- archcheck/profiles/rtos/cmsis-rtos2.yaml +57 -0
- archcheck/profiles/rtos/freertos.yaml +123 -0
- archcheck/profiles/rtos/posix.yaml +37 -0
- archcheck/profiles/rtos/protothreads.yaml +43 -0
- archcheck/profiles/rtos/zephyr.yaml +61 -0
- archcheck/report.py +219 -0
- archcheck/runtime_units.py +595 -0
- archcheck/semantic.py +2729 -0
- archcheck/templates/report.html +908 -0
- archcheck/time_base.py +291 -0
- archcheck/toolchains/__init__.py +6 -0
- archcheck/toolchains/keil.py +521 -0
- archcheck/yield_locals.py +114 -0
- archcheck-0.26.0.dist-info/METADATA +172 -0
- archcheck-0.26.0.dist-info/RECORD +50 -0
- archcheck-0.26.0.dist-info/WHEEL +5 -0
- archcheck-0.26.0.dist-info/entry_points.txt +3 -0
- archcheck-0.26.0.dist-info/licenses/LICENSE +21 -0
- archcheck-0.26.0.dist-info/top_level.txt +1 -0
archcheck/__init__.py
ADDED
archcheck/__main__.py
ADDED
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
|