compass-code 0.1.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.
- compass_cli/__init__.py +0 -0
- compass_cli/commands/__init__.py +0 -0
- compass_cli/commands/ingest.py +310 -0
- compass_cli/commands/mcp.py +25 -0
- compass_cli/commands/query.py +181 -0
- compass_cli/commands/serve.py +123 -0
- compass_cli/commands/setup.py +171 -0
- compass_cli/display.py +231 -0
- compass_cli/errors.py +30 -0
- compass_cli/main.py +54 -0
- compass_cli/progress.py +191 -0
- compass_cli/result_views.py +389 -0
- compass_cli/types.py +41 -0
- compass_code-0.1.0.dist-info/METADATA +72 -0
- compass_code-0.1.0.dist-info/RECORD +137 -0
- compass_code-0.1.0.dist-info/WHEEL +4 -0
- compass_code-0.1.0.dist-info/entry_points.txt +2 -0
- compass_core/__init__.py +1 -0
- compass_core/contract/__init__.py +6 -0
- compass_core/contract/errors.py +116 -0
- compass_core/contract/models.py +204 -0
- compass_core/contract/params.py +99 -0
- compass_core/contract/results.py +370 -0
- compass_core/contract/serialize.py +59 -0
- compass_core/contract/types.py +76 -0
- compass_core/errors.py +148 -0
- compass_core/graph/__init__.py +0 -0
- compass_core/graph/db.py +205 -0
- compass_core/graph/schema.py +165 -0
- compass_core/graph/stored_ids.py +133 -0
- compass_core/graph/writer.py +482 -0
- compass_core/index/__init__.py +1 -0
- compass_core/index/freshness.py +55 -0
- compass_core/index/locate.py +155 -0
- compass_core/index/metadata.py +107 -0
- compass_core/ir/__init__.py +0 -0
- compass_core/ir/graph.py +65 -0
- compass_core/ir/models.py +75 -0
- compass_core/languages/__init__.py +1 -0
- compass_core/languages/base.py +26 -0
- compass_core/languages/python/__init__.py +6 -0
- compass_core/languages/python/extractor.py +670 -0
- compass_core/languages/python/import_resolver.py +107 -0
- compass_core/languages/registry.py +89 -0
- compass_core/logger.py +42 -0
- compass_core/phases/__init__.py +0 -0
- compass_core/phases/base.py +58 -0
- compass_core/phases/discovery.py +167 -0
- compass_core/phases/parse.py +80 -0
- compass_core/phases/persistence.py +33 -0
- compass_core/phases/pipeline.py +152 -0
- compass_core/phases/resolution.py +572 -0
- compass_core/phases/symbol_table.py +36 -0
- compass_core/phases/validation.py +76 -0
- compass_core/progress.py +45 -0
- compass_core/py.typed +0 -0
- compass_core/query/__init__.py +1 -0
- compass_core/query/cypher.py +92 -0
- compass_core/query/detail.py +241 -0
- compass_core/query/graph_map.py +127 -0
- compass_core/query/impact.py +273 -0
- compass_core/query/impact_walk.py +352 -0
- compass_core/query/overview.py +221 -0
- compass_core/query/references.py +235 -0
- compass_core/query/rows.py +378 -0
- compass_core/query/search.py +183 -0
- compass_core/query/status.py +49 -0
- compass_core/query/walk_queries.py +202 -0
- compass_core/render.py +408 -0
- compass_core/settings.py +61 -0
- compass_core/shared_types.py +78 -0
- compass_mcp/__init__.py +0 -0
- compass_mcp/guide.py +45 -0
- compass_mcp/py.typed +0 -0
- compass_mcp/server.py +261 -0
- compass_server/__init__.py +5 -0
- compass_server/errors.py +84 -0
- compass_server/main.py +42 -0
- compass_server/py.typed +0 -0
- compass_server/routers/__init__.py +0 -0
- compass_server/routers/query.py +182 -0
- compass_server/settings.py +37 -0
- compass_server/static/404.html +1 -0
- compass_server/static/_next/static/chunks/1629039a.eed2a06658ec4f9c.js +1 -0
- compass_server/static/_next/static/chunks/1ff282bf-bb7c483245264fc9.js +1 -0
- compass_server/static/_next/static/chunks/223.f881a22f446259bf.js +508 -0
- compass_server/static/_next/static/chunks/51fa5c19.6396973222c9d6fd.js +485 -0
- compass_server/static/_next/static/chunks/556.443c1682b6b20128.js +1 -0
- compass_server/static/_next/static/chunks/684-1aa0170f59baf77e.js +1 -0
- compass_server/static/_next/static/chunks/92e71660.3a42d4f70d017951.js +342 -0
- compass_server/static/_next/static/chunks/937-8692224bb6d1b605.js +1 -0
- compass_server/static/_next/static/chunks/app/_not-found/page-2c96ddf295eb6a86.js +1 -0
- compass_server/static/_next/static/chunks/app/layout-4bd0ba4a7d18aff9.js +1 -0
- compass_server/static/_next/static/chunks/app/page-5376a0a98bbd1faa.js +1 -0
- compass_server/static/_next/static/chunks/framework-96dcd958cfe08bd4.js +1 -0
- compass_server/static/_next/static/chunks/main-6dee8e626b16731a.js +1 -0
- compass_server/static/_next/static/chunks/main-app-460e8bc670818718.js +1 -0
- compass_server/static/_next/static/chunks/pages/_app-638a3e7363f92b80.js +1 -0
- compass_server/static/_next/static/chunks/pages/_error-6a72dbbb45cae743.js +1 -0
- compass_server/static/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- compass_server/static/_next/static/chunks/webpack-597057bf48ce4111.js +1 -0
- compass_server/static/_next/static/css/1b0fbfd8dd653f37.css +3 -0
- compass_server/static/_next/static/ctUZeD9K7jQUgLqmBas2L/_buildManifest.js +1 -0
- compass_server/static/_next/static/ctUZeD9K7jQUgLqmBas2L/_ssgManifest.js +1 -0
- compass_server/static/_next/static/media/0086f8992871c45b-s.woff2 +0 -0
- compass_server/static/_next/static/media/034d78ad42e9620c-s.woff2 +0 -0
- compass_server/static/_next/static/media/03b436aa846269de-s.woff2 +0 -0
- compass_server/static/_next/static/media/0484562807a97172-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/08aedeceaf1dcd57-s.woff2 +0 -0
- compass_server/static/_next/static/media/0aa834ed78bf6d07-s.woff2 +0 -0
- compass_server/static/_next/static/media/29a4aea02fdee119-s.woff2 +0 -0
- compass_server/static/_next/static/media/29e7bbdce9332268-s.woff2 +0 -0
- compass_server/static/_next/static/media/36008747766f78c6-s.woff2 +0 -0
- compass_server/static/_next/static/media/39969fcf98a3026e-s.woff2 +0 -0
- compass_server/static/_next/static/media/4de1fea1a954a5b6-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/5c0c2bcbaa4149ca-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/67957d42bae0796d-s.woff2 +0 -0
- compass_server/static/_next/static/media/6d38f9d4e0f4772b-s.woff2 +0 -0
- compass_server/static/_next/static/media/6d664cce900333ee-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/886030b0b59bc5a7-s.woff2 +0 -0
- compass_server/static/_next/static/media/8888a3826f4a3af4-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/939c4f875ee75fbb-s.woff2 +0 -0
- compass_server/static/_next/static/media/a1386beebedccca4-s.woff2 +0 -0
- compass_server/static/_next/static/media/ab00a911ac2adf48-s.woff2 +0 -0
- compass_server/static/_next/static/media/b957ea75a84b6ea7-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/bb3ef058b751a6ad-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/c3bc380753a8436c-s.woff2 +0 -0
- compass_server/static/_next/static/media/cce080f35d014443-s.woff2 +0 -0
- compass_server/static/_next/static/media/db911767852bc875-s.woff2 +0 -0
- compass_server/static/_next/static/media/eafabf029ad39a43-s.p.woff2 +0 -0
- compass_server/static/_next/static/media/f10b8e9d91f3edcb-s.woff2 +0 -0
- compass_server/static/_next/static/media/f911b923c6adde36-s.woff2 +0 -0
- compass_server/static/_next/static/media/fe0777f1195381cb-s.woff2 +0 -0
- compass_server/static/index.html +1 -0
- compass_server/static/index.txt +25 -0
- compass_server/web_bundle.py +27 -0
- compass_server/web_limits.py +48 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Python import resolution: relative imports, packages, __init__.py.
|
|
2
|
+
|
|
3
|
+
Moved from resolver/import_map.py, adapted to the ImportResolver Protocol
|
|
4
|
+
and IR types. Relative imports (`.user`, `..utils`) are resolved against the
|
|
5
|
+
importing file's own package, per Python's relative-import level rules:
|
|
6
|
+
one leading dot means "this file's package", each additional dot walks up
|
|
7
|
+
one more parent package. A package's `__init__.py` *is* its package, so its
|
|
8
|
+
own leading-dot imports resolve relative to its own module_name rather than
|
|
9
|
+
a parent of it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from compass_core.ir.models import ParsedFile
|
|
15
|
+
from compass_core.shared_types import Language
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PythonImportResolver:
|
|
19
|
+
language_id = Language.PYTHON
|
|
20
|
+
|
|
21
|
+
def resolve(self, files: list[ParsedFile]) -> dict[str, dict[str, str | None]]:
|
|
22
|
+
module_to_path = _module_index(files)
|
|
23
|
+
return {pf.path: _resolve_file_imports(pf, module_to_path) for pf in files}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _module_index(files: list[ParsedFile]) -> dict[str, str]:
|
|
27
|
+
"""Build a mapping of module names to their file paths."""
|
|
28
|
+
return {pf.module_name: pf.path for pf in files}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _resolve_file_imports(pf: ParsedFile, module_to_path: dict[str, str]) -> dict[str, str | None]:
|
|
32
|
+
"""Resolve all imports in a single file to their source file paths."""
|
|
33
|
+
mapping: dict[str, str | None] = {}
|
|
34
|
+
for imp in pf.imports:
|
|
35
|
+
resolved = _resolve_module(imp.module, pf, module_to_path)
|
|
36
|
+
if imp.names:
|
|
37
|
+
# `from X import a, b, *` — each name gets its own entry
|
|
38
|
+
for name in imp.names:
|
|
39
|
+
local = imp.alias if (len(imp.names) == 1 and imp.alias) else name
|
|
40
|
+
target = resolved
|
|
41
|
+
if imp.is_relative and not imp.module.lstrip("."):
|
|
42
|
+
# `from . import name` / `from .. import name` — each
|
|
43
|
+
# imported name is usually a submodule of the base
|
|
44
|
+
# package (try that first); fall back to the generic
|
|
45
|
+
# resolution (the base package's own file) for names
|
|
46
|
+
# that are attributes defined in its __init__.py itself.
|
|
47
|
+
target = (
|
|
48
|
+
_resolve_bare_relative(imp.module, name, pf, module_to_path) or resolved
|
|
49
|
+
)
|
|
50
|
+
mapping[local] = target
|
|
51
|
+
else:
|
|
52
|
+
# `import X` or `import X as Y`
|
|
53
|
+
local = imp.alias or imp.module.split(".")[0]
|
|
54
|
+
mapping[local] = resolved
|
|
55
|
+
return mapping
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _resolve_module(
|
|
59
|
+
module: str, importer: ParsedFile, module_to_path: dict[str, str]
|
|
60
|
+
) -> str | None:
|
|
61
|
+
"""Resolve a dotted module name to an absolute file path.
|
|
62
|
+
|
|
63
|
+
Returns the file path if the module is internal to the repo, or None
|
|
64
|
+
if it is an external dependency.
|
|
65
|
+
"""
|
|
66
|
+
if not module.startswith("."):
|
|
67
|
+
return module_to_path.get(module)
|
|
68
|
+
|
|
69
|
+
base_parts = _base_package_parts(module, importer)
|
|
70
|
+
remainder = module.lstrip(".")
|
|
71
|
+
target_parts = base_parts + remainder.split(".") if remainder else base_parts
|
|
72
|
+
candidate = ".".join(part for part in target_parts if part)
|
|
73
|
+
return module_to_path.get(candidate)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _base_package_parts(module: str, importer: ParsedFile) -> list[str]:
|
|
77
|
+
"""Compute the dotted package an import's leading dots are relative to.
|
|
78
|
+
|
|
79
|
+
One dot means "the importing file's own package". A package's
|
|
80
|
+
`__init__.py` *is* that package, so its own module_name is the base;
|
|
81
|
+
any other file's base is its module_name with the last (leaf module)
|
|
82
|
+
segment stripped. Each dot beyond the first walks up one more level.
|
|
83
|
+
"""
|
|
84
|
+
level = len(module) - len(module.lstrip("."))
|
|
85
|
+
is_package_init = importer.path.endswith("__init__.py") or importer.path.endswith(
|
|
86
|
+
"__init__.pyi"
|
|
87
|
+
)
|
|
88
|
+
parts = importer.module_name.split(".") if importer.module_name else []
|
|
89
|
+
if not is_package_init:
|
|
90
|
+
parts = parts[:-1]
|
|
91
|
+
climb = level - 1
|
|
92
|
+
if climb:
|
|
93
|
+
parts = parts[:-climb] if climb <= len(parts) else []
|
|
94
|
+
return parts
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _resolve_bare_relative(
|
|
98
|
+
module: str, name: str, importer: ParsedFile, module_to_path: dict[str, str]
|
|
99
|
+
) -> str | None:
|
|
100
|
+
"""Resolve one name from `from . import name` / `from .. import name`.
|
|
101
|
+
|
|
102
|
+
Each name here is a submodule of the base package rather than an
|
|
103
|
+
attribute of a single target module, so it must be resolved per-name.
|
|
104
|
+
"""
|
|
105
|
+
base_parts = _base_package_parts(module, importer)
|
|
106
|
+
candidate = ".".join([*base_parts, name])
|
|
107
|
+
return module_to_path.get(candidate)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Extension/language-id -> plugin lookup. No phase past Phase 1 may know a
|
|
2
|
+
specific language — this module is the only place that maps a file
|
|
3
|
+
extension to a concrete LanguageExtractor."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from compass_core.errors import UnsupportedLanguageError
|
|
8
|
+
from compass_core.languages.base import ImportResolver, LanguageExtractor
|
|
9
|
+
from compass_core.shared_types import Language
|
|
10
|
+
|
|
11
|
+
_extractors_by_extension: dict[str, LanguageExtractor] = {}
|
|
12
|
+
_extractors_by_language: dict[Language, LanguageExtractor] = {}
|
|
13
|
+
_import_resolvers_by_language: dict[Language, ImportResolver] = {}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def register_extractor(extractor: LanguageExtractor) -> None:
|
|
17
|
+
for ext in extractor.file_extensions:
|
|
18
|
+
_extractors_by_extension[ext] = extractor
|
|
19
|
+
_extractors_by_language[extractor.language_id] = extractor
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def register_import_resolver(resolver: ImportResolver) -> None:
|
|
23
|
+
_import_resolvers_by_language[resolver.language_id] = resolver
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_extractor_for_extension(ext: str) -> LanguageExtractor:
|
|
27
|
+
try:
|
|
28
|
+
return _extractors_by_extension[ext]
|
|
29
|
+
except KeyError:
|
|
30
|
+
raise UnsupportedLanguageError(
|
|
31
|
+
f"no extractor registered for extension {ext!r}", language=ext
|
|
32
|
+
) from None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_extractor_for_language(language_id: Language) -> LanguageExtractor:
|
|
36
|
+
try:
|
|
37
|
+
return _extractors_by_language[language_id]
|
|
38
|
+
except KeyError:
|
|
39
|
+
raise UnsupportedLanguageError(
|
|
40
|
+
f"no extractor registered for language {language_id!r}", language=language_id
|
|
41
|
+
) from None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_import_resolver(language_id: Language) -> ImportResolver:
|
|
45
|
+
try:
|
|
46
|
+
return _import_resolvers_by_language[language_id]
|
|
47
|
+
except KeyError:
|
|
48
|
+
raise UnsupportedLanguageError(
|
|
49
|
+
f"no import resolver registered for language {language_id!r}", language=language_id
|
|
50
|
+
) from None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def registered_extensions() -> set[str]:
|
|
54
|
+
return set(_extractors_by_extension.keys())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _snapshot_for_tests() -> tuple[
|
|
58
|
+
dict[str, LanguageExtractor], dict[Language, LanguageExtractor], dict[Language, ImportResolver]
|
|
59
|
+
]:
|
|
60
|
+
"""Test-only: capture current registry state so it can be restored after a test wipes it."""
|
|
61
|
+
return (
|
|
62
|
+
dict(_extractors_by_extension),
|
|
63
|
+
dict(_extractors_by_language),
|
|
64
|
+
dict(_import_resolvers_by_language),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _restore_for_tests(
|
|
69
|
+
snapshot: tuple[
|
|
70
|
+
dict[str, LanguageExtractor],
|
|
71
|
+
dict[Language, LanguageExtractor],
|
|
72
|
+
dict[Language, ImportResolver],
|
|
73
|
+
],
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Test-only: restore registry state captured by _snapshot_for_tests."""
|
|
76
|
+
by_ext, by_lang, resolvers = snapshot
|
|
77
|
+
_extractors_by_extension.clear()
|
|
78
|
+
_extractors_by_extension.update(by_ext)
|
|
79
|
+
_extractors_by_language.clear()
|
|
80
|
+
_extractors_by_language.update(by_lang)
|
|
81
|
+
_import_resolvers_by_language.clear()
|
|
82
|
+
_import_resolvers_by_language.update(resolvers)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _reset_for_tests() -> None:
|
|
86
|
+
"""Test-only: clear registry state between tests. Not part of the public API."""
|
|
87
|
+
_extractors_by_extension.clear()
|
|
88
|
+
_extractors_by_language.clear()
|
|
89
|
+
_import_resolvers_by_language.clear()
|
compass_core/logger.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Centralized logging configuration for Code Compass.
|
|
2
|
+
|
|
3
|
+
Call :func:`setup` once at process startup (e.g. from the CLI entry point).
|
|
4
|
+
All other modules should continue to use ``logging.getLogger(__name__)`` —
|
|
5
|
+
this module only configures the root handler.
|
|
6
|
+
|
|
7
|
+
Example::
|
|
8
|
+
|
|
9
|
+
from compass_core.log import setup
|
|
10
|
+
setup(level="INFO")
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
|
|
17
|
+
from rich.logging import RichHandler
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def setup(level: str = "WARNING", *, show_path: bool = False) -> None:
|
|
21
|
+
"""Configure the root logger with a Rich console handler.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
level: Log level string (e.g. ``"DEBUG"``, ``"INFO"``, ``"WARNING"``).
|
|
25
|
+
Defaults to ``"WARNING"`` so only important messages surface in the
|
|
26
|
+
CLI by default.
|
|
27
|
+
show_path: Whether Rich should append the source file/line to each
|
|
28
|
+
log line. Useful in debug mode, noisy otherwise.
|
|
29
|
+
"""
|
|
30
|
+
handler = RichHandler(
|
|
31
|
+
rich_tracebacks=True,
|
|
32
|
+
markup=True,
|
|
33
|
+
show_path=show_path,
|
|
34
|
+
log_time_format="[%X]",
|
|
35
|
+
)
|
|
36
|
+
logging.basicConfig(
|
|
37
|
+
level=level.upper(),
|
|
38
|
+
format="%(message)s",
|
|
39
|
+
datefmt="[%X]",
|
|
40
|
+
handlers=[handler],
|
|
41
|
+
force=True,
|
|
42
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""The Phase Protocol and IngestContext that flows through phases 0-5.
|
|
2
|
+
Enables `compass ingest --stop-after <phase>` for debugging and lets any
|
|
3
|
+
phase be unit-tested with a hand-built ctx, no upstream phase required."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Protocol, runtime_checkable
|
|
10
|
+
|
|
11
|
+
from compass_core.graph.writer import WriteResult
|
|
12
|
+
from compass_core.ir.graph import ResolvedGraph
|
|
13
|
+
from compass_core.ir.models import ParsedFile
|
|
14
|
+
from compass_core.progress import ProgressReporter
|
|
15
|
+
from compass_core.shared_types import Language
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class FileMetadata:
|
|
20
|
+
path: Path
|
|
21
|
+
language: Language
|
|
22
|
+
content_hash: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class FileManifest:
|
|
27
|
+
files: list[FileMetadata]
|
|
28
|
+
dirs_scanned: int = 0
|
|
29
|
+
ignored_count: int = 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class IngestReport:
|
|
34
|
+
resolved_by_language: dict[Language, float]
|
|
35
|
+
unresolved_by_language: dict[Language, float]
|
|
36
|
+
dangling_import_count: int
|
|
37
|
+
orphan_node_count: int
|
|
38
|
+
degraded_file_count: int
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class IngestContext:
|
|
43
|
+
repo_root: Path
|
|
44
|
+
manifest: FileManifest | None = None
|
|
45
|
+
parsed_files: list[ParsedFile] | None = None
|
|
46
|
+
symbol_table: dict[str, str] | None = None
|
|
47
|
+
import_map: dict[str, dict[str, str | None]] | None = None
|
|
48
|
+
resolved_graph: ResolvedGraph | None = None
|
|
49
|
+
report: IngestReport | None = None
|
|
50
|
+
reporter: ProgressReporter | None = None
|
|
51
|
+
write_result: WriteResult | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@runtime_checkable
|
|
55
|
+
class Phase(Protocol):
|
|
56
|
+
name: str
|
|
57
|
+
|
|
58
|
+
def run(self, ctx: IngestContext) -> IngestContext: ...
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Phase 0: repo path -> FileManifest. Walks the repo respecting
|
|
2
|
+
.gitignore/.compassignore, detects language per file via the registry's
|
|
3
|
+
known extensions, filters binaries/vendored/generated code, and computes a
|
|
4
|
+
content_hash per file (unused for skip-logic yet — captured now for future
|
|
5
|
+
incremental re-index, per spec)."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import logging
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import pathspec
|
|
16
|
+
|
|
17
|
+
from compass_core.errors import DiscoveryError
|
|
18
|
+
from compass_core.languages import registry
|
|
19
|
+
from compass_core.phases.base import FileManifest, FileMetadata, IngestContext
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
_SKIP_DIRS = frozenset(
|
|
24
|
+
{
|
|
25
|
+
".git",
|
|
26
|
+
".claude",
|
|
27
|
+
"__pycache__",
|
|
28
|
+
"node_modules",
|
|
29
|
+
".venv",
|
|
30
|
+
"venv",
|
|
31
|
+
"env",
|
|
32
|
+
"dist",
|
|
33
|
+
"build",
|
|
34
|
+
".tox",
|
|
35
|
+
".mypy_cache",
|
|
36
|
+
".ruff_cache",
|
|
37
|
+
".pytest_cache",
|
|
38
|
+
".eggs",
|
|
39
|
+
"*.egg-info",
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
_MAX_BYTES = 500 * 1024
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class _WalkStats:
|
|
47
|
+
"""Counts skipped directories/files as a byproduct of the walk, so the
|
|
48
|
+
manifest can report how much of the repo was filtered out without a
|
|
49
|
+
second traversal."""
|
|
50
|
+
|
|
51
|
+
dirs_scanned: int = 0
|
|
52
|
+
ignored_count: int = 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class DiscoveryPhase:
|
|
56
|
+
name = "discovery"
|
|
57
|
+
|
|
58
|
+
def run(self, ctx: IngestContext) -> IngestContext:
|
|
59
|
+
root = ctx.repo_root.resolve()
|
|
60
|
+
if not root.is_dir():
|
|
61
|
+
raise DiscoveryError(f"not a directory: {root}", phase_name=self.name)
|
|
62
|
+
|
|
63
|
+
spec = _load_ignore_spec(root)
|
|
64
|
+
extensions = registry.registered_extensions()
|
|
65
|
+
stats = _WalkStats()
|
|
66
|
+
files: list[FileMetadata] = []
|
|
67
|
+
for path in _walk(root, root, spec, extensions, stats):
|
|
68
|
+
language = registry.get_extractor_for_extension(path.suffix).language_id
|
|
69
|
+
content_hash = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
70
|
+
files.append(FileMetadata(path=path, language=language, content_hash=content_hash))
|
|
71
|
+
|
|
72
|
+
ctx.manifest = FileManifest(
|
|
73
|
+
files=files, dirs_scanned=stats.dirs_scanned, ignored_count=stats.ignored_count
|
|
74
|
+
)
|
|
75
|
+
ctx.repo_root = root
|
|
76
|
+
if ctx.reporter is not None:
|
|
77
|
+
ctx.reporter.update("discovery", len(files), len(files))
|
|
78
|
+
return ctx
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _load_ignore_spec(root: Path) -> pathspec.PathSpec | None:
|
|
82
|
+
"""Load and merge .gitignore and .compassignore pattern sets from *root*.
|
|
83
|
+
|
|
84
|
+
Moved from parser/file_walker.py::_load_gitignore, extended to also
|
|
85
|
+
consider a .compassignore file (if present), merging both pattern sets
|
|
86
|
+
into a single PathSpec.
|
|
87
|
+
"""
|
|
88
|
+
lines: list[str] = []
|
|
89
|
+
for name in (".gitignore", ".compassignore"):
|
|
90
|
+
ignore_file = root / name
|
|
91
|
+
if not ignore_file.is_file():
|
|
92
|
+
continue
|
|
93
|
+
try:
|
|
94
|
+
lines.extend(ignore_file.read_text(encoding="utf-8", errors="replace").splitlines())
|
|
95
|
+
except OSError as exc:
|
|
96
|
+
logger.warning("Could not read %s: %s", name, exc)
|
|
97
|
+
|
|
98
|
+
if not lines:
|
|
99
|
+
return None
|
|
100
|
+
return pathspec.PathSpec.from_lines("gitwildmatch", lines)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _walk(
|
|
104
|
+
current: Path,
|
|
105
|
+
root: Path,
|
|
106
|
+
spec: pathspec.PathSpec | None,
|
|
107
|
+
extensions: set[str],
|
|
108
|
+
stats: _WalkStats,
|
|
109
|
+
) -> Iterator[Path]:
|
|
110
|
+
"""Recursively traverse directories to find files with a registered extension.
|
|
111
|
+
|
|
112
|
+
Generalized from parser/file_walker.py::_recurse — same PermissionError
|
|
113
|
+
isolation (catch, log at debug, skip) and _should_skip_dir/_should_skip_file
|
|
114
|
+
logic, but filters on `entry.suffix in extensions` instead of `== ".py"`.
|
|
115
|
+
"""
|
|
116
|
+
try:
|
|
117
|
+
entries = sorted(current.iterdir(), key=lambda p: (p.is_file(), p.name))
|
|
118
|
+
except PermissionError as exc:
|
|
119
|
+
logger.debug("Permission denied, skipping: %s (%s)", current, exc)
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
stats.dirs_scanned += 1
|
|
123
|
+
for entry in entries:
|
|
124
|
+
if entry.is_dir():
|
|
125
|
+
if _should_skip_dir(entry, root, spec):
|
|
126
|
+
stats.ignored_count += 1
|
|
127
|
+
continue
|
|
128
|
+
yield from _walk(entry, root, spec, extensions, stats)
|
|
129
|
+
|
|
130
|
+
elif entry.is_file() and entry.suffix in extensions:
|
|
131
|
+
if _should_skip_file(entry, root, spec):
|
|
132
|
+
stats.ignored_count += 1
|
|
133
|
+
continue
|
|
134
|
+
yield entry
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _should_skip_dir(entry: Path, root: Path, spec: pathspec.PathSpec | None) -> bool:
|
|
138
|
+
"""Determine if a directory should be skipped during traversal.
|
|
139
|
+
|
|
140
|
+
Checks against common skip directories and .gitignore/.compassignore rules.
|
|
141
|
+
"""
|
|
142
|
+
if entry.name in _SKIP_DIRS:
|
|
143
|
+
return True
|
|
144
|
+
if spec is not None:
|
|
145
|
+
# pathspec expects forward-slash paths; append "/" for directory matching
|
|
146
|
+
rel = entry.relative_to(root).as_posix() + "/"
|
|
147
|
+
if spec.match_file(rel):
|
|
148
|
+
return True
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _should_skip_file(entry: Path, root: Path, spec: pathspec.PathSpec | None) -> bool:
|
|
153
|
+
"""Determine if a file should be skipped during discovery.
|
|
154
|
+
|
|
155
|
+
Checks against .gitignore/.compassignore rules and file size limits.
|
|
156
|
+
"""
|
|
157
|
+
if spec is not None:
|
|
158
|
+
rel = entry.relative_to(root).as_posix()
|
|
159
|
+
if spec.match_file(rel):
|
|
160
|
+
return True
|
|
161
|
+
try:
|
|
162
|
+
if entry.stat().st_size > _MAX_BYTES:
|
|
163
|
+
logger.debug("Skipping large file (%d bytes): %s", entry.stat().st_size, entry)
|
|
164
|
+
return True
|
|
165
|
+
except OSError:
|
|
166
|
+
return True
|
|
167
|
+
return False
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Phase 1: FileManifest -> list[ParsedFile]. tree-sitter parse per file,
|
|
2
|
+
dispatched via the language registry, run in ProcessPoolExecutor. Per-file
|
|
3
|
+
errors isolate (degraded flag on ParsedFile), never kill the pool — this is
|
|
4
|
+
the single most important error-isolation point in the pipeline."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import traceback
|
|
10
|
+
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
11
|
+
from functools import cache
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from compass_core.ir.models import ParsedFile
|
|
15
|
+
from compass_core.languages import registry
|
|
16
|
+
from compass_core.languages.base import LanguageExtractor
|
|
17
|
+
from compass_core.phases.base import IngestContext
|
|
18
|
+
from compass_core.shared_types import Language
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@cache
|
|
22
|
+
def _get_extractor(language_id: Language) -> LanguageExtractor:
|
|
23
|
+
return registry.get_extractor_for_language(language_id)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _parse_one(language_id: Language, abs_path: str, rel_path: str) -> ParsedFile:
|
|
27
|
+
"""rel_path is abs_path made relative to ctx.repo_root by the caller.
|
|
28
|
+
Extractors receive the relative path (per the languages/python module-name
|
|
29
|
+
contract established in Task 5: file_path is repo-relative, not absolute,
|
|
30
|
+
so module-name derivation can build a dotted name without needing root
|
|
31
|
+
itself) — abs_path is only used here, to actually read the file's bytes."""
|
|
32
|
+
extractor = _get_extractor(language_id)
|
|
33
|
+
with open(abs_path, "rb") as f:
|
|
34
|
+
source = f.read()
|
|
35
|
+
return extractor.parse(source, Path(rel_path))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_worker(args: tuple[Language, str, str]) -> ParsedFile:
|
|
39
|
+
language_id, abs_path, rel_path = args
|
|
40
|
+
try:
|
|
41
|
+
return _parse_one(language_id, abs_path, rel_path)
|
|
42
|
+
except Exception as exc: # noqa: BLE001 — per-file isolation boundary, see module docstring
|
|
43
|
+
traceback.print_exc()
|
|
44
|
+
return ParsedFile(
|
|
45
|
+
path=rel_path,
|
|
46
|
+
language=language_id,
|
|
47
|
+
module_name="",
|
|
48
|
+
content_hash="",
|
|
49
|
+
definitions=[],
|
|
50
|
+
imports=[],
|
|
51
|
+
call_sites=[],
|
|
52
|
+
degraded=True,
|
|
53
|
+
error=str(exc),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ParsePhase:
|
|
58
|
+
name = "parse"
|
|
59
|
+
|
|
60
|
+
def __init__(self, max_workers: int | None = None) -> None:
|
|
61
|
+
self._max_workers = max_workers
|
|
62
|
+
|
|
63
|
+
def run(self, ctx: IngestContext) -> IngestContext:
|
|
64
|
+
assert ctx.manifest is not None, "ParsePhase requires DiscoveryPhase to run first"
|
|
65
|
+
tasks = [
|
|
66
|
+
(f.language, str(f.path), str(f.path.relative_to(ctx.repo_root)))
|
|
67
|
+
for f in ctx.manifest.files
|
|
68
|
+
]
|
|
69
|
+
workers = self._max_workers or os.cpu_count() or 1
|
|
70
|
+
|
|
71
|
+
parsed: list[ParsedFile] = []
|
|
72
|
+
with ProcessPoolExecutor(max_workers=workers) as pool:
|
|
73
|
+
futures = {pool.submit(_parse_worker, task): task for task in tasks}
|
|
74
|
+
for done, future in enumerate(as_completed(futures), start=1):
|
|
75
|
+
parsed.append(future.result())
|
|
76
|
+
if ctx.reporter is not None:
|
|
77
|
+
ctx.reporter.update("parse", done, len(tasks))
|
|
78
|
+
|
|
79
|
+
ctx.parsed_files = parsed
|
|
80
|
+
return ctx
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Phase 5: ResolvedGraph -> LadybugDB. Nodes before edges, per
|
|
2
|
+
graph/writer.py's write order (File -> Class -> Function -> ExternalFunction
|
|
3
|
+
nodes, then CONTAINS -> IMPORTS -> INHERITS -> CALLS -> CALLS_EXTERN edges).
|
|
4
|
+
|
|
5
|
+
Deliberately deviates from the uniform Phase Protocol: `run` takes an extra
|
|
6
|
+
`db: GraphDB` argument because the DB connection is a context-managed
|
|
7
|
+
resource owned by phases/pipeline.py, not something this phase constructs
|
|
8
|
+
itself. pipeline.py calls this phase specially, not through the generic
|
|
9
|
+
phase loop."""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from compass_core.errors import PersistenceError
|
|
14
|
+
from compass_core.graph.db import GraphDB
|
|
15
|
+
from compass_core.graph.writer import write
|
|
16
|
+
from compass_core.phases.base import IngestContext
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PersistencePhase:
|
|
20
|
+
name = "persistence"
|
|
21
|
+
|
|
22
|
+
def run(self, ctx: IngestContext, db: GraphDB) -> IngestContext:
|
|
23
|
+
assert ctx.resolved_graph is not None
|
|
24
|
+
try:
|
|
25
|
+
ctx.write_result = write(db, ctx.resolved_graph, reporter=ctx.reporter)
|
|
26
|
+
except PersistenceError:
|
|
27
|
+
raise
|
|
28
|
+
except Exception as exc: # noqa: BLE001 — unrecoverable DB failure boundary
|
|
29
|
+
raise PersistenceError(
|
|
30
|
+
f"failed to persist resolved graph: {exc}",
|
|
31
|
+
phase_name=self.name,
|
|
32
|
+
) from exc
|
|
33
|
+
return ctx
|