sourcecode 0.15.1__tar.gz → 0.17.0__tar.gz
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.
- {sourcecode-0.15.1 → sourcecode-0.17.0}/PKG-INFO +1 -1
- {sourcecode-0.15.1 → sourcecode-0.17.0}/docs/schema.md +2 -2
- {sourcecode-0.15.1 → sourcecode-0.17.0}/pyproject.toml +1 -1
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/__init__.py +1 -1
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/architecture_analyzer.py +141 -30
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/architecture_summary.py +28 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/cli.py +30 -3
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/code_notes_analyzer.py +25 -9
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/dependency_analyzer.py +45 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/scanner.py +4 -0
- sourcecode-0.17.0/src/sourcecode/serializer.py +352 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_architecture_analyzer.py +67 -0
- sourcecode-0.17.0/tests/test_code_notes_analyzer.py +137 -0
- sourcecode-0.17.0/tests/test_cross_consistency.py +365 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_dependency_analyzer_node_python.py +2 -2
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_integration_dependencies.py +1 -1
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_integration_graph_modules.py +8 -2
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_integration_lqn.py +6 -3
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_scanner.py +18 -0
- sourcecode-0.17.0/tests/test_schema_normalization.py +245 -0
- sourcecode-0.15.1/-o +0 -296
- sourcecode-0.15.1/src/sourcecode/serializer.py +0 -107
- {sourcecode-0.15.1 → sourcecode-0.17.0}/.gitignore +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/.ruff.toml +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/README.md +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/classifier.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/coverage_parser.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/__init__.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/base.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/dart.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/dotnet.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/elixir.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/go.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/heuristic.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/java.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/jvm_ext.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/nodejs.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/parsers.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/php.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/project.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/python.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/ruby.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/rust.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/systems.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/terraform.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/detectors/tooling.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/doc_analyzer.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/env_analyzer.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/git_analyzer.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/graph_analyzer.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/metrics_analyzer.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/prepare_context.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/redactor.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/schema.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/semantic_analyzer.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/summarizer.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/tree_utils.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/src/sourcecode/workspace.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/__init__.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/conftest.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/fixtures/coverage.xml +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/fixtures/fastapi_app/pyproject.toml +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/fixtures/fastapi_app/src/main.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/fixtures/go_service/cmd/api/main.go +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/fixtures/go_service/go.mod +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/fixtures/jacoco.xml +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/fixtures/lcov.info +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/fixtures/nextjs_app/app/page.tsx +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/fixtures/nextjs_app/package.json +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/fixtures/nextjs_app/pnpm-lock.yaml +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/fixtures/pnpm_monorepo/apps/web/app/page.tsx +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/fixtures/pnpm_monorepo/apps/web/package.json +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/fixtures/pnpm_monorepo/packages/api/main.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/fixtures/pnpm_monorepo/packages/api/pyproject.toml +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/fixtures/pnpm_monorepo/pnpm-workspace.yaml +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_architecture_summary.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_classifier.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_cli.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_coverage_parser.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_dependency_analyzer_polyglot.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_dependency_schema.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_detector_go_rust_java.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_detector_nodejs.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_detector_php_ruby_dart.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_detector_python.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_detector_universal_managed.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_detector_universal_systems.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_detectors_base.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_doc_analyzer_jsdom.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_doc_analyzer_python.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_graph_analyzer_polyglot.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_graph_analyzer_python_node.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_graph_schema.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_integration.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_integration_detection.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_integration_docs.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_integration_metrics.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_integration_multistack.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_integration_semantics.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_integration_universal.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_metrics_analyzer.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_packaging.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_real_projects.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_redactor.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_schema.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_semantic_analyzer_node.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_semantic_analyzer_python.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_semantic_import_resolution.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_semantic_schema.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_summarizer.py +0 -0
- {sourcecode-0.15.1 → sourcecode-0.17.0}/tests/test_workspace_analyzer.py +0 -0
|
@@ -81,7 +81,7 @@ Campos:
|
|
|
81
81
|
{
|
|
82
82
|
"schema_version": "1.0",
|
|
83
83
|
"generated_at": "2026-04-07T19:41:05.686277+00:00",
|
|
84
|
-
"sourcecode_version": "0.
|
|
84
|
+
"sourcecode_version": "0.17.0",
|
|
85
85
|
"analyzed_path": "/abs/path/to/project"
|
|
86
86
|
}
|
|
87
87
|
```
|
|
@@ -645,7 +645,7 @@ Ejemplo de salida para un monorepo con web Node.js y API Python con `--dependenc
|
|
|
645
645
|
"metadata": {
|
|
646
646
|
"schema_version": "1.0",
|
|
647
647
|
"generated_at": "2026-04-07T19:41:05.686277+00:00",
|
|
648
|
-
"sourcecode_version": "0.
|
|
648
|
+
"sourcecode_version": "0.17.0",
|
|
649
649
|
"analyzed_path": "/abs/path/to/project"
|
|
650
650
|
},
|
|
651
651
|
"file_tree": {
|
|
@@ -33,6 +33,37 @@ _CODE_EXTENSIONS = {
|
|
|
33
33
|
}
|
|
34
34
|
_GENERIC_NAMES = {"utils", "helpers", "common", "shared", "misc", "core", "root", ""}
|
|
35
35
|
|
|
36
|
+
_TEST_DIRS: frozenset[str] = frozenset({"tests", "test", "spec", "specs", "__tests__", "e2e"})
|
|
37
|
+
|
|
38
|
+
# Exact file stems that signal a specific architectural layer
|
|
39
|
+
_LAYER_STEM_EXACT: dict[str, str] = {
|
|
40
|
+
"cli": "orchestration",
|
|
41
|
+
"main": "orchestration",
|
|
42
|
+
"app": "orchestration",
|
|
43
|
+
"server": "orchestration",
|
|
44
|
+
"schema": "data",
|
|
45
|
+
"model": "data",
|
|
46
|
+
"models": "data",
|
|
47
|
+
"config": "data",
|
|
48
|
+
"settings": "data",
|
|
49
|
+
"store": "data",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# Suffix patterns (stem == suffix OR stem ends with "_" + suffix) → layer
|
|
53
|
+
_LAYER_STEM_SUFFIXES: list[tuple[str, str]] = [
|
|
54
|
+
("analyzer", "processing"),
|
|
55
|
+
("processor", "processing"),
|
|
56
|
+
("parser", "processing"),
|
|
57
|
+
("detector", "processing"),
|
|
58
|
+
("scanner", "processing"),
|
|
59
|
+
("handler", "orchestration"),
|
|
60
|
+
("controller", "orchestration"),
|
|
61
|
+
("repository", "data"),
|
|
62
|
+
("repo", "data"),
|
|
63
|
+
("serializer", "data"),
|
|
64
|
+
("formatter", "data"),
|
|
65
|
+
]
|
|
66
|
+
|
|
36
67
|
DOMAIN_ROLES: dict[str, str] = {
|
|
37
68
|
"controllers": "HTTP request handlers",
|
|
38
69
|
"handlers": "HTTP request handlers",
|
|
@@ -120,8 +151,9 @@ class ArchitectureAnalyzer:
|
|
|
120
151
|
|
|
121
152
|
# Overall confidence
|
|
122
153
|
confidence: Literal["high", "medium", "low"]
|
|
123
|
-
if
|
|
124
|
-
|
|
154
|
+
if pattern not in (None, "unknown", "flat"):
|
|
155
|
+
# A recognised pattern was detected — at least medium confidence
|
|
156
|
+
confidence = "high" if len(domains) >= 3 else "medium"
|
|
125
157
|
elif len(domains) >= 1:
|
|
126
158
|
confidence = "medium"
|
|
127
159
|
else:
|
|
@@ -165,7 +197,13 @@ class ArchitectureAnalyzer:
|
|
|
165
197
|
if len(parts) == 1:
|
|
166
198
|
return "root"
|
|
167
199
|
first = parts[0]
|
|
168
|
-
if first in _SRC_TRANSPARENT
|
|
200
|
+
if first in _SRC_TRANSPARENT:
|
|
201
|
+
if len(parts) == 2:
|
|
202
|
+
return "root"
|
|
203
|
+
if len(parts) >= 4:
|
|
204
|
+
# src/pkg/subpkg/file.py → subpkg is the meaningful module
|
|
205
|
+
return parts[2]
|
|
206
|
+
# src/pkg/file.py → pkg
|
|
169
207
|
return parts[1]
|
|
170
208
|
return first
|
|
171
209
|
|
|
@@ -173,6 +211,8 @@ class ArchitectureAnalyzer:
|
|
|
173
211
|
groups: dict[str, list[str]] = {}
|
|
174
212
|
for p in paths:
|
|
175
213
|
seg = self._extract_domain_segment(p)
|
|
214
|
+
if seg.lower() in _TEST_DIRS:
|
|
215
|
+
continue # tests are infrastructure, not architecture domains
|
|
176
216
|
groups.setdefault(seg, []).append(p)
|
|
177
217
|
|
|
178
218
|
domains: list[ArchitectureDomain] = []
|
|
@@ -191,12 +231,21 @@ class ArchitectureAnalyzer:
|
|
|
191
231
|
return domains
|
|
192
232
|
|
|
193
233
|
def _detect_layers(self, paths: list[str]) -> tuple[str, list[ArchitectureLayer]]:
|
|
234
|
+
# Exclude test paths so test directories don't skew layer scoring
|
|
235
|
+
source_paths = [
|
|
236
|
+
p for p in paths
|
|
237
|
+
if not any(part.lower() in _TEST_DIRS for part in p.replace("\\", "/").split("/"))
|
|
238
|
+
]
|
|
239
|
+
if not source_paths:
|
|
240
|
+
return "unknown", []
|
|
241
|
+
|
|
194
242
|
dir_names: set[str] = set()
|
|
195
|
-
for p in
|
|
243
|
+
for p in source_paths:
|
|
196
244
|
parts = p.replace("\\", "/").split("/")
|
|
197
245
|
for part in parts[:-1]:
|
|
198
246
|
dir_names.add(part.lower())
|
|
199
247
|
|
|
248
|
+
# 1. Classical keyword-based pattern matching (MVC, layered, hexagonal, fullstack)
|
|
200
249
|
best_pattern = ""
|
|
201
250
|
best_score = 0
|
|
202
251
|
best_matched: dict[str, list[str]] = {}
|
|
@@ -213,34 +262,96 @@ class ArchitectureAnalyzer:
|
|
|
213
262
|
best_pattern = pattern_name
|
|
214
263
|
best_matched = matched
|
|
215
264
|
|
|
216
|
-
if best_score
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
265
|
+
if best_score >= 2:
|
|
266
|
+
layer_confidence: Literal["high", "medium", "low"] = "high" if best_score >= 3 else "medium"
|
|
267
|
+
layers: list[ArchitectureLayer] = []
|
|
268
|
+
for layer_key, matched_dirs in best_matched.items():
|
|
269
|
+
matched_files = [
|
|
270
|
+
p for p in source_paths
|
|
271
|
+
if any(
|
|
272
|
+
seg.lower() in matched_dirs
|
|
273
|
+
for seg in p.replace("\\", "/").split("/")[:-1]
|
|
274
|
+
)
|
|
275
|
+
]
|
|
276
|
+
layers.append(ArchitectureLayer(
|
|
277
|
+
name=layer_key,
|
|
278
|
+
pattern=best_pattern,
|
|
279
|
+
files=matched_files,
|
|
280
|
+
confidence=layer_confidence,
|
|
281
|
+
))
|
|
282
|
+
return best_pattern, layers
|
|
283
|
+
|
|
284
|
+
# 2. Functional file-naming heuristic: *_analyzer.py, cli.py, schema.py, …
|
|
285
|
+
func_result = self._detect_layered_functional(source_paths)
|
|
286
|
+
if func_result is not None:
|
|
287
|
+
return func_result
|
|
288
|
+
|
|
289
|
+
# 3. Modular sub-package heuristic: ≥2 distinct named sub-packages
|
|
290
|
+
modular_result = self._detect_modular(source_paths)
|
|
291
|
+
if modular_result is not None:
|
|
292
|
+
return modular_result
|
|
293
|
+
|
|
294
|
+
# 4. Fallback: flat (shallow) vs truly unknown (deep but unrecognised)
|
|
295
|
+
max_depth = max(
|
|
296
|
+
(len(p.replace("\\", "/").split("/")) - 1 for p in source_paths),
|
|
297
|
+
default=0,
|
|
298
|
+
)
|
|
299
|
+
return ("flat" if max_depth <= 2 else "unknown"), []
|
|
300
|
+
|
|
301
|
+
def _detect_layered_functional(
|
|
302
|
+
self, paths: list[str]
|
|
303
|
+
) -> Optional[tuple[str, list[ArchitectureLayer]]]:
|
|
304
|
+
"""Detect layered architecture from file-naming conventions.
|
|
305
|
+
|
|
306
|
+
Recognises three logical layers without requiring classical directory names:
|
|
307
|
+
- orchestration: cli.py, main.py, *_handler.py, *_controller.py
|
|
308
|
+
- processing: *_analyzer.py, *_processor.py, *_parser.py, *_detector.py, *_scanner.py
|
|
309
|
+
- data: schema.py, model.py, *_repository.py, *_serializer.py, config.py, …
|
|
310
|
+
"""
|
|
311
|
+
layer_files: dict[str, list[str]] = {"orchestration": [], "processing": [], "data": []}
|
|
312
|
+
for p in paths:
|
|
313
|
+
stem = Path(p.replace("\\", "/").split("/")[-1]).stem.lower()
|
|
314
|
+
if stem in _LAYER_STEM_EXACT:
|
|
315
|
+
layer_files[_LAYER_STEM_EXACT[stem]].append(p)
|
|
316
|
+
continue
|
|
317
|
+
for suffix, layer in _LAYER_STEM_SUFFIXES:
|
|
318
|
+
if stem == suffix or stem.endswith("_" + suffix):
|
|
319
|
+
layer_files[layer].append(p)
|
|
320
|
+
break
|
|
321
|
+
|
|
322
|
+
non_empty = {k: v for k, v in layer_files.items() if v}
|
|
323
|
+
if len(non_empty) >= 2:
|
|
324
|
+
return "layered", [
|
|
325
|
+
ArchitectureLayer(name=k, pattern="layered", files=v, confidence="medium")
|
|
326
|
+
for k, v in non_empty.items()
|
|
235
327
|
]
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
328
|
+
return None
|
|
329
|
+
|
|
330
|
+
def _detect_modular(
|
|
331
|
+
self, paths: list[str]
|
|
332
|
+
) -> Optional[tuple[str, list[ArchitectureLayer]]]:
|
|
333
|
+
"""Detect modular architecture from ≥2 distinct named sub-packages.
|
|
242
334
|
|
|
243
|
-
|
|
335
|
+
Each top-level (or first non-transparent) directory that is not a test or
|
|
336
|
+
generic name is treated as a self-contained module.
|
|
337
|
+
"""
|
|
338
|
+
module_files: dict[str, list[str]] = {}
|
|
339
|
+
for p in paths:
|
|
340
|
+
parts = p.replace("\\", "/").split("/")
|
|
341
|
+
for part in parts[:-1]:
|
|
342
|
+
if (part not in _SRC_TRANSPARENT
|
|
343
|
+
and part.lower() not in _TEST_DIRS
|
|
344
|
+
and part.lower() not in _GENERIC_NAMES):
|
|
345
|
+
module_files.setdefault(part, []).append(p)
|
|
346
|
+
break
|
|
347
|
+
|
|
348
|
+
meaningful = {k: v for k, v in module_files.items() if len(v) >= 2}
|
|
349
|
+
if len(meaningful) >= 2:
|
|
350
|
+
return "modular", [
|
|
351
|
+
ArchitectureLayer(name=k, pattern="modular", files=v, confidence="medium")
|
|
352
|
+
for k, v in meaningful.items()
|
|
353
|
+
]
|
|
354
|
+
return None
|
|
244
355
|
|
|
245
356
|
def _infer_bounded_contexts(
|
|
246
357
|
self,
|
|
@@ -12,6 +12,7 @@ _TOOLING_PREFIXES = (".claude/", ".vscode/", "bin/")
|
|
|
12
12
|
_PYTHON_EXTENSIONS = {".py"}
|
|
13
13
|
_NODE_EXTENSIONS = {".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"}
|
|
14
14
|
_GO_EXTENSIONS = {".go"}
|
|
15
|
+
_JAVA_EXTENSIONS = {".java", ".kt", ".scala"}
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
class ArchitectureSummarizer:
|
|
@@ -58,6 +59,8 @@ class ArchitectureSummarizer:
|
|
|
58
59
|
lines.extend(self._summarize_node_entry(entry_point.path, content))
|
|
59
60
|
elif suffix in _GO_EXTENSIONS:
|
|
60
61
|
lines.extend(self._summarize_go_entry(entry_point.path, content))
|
|
62
|
+
elif suffix in _JAVA_EXTENSIONS:
|
|
63
|
+
lines.extend(self._summarize_java_entry(entry_point.path, content, sm.stacks))
|
|
61
64
|
else:
|
|
62
65
|
lines.append("Orquesta modulos internos no detallados por el analisis estatico disponible.")
|
|
63
66
|
|
|
@@ -122,6 +125,21 @@ class ArchitectureSummarizer:
|
|
|
122
125
|
lines.append("Produce la salida principal del entry point JavaScript/TypeScript detectado.")
|
|
123
126
|
return lines
|
|
124
127
|
|
|
128
|
+
def _summarize_java_entry(self, path: str, content: str, stacks: list[StackDetection]) -> list[str]:
|
|
129
|
+
lines: list[str] = []
|
|
130
|
+
frameworks = [f.name for stack in stacks for f in stack.frameworks]
|
|
131
|
+
if frameworks:
|
|
132
|
+
lines.append(f"Frameworks detectados: {', '.join(frameworks)}.")
|
|
133
|
+
annotations = re.findall(r"@(SpringBootApplication|QuarkusMain|MicronautApplication|Application)\b", content)
|
|
134
|
+
if annotations:
|
|
135
|
+
lines.append(f"Anotacion de arranque: @{annotations[0]}.")
|
|
136
|
+
# Detect Spring Boot profile hints
|
|
137
|
+
if "@SpringBootApplication" in content:
|
|
138
|
+
lines.append("Arranca el contexto de Spring con auto-configuracion y component scan.")
|
|
139
|
+
elif not lines:
|
|
140
|
+
lines.append("Orquesta el arranque de la aplicacion JVM.")
|
|
141
|
+
return lines
|
|
142
|
+
|
|
125
143
|
def _summarize_go_entry(self, path: str, content: str) -> list[str]:
|
|
126
144
|
imports = re.findall(r'"([^"]+)"', content)
|
|
127
145
|
internal = [module for module in imports if not module.startswith(("fmt", "net/", "os", "context"))]
|
|
@@ -196,6 +214,16 @@ class ArchitectureSummarizer:
|
|
|
196
214
|
confidence="medium",
|
|
197
215
|
)
|
|
198
216
|
)
|
|
217
|
+
elif path.endswith("Application.java") or path.endswith("Main.java"):
|
|
218
|
+
candidates.append(
|
|
219
|
+
EntryPoint(
|
|
220
|
+
path=path,
|
|
221
|
+
stack="java",
|
|
222
|
+
kind="application",
|
|
223
|
+
source="convention",
|
|
224
|
+
confidence="medium",
|
|
225
|
+
)
|
|
226
|
+
)
|
|
199
227
|
return candidates
|
|
200
228
|
|
|
201
229
|
def _fallback_priority(self, path: str) -> tuple[int, int, str]:
|
|
@@ -202,12 +202,26 @@ def main(
|
|
|
202
202
|
SourceMap,
|
|
203
203
|
StackDetection,
|
|
204
204
|
)
|
|
205
|
-
from sourcecode.serializer import compact_view, write_output
|
|
205
|
+
from sourcecode.serializer import compact_view, normalize_source_map, validate_cross_analyzer_consistency, validate_source_map, write_output
|
|
206
206
|
from sourcecode.workspace import WorkspaceAnalyzer
|
|
207
207
|
|
|
208
208
|
# 1. Escanear el directorio (SCAN-01 a SCAN-05)
|
|
209
209
|
redactor = SecretRedactor(enabled=not no_redact)
|
|
210
|
-
|
|
210
|
+
|
|
211
|
+
# Detectar manifests antes del scan para ajustar depth.
|
|
212
|
+
# find_manifests() solo mira profundidad 0-1, no necesita el arbol.
|
|
213
|
+
_pre_scanner = FileScanner(target, max_depth=1)
|
|
214
|
+
manifests = _pre_scanner.find_manifests()
|
|
215
|
+
|
|
216
|
+
# Maven usa src/main/java/<groupId>/<artifactId>/<module>/ (profundidad 7+).
|
|
217
|
+
# Con depth=4 los ficheros .java son invisibles y todos los analizadores fallan.
|
|
218
|
+
# Necesitamos al menos 8: src(1)+main(2)+java(3)+com(4)+co(5)+app(6)+module(7)+file.
|
|
219
|
+
_java_manifest_names = {"pom.xml", "build.gradle", "build.gradle.kts"}
|
|
220
|
+
_is_java = any(Path(m).name in _java_manifest_names for m in manifests)
|
|
221
|
+
_java_min_depth = 8
|
|
222
|
+
effective_depth = max(depth, _java_min_depth) if _is_java and depth < _java_min_depth else depth
|
|
223
|
+
|
|
224
|
+
scanner = FileScanner(target, max_depth=effective_depth)
|
|
211
225
|
raw_tree = scanner.scan_tree()
|
|
212
226
|
|
|
213
227
|
# 2. Filtrar del arbol las entradas de .env y *.secret (SEC-02, todos los niveles)
|
|
@@ -244,7 +258,6 @@ def main(
|
|
|
244
258
|
return pruned
|
|
245
259
|
|
|
246
260
|
file_tree = filter_sensitive_files(raw_tree)
|
|
247
|
-
manifests = scanner.find_manifests()
|
|
248
261
|
detector = ProjectDetector(build_default_detectors())
|
|
249
262
|
workspace_analysis = WorkspaceAnalyzer().analyze(target, manifests)
|
|
250
263
|
dependency_analyzer = DependencyAnalyzer() if dependencies else None
|
|
@@ -557,6 +570,20 @@ def main(
|
|
|
557
570
|
cn_notes, cn_adrs, cn_summary = CodeNotesAnalyzer().analyze(target)
|
|
558
571
|
sm = replace(sm, code_notes=cn_notes, code_adrs=cn_adrs, code_notes_summary=cn_summary)
|
|
559
572
|
|
|
573
|
+
# Normalize optional analyzer outputs → validate schema contracts.
|
|
574
|
+
# normalize_source_map fills None fields with typed empty defaults so that
|
|
575
|
+
# consumers never need to null-check architecture or module_graph.
|
|
576
|
+
# validate_source_map then asserts the contracts hold; it raises here
|
|
577
|
+
# (pre-serialization) rather than silently producing invalid JSON.
|
|
578
|
+
sm = normalize_source_map(sm)
|
|
579
|
+
validate_source_map(sm)
|
|
580
|
+
|
|
581
|
+
# Cross-analyzer semantic consistency (non-blocking: warnings to stderr).
|
|
582
|
+
# strict=False so a mismatched dependency or orphan semantic link never
|
|
583
|
+
# aborts a run — findings are informational until the team decides to harden.
|
|
584
|
+
for _finding in validate_cross_analyzer_consistency(sm, strict=False):
|
|
585
|
+
typer.echo(f"[consistency] {_finding}", err=True)
|
|
586
|
+
|
|
560
587
|
# 4. Serializar (con o sin modo compact)
|
|
561
588
|
if compact:
|
|
562
589
|
data = compact_view(sm)
|
|
@@ -5,6 +5,11 @@ from collections import Counter
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
from typing import Optional
|
|
7
7
|
|
|
8
|
+
# Schema imports are hoisted here (not lazily inside _scan_source_file) so that
|
|
9
|
+
# a stale .pyc / editable-install mismatch is caught at import time rather than
|
|
10
|
+
# silently dropping notes mid-scan on the first failed late-import.
|
|
11
|
+
from sourcecode.schema import AdrRecord, CodeNote, CodeNotesSummary
|
|
12
|
+
|
|
8
13
|
_MAX_NOTES = 500
|
|
9
14
|
_MAX_NOTES_PER_FILE = 30
|
|
10
15
|
_MAX_ADRS = 50
|
|
@@ -103,7 +108,6 @@ def _scan_source_file(
|
|
|
103
108
|
line_num = content.count("\n", 0, m.start()) + 1
|
|
104
109
|
symbol = _find_nearby_symbol(lines, line_num - 1)
|
|
105
110
|
|
|
106
|
-
from sourcecode.schema import CodeNote
|
|
107
111
|
notes.append(CodeNote(
|
|
108
112
|
kind=kind,
|
|
109
113
|
path=rel_path,
|
|
@@ -121,8 +125,6 @@ def _parse_adr(path: Path, rel_path: str) -> Optional[object]:
|
|
|
121
125
|
except OSError:
|
|
122
126
|
return None
|
|
123
127
|
|
|
124
|
-
from sourcecode.schema import AdrRecord
|
|
125
|
-
|
|
126
128
|
title: Optional[str] = None
|
|
127
129
|
status: Optional[str] = None
|
|
128
130
|
summary_lines: list[str] = []
|
|
@@ -167,15 +169,13 @@ def _parse_adr(path: Path, rel_path: str) -> Optional[object]:
|
|
|
167
169
|
if not title:
|
|
168
170
|
title = path.stem
|
|
169
171
|
|
|
170
|
-
return AdrRecord(path=rel_path, title=title, status=status, summary=summary)
|
|
172
|
+
return AdrRecord(path=rel_path, title=title, status=status, summary=summary) # noqa: RET504
|
|
171
173
|
|
|
172
174
|
|
|
173
175
|
class CodeNotesAnalyzer:
|
|
174
176
|
"""Extrae notas de código (TODO/FIXME/HACK/etc.) y ADRs del proyecto."""
|
|
175
177
|
|
|
176
178
|
def analyze(self, root: Path) -> tuple[list, list, object]:
|
|
177
|
-
from sourcecode.schema import CodeNotesSummary
|
|
178
|
-
|
|
179
179
|
notes: list = []
|
|
180
180
|
adrs: list = []
|
|
181
181
|
limitations: list[str] = []
|
|
@@ -186,9 +186,19 @@ class CodeNotesAnalyzer:
|
|
|
186
186
|
if total_count[0] >= _MAX_NOTES:
|
|
187
187
|
limitations.append(f"notes_truncated_at:{_MAX_NOTES}")
|
|
188
188
|
|
|
189
|
+
# Explicit canonical sort guarantees identical output for identical input
|
|
190
|
+
# regardless of filesystem traversal order or OS/platform differences.
|
|
191
|
+
# Without this, output order depended on sorted(Path.iterdir()) which
|
|
192
|
+
# varies across Python versions, APFS vs ext4, and future walk refactors.
|
|
193
|
+
notes.sort(key=lambda n: (n.path, n.line))
|
|
194
|
+
adrs.sort(key=lambda a: a.path)
|
|
195
|
+
|
|
189
196
|
kind_counts: Counter = Counter(n.kind for n in notes)
|
|
190
197
|
file_counts: Counter = Counter(n.path for n in notes)
|
|
191
|
-
|
|
198
|
+
# Secondary sort by path makes top_files stable when counts are tied.
|
|
199
|
+
top_files = [
|
|
200
|
+
f for f, _ in sorted(file_counts.items(), key=lambda x: (-x[1], x[0]))[:5]
|
|
201
|
+
]
|
|
192
202
|
|
|
193
203
|
summary = CodeNotesSummary(
|
|
194
204
|
requested=True,
|
|
@@ -211,7 +221,10 @@ class CodeNotesAnalyzer:
|
|
|
211
221
|
) -> None:
|
|
212
222
|
try:
|
|
213
223
|
entries = sorted(current.iterdir())
|
|
214
|
-
except
|
|
224
|
+
except OSError:
|
|
225
|
+
# Catches PermissionError, EMFILE (too many open files), and other
|
|
226
|
+
# OS-level errors that previously aborted the entire walk mid-scan,
|
|
227
|
+
# returning an empty or partial list depending on when the error hit.
|
|
215
228
|
return
|
|
216
229
|
|
|
217
230
|
for entry in entries:
|
|
@@ -237,5 +250,8 @@ class CodeNotesAnalyzer:
|
|
|
237
250
|
adrs.append(adr)
|
|
238
251
|
continue
|
|
239
252
|
|
|
240
|
-
|
|
253
|
+
# The quota check lives inside _scan_source_file; the pre-check
|
|
254
|
+
# here was redundant and caused files to be silently skipped when
|
|
255
|
+
# traversal order varied (different files filled the quota first).
|
|
256
|
+
if suffix in _CODE_EXTENSIONS:
|
|
241
257
|
_scan_source_file(entry, rel, notes, total_count)
|
|
@@ -33,6 +33,7 @@ class DependencyAnalyzer:
|
|
|
33
33
|
self._analyze_rust,
|
|
34
34
|
self._analyze_go,
|
|
35
35
|
self._analyze_dotnet,
|
|
36
|
+
self._analyze_java,
|
|
36
37
|
):
|
|
37
38
|
handler_records, handler_limitations = handler(root)
|
|
38
39
|
records.extend(replace(record, workspace=workspace) for record in handler_records)
|
|
@@ -920,6 +921,50 @@ class DependencyAnalyzer:
|
|
|
920
921
|
result.append(resolved)
|
|
921
922
|
return self._dedupe(result)
|
|
922
923
|
|
|
924
|
+
def _analyze_java(self, root: Path) -> tuple[list[DependencyRecord], list[str]]:
|
|
925
|
+
pom = root / "pom.xml"
|
|
926
|
+
if not pom.exists():
|
|
927
|
+
return [], []
|
|
928
|
+
try:
|
|
929
|
+
tree = ET.parse(pom)
|
|
930
|
+
except (ET.ParseError, OSError):
|
|
931
|
+
return [], ["java: error al parsear pom.xml"]
|
|
932
|
+
|
|
933
|
+
root_elem = tree.getroot()
|
|
934
|
+
ns_match = re.match(r"\{[^}]+\}", root_elem.tag)
|
|
935
|
+
ns = ns_match.group(0) if ns_match else ""
|
|
936
|
+
|
|
937
|
+
records: list[DependencyRecord] = []
|
|
938
|
+
deps_elem = root_elem.find(f"{ns}dependencies")
|
|
939
|
+
if deps_elem is None:
|
|
940
|
+
return [], ["java: pom.xml sin bloque <dependencies>"]
|
|
941
|
+
|
|
942
|
+
for dep in deps_elem.findall(f"{ns}dependency"):
|
|
943
|
+
group_id = (dep.findtext(f"{ns}groupId") or "").strip()
|
|
944
|
+
artifact_id = (dep.findtext(f"{ns}artifactId") or "").strip()
|
|
945
|
+
if not group_id or not artifact_id:
|
|
946
|
+
continue
|
|
947
|
+
version_raw = (dep.findtext(f"{ns}version") or "").strip() or None
|
|
948
|
+
# Versiones interpoladas con propiedades Maven (${...}) no son resolvibles estáticamente
|
|
949
|
+
declared = version_raw if version_raw and not version_raw.startswith("${") else None
|
|
950
|
+
scope_text = (dep.findtext(f"{ns}scope") or "compile").strip().lower()
|
|
951
|
+
scope = "dev" if scope_text == "test" else "direct"
|
|
952
|
+
records.append(
|
|
953
|
+
DependencyRecord(
|
|
954
|
+
name=f"{group_id}:{artifact_id}",
|
|
955
|
+
ecosystem="java",
|
|
956
|
+
scope=scope,
|
|
957
|
+
declared_version=declared,
|
|
958
|
+
source="manifest",
|
|
959
|
+
manifest_path="pom.xml",
|
|
960
|
+
)
|
|
961
|
+
)
|
|
962
|
+
|
|
963
|
+
limitations: list[str] = []
|
|
964
|
+
if not records:
|
|
965
|
+
limitations.append("java: pom.xml sin dependencias parseables (puede usar BOM o propiedades)")
|
|
966
|
+
return records, limitations
|
|
967
|
+
|
|
923
968
|
def _load_yaml_file(self, path: Path) -> Optional[dict[str, Any]]:
|
|
924
969
|
if not path.exists():
|
|
925
970
|
return None
|
|
@@ -128,6 +128,10 @@ class FileScanner:
|
|
|
128
128
|
|
|
129
129
|
# Agregar ficheros al nodo (null = fichero segun D-02)
|
|
130
130
|
for fname in filenames:
|
|
131
|
+
# Skip flag-shaped names (e.g. "-o", "--format") — shell redirect artifacts.
|
|
132
|
+
# No legitimate source file starts with "-".
|
|
133
|
+
if fname.startswith("-"):
|
|
134
|
+
continue
|
|
131
135
|
fpath = current / fname
|
|
132
136
|
# SCAN-03: no incluir symlinks de fichero
|
|
133
137
|
if fpath.is_symlink():
|