opencode-arch 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opencode_arch/__init__.py +3 -0
- opencode_arch/artifacts/__init__.py +48 -0
- opencode_arch/artifacts/context.py +451 -0
- opencode_arch/artifacts/diagrams.py +451 -0
- opencode_arch/artifacts/selector.py +331 -0
- opencode_arch/artifacts/templates.py +444 -0
- opencode_arch/cli/__init__.py +1 -0
- opencode_arch/cli/bench.py +25 -0
- opencode_arch/cli/calibrate.py +208 -0
- opencode_arch/cli/confidence.py +66 -0
- opencode_arch/cli/docs.py +333 -0
- opencode_arch/cli/docs_validator.py +295 -0
- opencode_arch/cli/export_data.py +133 -0
- opencode_arch/cli/extract.py +93 -0
- opencode_arch/cli/gap_analyzer.py +107 -0
- opencode_arch/cli/generate.py +68 -0
- opencode_arch/cli/launch.py +264 -0
- opencode_arch/cli/main.py +360 -0
- opencode_arch/cli/metrics.py +186 -0
- opencode_arch/cli/prompts.py +20 -0
- opencode_arch/cli/regen_loop.py +1028 -0
- opencode_arch/context/__init__.py +29 -0
- opencode_arch/context/formatter.py +492 -0
- opencode_arch/context/pipeline_bridge.py +201 -0
- opencode_arch/extract/__init__.py +8 -0
- opencode_arch/extract/constraint_detector.py +398 -0
- opencode_arch/extract/from_artifacts.py +837 -0
- opencode_arch/extract/from_code.py +646 -0
- opencode_arch/extract/route_detector.py +400 -0
- opencode_arch/extract/table_parser.py +177 -0
- opencode_arch/learning/__init__.py +19 -0
- opencode_arch/learning/adapter.py +157 -0
- opencode_arch/learning/assessor.py +170 -0
- opencode_arch/learning/classifier.py +144 -0
- opencode_arch/learning/lessons.py +139 -0
- opencode_arch/learning/maintainer.py +281 -0
- opencode_arch/learning/patterns.py +51 -0
- opencode_arch/mcp/__init__.py +1 -0
- opencode_arch/mcp/__main__.py +8 -0
- opencode_arch/mcp/server.py +183 -0
- opencode_arch/mcp/tools/__init__.py +1 -0
- opencode_arch/mcp/tools/check.py +159 -0
- opencode_arch/mcp/tools/extract.py +107 -0
- opencode_arch/mcp/tools/feedback.py +65 -0
- opencode_arch/mcp/tools/generate.py +104 -0
- opencode_arch/mcp/tools/group.py +62 -0
- opencode_arch/mcp/tools/ingest.py +101 -0
- opencode_arch/mcp/tools/require.py +77 -0
- opencode_arch/mcp/tools/scan.py +53 -0
- opencode_arch/mcp/tools/slice.py +235 -0
- opencode_arch/mcp/tools/validate.py +59 -0
- opencode_arch/prompts/__init__.py +1 -0
- opencode_arch/prompts/regen.py +36 -0
- opencode_arch/runner/__init__.py +5 -0
- opencode_arch/runner/base.py +21 -0
- opencode_arch/runner/opencode.py +66 -0
- opencode_arch/telemetry/__init__.py +6 -0
- opencode_arch/telemetry/collector.py +40 -0
- opencode_arch/telemetry/recorder.py +12 -0
- opencode_arch/telemetry/store.py +537 -0
- opencode_arch-1.0.0.dist-info/METADATA +247 -0
- opencode_arch-1.0.0.dist-info/RECORD +65 -0
- opencode_arch-1.0.0.dist-info/WHEEL +4 -0
- opencode_arch-1.0.0.dist-info/entry_points.txt +2 -0
- opencode_arch-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pipeline Bridge: Connect the Architecture Model to the artifact generation pipeline.
|
|
3
|
+
|
|
4
|
+
This module provides the interface between:
|
|
5
|
+
- The existing _pipeline_manifest.py (code-grounded reality)
|
|
6
|
+
- The architecture model (structural + semantic truth)
|
|
7
|
+
- The artifact generation pipeline (_pipeline_artifacts.py, _pipeline_templates.py)
|
|
8
|
+
|
|
9
|
+
It replaces raw manifest slices with model-enriched context for LLM artifact generation.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
from architecture_model.core.parser import load_model, save_model
|
|
18
|
+
from architecture_model.core.merger import merge_manifest
|
|
19
|
+
from architecture_model.core.slicer import slice_by_fblock, slice_for_artifact
|
|
20
|
+
from architecture_model.core.validator import validate_model
|
|
21
|
+
from architecture_model.core.types import ArchitectureModel
|
|
22
|
+
from opencode_arch.context.formatter import (
|
|
23
|
+
format_model_context,
|
|
24
|
+
format_fblock_context,
|
|
25
|
+
format_artifact_context,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Configuration — loaded from .architecture-model.yaml
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _get_default_paths(project_root: Path) -> tuple[Path, Path, Path]:
|
|
35
|
+
"""Resolve output paths from config."""
|
|
36
|
+
try:
|
|
37
|
+
from architecture_model.config.loader import get_config
|
|
38
|
+
|
|
39
|
+
config = get_config(project_root)
|
|
40
|
+
resolved = config.resolved_output()
|
|
41
|
+
return resolved.model, resolved.manifest, resolved.artifacts
|
|
42
|
+
except Exception:
|
|
43
|
+
# Fallback for backward compatibility
|
|
44
|
+
name = project_root.name
|
|
45
|
+
return (
|
|
46
|
+
project_root / f"output/{name}/architecture-model.yaml",
|
|
47
|
+
project_root / f"output/{name}/reality-manifest.json",
|
|
48
|
+
project_root / f"output/{name}/artifacts/stage2",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Public API
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_model(
|
|
58
|
+
project_root: str | Path,
|
|
59
|
+
force_refresh: bool = False,
|
|
60
|
+
) -> ArchitectureModel:
|
|
61
|
+
"""
|
|
62
|
+
Load or generate the architecture model for the project.
|
|
63
|
+
|
|
64
|
+
If the model YAML exists and isn't stale, loads it.
|
|
65
|
+
If missing or force_refresh=True, re-extracts from artifacts and merges manifest.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
project_root: Path to project root directory.
|
|
69
|
+
force_refresh: Force re-extraction even if model exists.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Loaded and validated ArchitectureModel.
|
|
73
|
+
"""
|
|
74
|
+
project_root = Path(project_root)
|
|
75
|
+
model_path, manifest_path, artifact_dir = _get_default_paths(project_root)
|
|
76
|
+
|
|
77
|
+
if model_path.exists() and not force_refresh:
|
|
78
|
+
return load_model(model_path)
|
|
79
|
+
|
|
80
|
+
# Re-extract
|
|
81
|
+
from opencode_arch.extract.from_artifacts import extract_from_artifacts
|
|
82
|
+
|
|
83
|
+
model = extract_from_artifacts(artifact_dir)
|
|
84
|
+
|
|
85
|
+
if manifest_path.exists():
|
|
86
|
+
merge_manifest(model, manifest_path)
|
|
87
|
+
|
|
88
|
+
save_model(model, model_path)
|
|
89
|
+
return model
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_artifact_context(
|
|
93
|
+
project_root: str | Path,
|
|
94
|
+
artifact_name: str,
|
|
95
|
+
max_tokens: int = 3000,
|
|
96
|
+
) -> str:
|
|
97
|
+
"""
|
|
98
|
+
Get model-based context for artifact generation/regeneration.
|
|
99
|
+
|
|
100
|
+
This REPLACES the raw manifest slice with structured architectural context.
|
|
101
|
+
The returned string is injected into the LLM system prompt alongside the
|
|
102
|
+
manifest metrics (which are kept for ground-truth file counts).
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
project_root: Project root path.
|
|
106
|
+
artifact_name: Which artifact needs context.
|
|
107
|
+
max_tokens: Token budget for the context block.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Formatted model context string for LLM prompt injection.
|
|
111
|
+
"""
|
|
112
|
+
model = get_model(project_root)
|
|
113
|
+
return format_artifact_context(model, artifact_name, max_tokens=max_tokens)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_fblock_context(
|
|
117
|
+
project_root: str | Path,
|
|
118
|
+
f_block: str,
|
|
119
|
+
max_tokens: int = 2000,
|
|
120
|
+
) -> str:
|
|
121
|
+
"""
|
|
122
|
+
Get model-based context for a single F-block (for section regeneration).
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
project_root: Project root path.
|
|
126
|
+
f_block: F-block ID (e.g., "F3").
|
|
127
|
+
max_tokens: Token budget.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Formatted F-block context string.
|
|
131
|
+
"""
|
|
132
|
+
model = get_model(project_root)
|
|
133
|
+
return format_fblock_context(model, f_block, max_tokens=max_tokens, project_root=Path(project_root))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def get_model_summary(project_root: str | Path) -> dict[str, Any]:
|
|
137
|
+
"""
|
|
138
|
+
Get a summary dict of the model for injection into pipeline metadata.
|
|
139
|
+
|
|
140
|
+
Returns dict with entity_count, relationship_count, validation score, etc.
|
|
141
|
+
"""
|
|
142
|
+
model = get_model(project_root)
|
|
143
|
+
result = validate_model(model)
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
"entity_count": model.entity_count,
|
|
147
|
+
"relationship_count": model.relationship_count,
|
|
148
|
+
"validation_score": result.score,
|
|
149
|
+
"entities": {
|
|
150
|
+
"actors": len(model.entities.actors),
|
|
151
|
+
"capabilities": len(model.entities.capabilities),
|
|
152
|
+
"behaviors": len(model.entities.behaviors),
|
|
153
|
+
"interfaces": len(model.entities.interfaces),
|
|
154
|
+
"constraints": len(model.entities.constraints),
|
|
155
|
+
"layers": len(model.entities.layers),
|
|
156
|
+
"components": len(model.entities.components),
|
|
157
|
+
},
|
|
158
|
+
"schema_version": model.meta.schema_version,
|
|
159
|
+
"manifest_hash": model.meta.manifest_hash,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def enrich_manifest_slice(
|
|
164
|
+
manifest_slice: str,
|
|
165
|
+
project_root: str | Path,
|
|
166
|
+
artifact_name: str,
|
|
167
|
+
max_model_tokens: int = 2000,
|
|
168
|
+
) -> str:
|
|
169
|
+
"""
|
|
170
|
+
Enrich an existing manifest slice with architecture model context.
|
|
171
|
+
|
|
172
|
+
This is the BACKWARD-COMPATIBLE integration point. The existing pipeline
|
|
173
|
+
generates manifest slices via _pipeline_manifest.py — this function
|
|
174
|
+
prepends model context to that slice, giving the LLM both:
|
|
175
|
+
1. Architectural structure (from model) — WHAT things mean, how they relate
|
|
176
|
+
2. Code-grounded metrics (from manifest) — WHAT actually exists
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
manifest_slice: Raw manifest slice text (from _pipeline_manifest.py).
|
|
180
|
+
project_root: Project root path.
|
|
181
|
+
artifact_name: Which artifact this slice is for.
|
|
182
|
+
max_model_tokens: Token budget for model context portion.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Combined context: model context + separator + manifest metrics.
|
|
186
|
+
"""
|
|
187
|
+
try:
|
|
188
|
+
model_context = get_artifact_context(
|
|
189
|
+
project_root, artifact_name, max_tokens=max_model_tokens
|
|
190
|
+
)
|
|
191
|
+
except Exception:
|
|
192
|
+
# If model loading fails, fall back to manifest-only
|
|
193
|
+
return manifest_slice
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
f"=== ARCHITECTURE MODEL CONTEXT ===\n"
|
|
197
|
+
f"{model_context}\n"
|
|
198
|
+
f"\n"
|
|
199
|
+
f"=== CODE-GROUNDED MANIFEST (verified metrics) ===\n"
|
|
200
|
+
f"{manifest_slice}"
|
|
201
|
+
)
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Parse project configuration files to derive technical and organizational constraints.
|
|
3
|
+
|
|
4
|
+
Scans pyproject.toml, Dockerfile, .env templates, CI config, and setup.cfg to
|
|
5
|
+
automatically detect constraints such as Python version requirements, framework
|
|
6
|
+
choices, runtime images, exposed ports, and CI/CD platform.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import tomllib
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from architecture_model.core.types import Constraint, ConstraintType, Status
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Key frameworks to detect in dependencies
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
_KEY_FRAMEWORKS: set[str] = {
|
|
23
|
+
"fastapi",
|
|
24
|
+
"flask",
|
|
25
|
+
"django",
|
|
26
|
+
"starlette",
|
|
27
|
+
"aiohttp",
|
|
28
|
+
"tornado",
|
|
29
|
+
"sqlalchemy",
|
|
30
|
+
"asyncpg",
|
|
31
|
+
"psycopg2",
|
|
32
|
+
"pymongo",
|
|
33
|
+
"redis",
|
|
34
|
+
"celery",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Public API
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def detect_constraints(project_root: Path) -> list[Constraint]:
|
|
44
|
+
"""Scan project configuration files for derivable constraints.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
project_root: Root directory of the project.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
List of Constraint entities derived from config files.
|
|
51
|
+
"""
|
|
52
|
+
constraints: list[Constraint] = []
|
|
53
|
+
tc_counter = 0
|
|
54
|
+
oc_counter = 0
|
|
55
|
+
|
|
56
|
+
# --- pyproject.toml ---
|
|
57
|
+
tc_counter, oc_counter = _parse_pyproject(
|
|
58
|
+
project_root, constraints, tc_counter, oc_counter
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# --- Dockerfile ---
|
|
62
|
+
tc_counter, oc_counter = _parse_dockerfile(
|
|
63
|
+
project_root, constraints, tc_counter, oc_counter
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# --- .env.example / .env.template ---
|
|
67
|
+
oc_counter = _parse_env_template(project_root, constraints, oc_counter)
|
|
68
|
+
|
|
69
|
+
# --- CI/CD config ---
|
|
70
|
+
oc_counter = _parse_ci_config(project_root, constraints, oc_counter)
|
|
71
|
+
|
|
72
|
+
# --- setup.cfg fallback for python_requires ---
|
|
73
|
+
tc_counter, oc_counter = _parse_setup_cfg(
|
|
74
|
+
project_root, constraints, tc_counter, oc_counter
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return constraints
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# pyproject.toml parsing
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _parse_pyproject(
|
|
86
|
+
project_root: Path,
|
|
87
|
+
constraints: list[Constraint],
|
|
88
|
+
tc_counter: int,
|
|
89
|
+
oc_counter: int,
|
|
90
|
+
) -> tuple[int, int]:
|
|
91
|
+
"""Extract constraints from pyproject.toml."""
|
|
92
|
+
pyproject_path = project_root / "pyproject.toml"
|
|
93
|
+
if not pyproject_path.is_file():
|
|
94
|
+
return tc_counter, oc_counter
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
data = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
|
|
98
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
99
|
+
return tc_counter, oc_counter
|
|
100
|
+
|
|
101
|
+
# requires-python
|
|
102
|
+
requires_python = data.get("project", {}).get("requires-python", "")
|
|
103
|
+
if requires_python:
|
|
104
|
+
tc_counter += 1
|
|
105
|
+
constraints.append(
|
|
106
|
+
Constraint(
|
|
107
|
+
id=f"TC-{tc_counter:02d}",
|
|
108
|
+
name=f"Python {requires_python}",
|
|
109
|
+
status=Status.ACTIVE,
|
|
110
|
+
description=f"Project requires Python {requires_python}",
|
|
111
|
+
type=ConstraintType.TECHNOLOGY,
|
|
112
|
+
metric="python_version",
|
|
113
|
+
threshold=requires_python,
|
|
114
|
+
rationale="Detected from pyproject.toml requires-python",
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# dependencies (key frameworks)
|
|
119
|
+
deps = _collect_dependencies(data)
|
|
120
|
+
for dep_name in sorted(deps):
|
|
121
|
+
normalized = _normalize_dep_name(dep_name)
|
|
122
|
+
if normalized in _KEY_FRAMEWORKS:
|
|
123
|
+
tc_counter += 1
|
|
124
|
+
constraints.append(
|
|
125
|
+
Constraint(
|
|
126
|
+
id=f"TC-{tc_counter:02d}",
|
|
127
|
+
name=f"Dependency: {normalized}",
|
|
128
|
+
status=Status.ACTIVE,
|
|
129
|
+
description=f"Project depends on {normalized}",
|
|
130
|
+
tags=[normalized],
|
|
131
|
+
type=ConstraintType.TECHNOLOGY,
|
|
132
|
+
rationale="Detected from pyproject.toml dependencies",
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return tc_counter, oc_counter
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _collect_dependencies(data: dict) -> list[str]:
|
|
140
|
+
"""Collect dependency names from pyproject.toml data.
|
|
141
|
+
|
|
142
|
+
Checks [project.dependencies] and [tool.poetry.dependencies].
|
|
143
|
+
"""
|
|
144
|
+
deps: list[str] = []
|
|
145
|
+
|
|
146
|
+
# PEP 621 style
|
|
147
|
+
project_deps = data.get("project", {}).get("dependencies", [])
|
|
148
|
+
for dep in project_deps:
|
|
149
|
+
# Parse requirement specifier: "fastapi>=0.100" -> "fastapi"
|
|
150
|
+
name = re.split(r"[>=<!\[;@\s]", dep, maxsplit=1)[0].strip()
|
|
151
|
+
if name:
|
|
152
|
+
deps.append(name)
|
|
153
|
+
|
|
154
|
+
# Poetry style
|
|
155
|
+
poetry_deps = (
|
|
156
|
+
data.get("tool", {}).get("poetry", {}).get("dependencies", {})
|
|
157
|
+
)
|
|
158
|
+
if isinstance(poetry_deps, dict):
|
|
159
|
+
for name in poetry_deps:
|
|
160
|
+
if name.lower() != "python":
|
|
161
|
+
deps.append(name)
|
|
162
|
+
|
|
163
|
+
return deps
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _normalize_dep_name(name: str) -> str:
|
|
167
|
+
"""Normalize a dependency name for comparison (lowercase, hyphens to underscores removed)."""
|
|
168
|
+
return re.sub(r"[-_.]", "", name.lower())
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
# Dockerfile parsing
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _parse_dockerfile(
|
|
177
|
+
project_root: Path,
|
|
178
|
+
constraints: list[Constraint],
|
|
179
|
+
tc_counter: int,
|
|
180
|
+
oc_counter: int,
|
|
181
|
+
) -> tuple[int, int]:
|
|
182
|
+
"""Extract constraints from Dockerfile."""
|
|
183
|
+
dockerfile_path = project_root / "Dockerfile"
|
|
184
|
+
if not dockerfile_path.is_file():
|
|
185
|
+
return tc_counter, oc_counter
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
content = dockerfile_path.read_text(encoding="utf-8")
|
|
189
|
+
except OSError:
|
|
190
|
+
return tc_counter, oc_counter
|
|
191
|
+
|
|
192
|
+
# FROM line (first non-comment FROM)
|
|
193
|
+
from_match = re.search(
|
|
194
|
+
r"^\s*FROM\s+(\S+)", content, re.MULTILINE | re.IGNORECASE
|
|
195
|
+
)
|
|
196
|
+
if from_match:
|
|
197
|
+
base_image = from_match.group(1)
|
|
198
|
+
tc_counter += 1
|
|
199
|
+
constraints.append(
|
|
200
|
+
Constraint(
|
|
201
|
+
id=f"TC-{tc_counter:02d}",
|
|
202
|
+
name=f"Base image: {base_image}",
|
|
203
|
+
status=Status.ACTIVE,
|
|
204
|
+
description=f"Container runtime uses base image {base_image}",
|
|
205
|
+
type=ConstraintType.TECHNOLOGY,
|
|
206
|
+
metric="base_image",
|
|
207
|
+
threshold=base_image,
|
|
208
|
+
rationale="Detected from Dockerfile FROM directive",
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# EXPOSE lines
|
|
213
|
+
expose_matches = re.findall(
|
|
214
|
+
r"^\s*EXPOSE\s+(.+)$", content, re.MULTILINE | re.IGNORECASE
|
|
215
|
+
)
|
|
216
|
+
ports: list[str] = []
|
|
217
|
+
for match in expose_matches:
|
|
218
|
+
# EXPOSE can have multiple ports: "EXPOSE 8000 8080"
|
|
219
|
+
found = re.findall(r"\d+", match)
|
|
220
|
+
ports.extend(found)
|
|
221
|
+
|
|
222
|
+
if ports:
|
|
223
|
+
tc_counter += 1
|
|
224
|
+
port_str = ", ".join(ports)
|
|
225
|
+
constraints.append(
|
|
226
|
+
Constraint(
|
|
227
|
+
id=f"TC-{tc_counter:02d}",
|
|
228
|
+
name=f"Exposed ports: {port_str}",
|
|
229
|
+
status=Status.ACTIVE,
|
|
230
|
+
description=f"Container exposes network port(s): {port_str}",
|
|
231
|
+
tags=["networking"],
|
|
232
|
+
type=ConstraintType.TECHNOLOGY,
|
|
233
|
+
metric="exposed_ports",
|
|
234
|
+
threshold=port_str,
|
|
235
|
+
rationale="Detected from Dockerfile EXPOSE directive",
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
return tc_counter, oc_counter
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
# .env template parsing
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _parse_env_template(
|
|
248
|
+
project_root: Path,
|
|
249
|
+
constraints: list[Constraint],
|
|
250
|
+
oc_counter: int,
|
|
251
|
+
) -> int:
|
|
252
|
+
"""Count required env vars from .env.example or .env.template."""
|
|
253
|
+
env_path: Path | None = None
|
|
254
|
+
for name in (".env.example", ".env.template"):
|
|
255
|
+
candidate = project_root / name
|
|
256
|
+
if candidate.is_file():
|
|
257
|
+
env_path = candidate
|
|
258
|
+
break
|
|
259
|
+
|
|
260
|
+
if env_path is None:
|
|
261
|
+
return oc_counter
|
|
262
|
+
|
|
263
|
+
try:
|
|
264
|
+
content = env_path.read_text(encoding="utf-8")
|
|
265
|
+
except OSError:
|
|
266
|
+
return oc_counter
|
|
267
|
+
|
|
268
|
+
# Count non-empty, non-comment lines that look like KEY=...
|
|
269
|
+
var_count = 0
|
|
270
|
+
for line in content.splitlines():
|
|
271
|
+
stripped = line.strip()
|
|
272
|
+
if stripped and not stripped.startswith("#"):
|
|
273
|
+
if re.match(r"^[A-Za-z_][A-Za-z0-9_]*\s*=", stripped):
|
|
274
|
+
var_count += 1
|
|
275
|
+
|
|
276
|
+
if var_count > 0:
|
|
277
|
+
oc_counter += 1
|
|
278
|
+
constraints.append(
|
|
279
|
+
Constraint(
|
|
280
|
+
id=f"OC-{oc_counter:02d}",
|
|
281
|
+
name=f"Environment configuration ({var_count} vars)",
|
|
282
|
+
status=Status.ACTIVE,
|
|
283
|
+
description=(
|
|
284
|
+
f"Application requires {var_count} environment variable(s) "
|
|
285
|
+
f"as defined in {env_path.name}"
|
|
286
|
+
),
|
|
287
|
+
tags=["configuration"],
|
|
288
|
+
type=ConstraintType.OPERATIONAL,
|
|
289
|
+
metric="env_var_count",
|
|
290
|
+
threshold=str(var_count),
|
|
291
|
+
rationale=f"Detected from {env_path.name}",
|
|
292
|
+
)
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
return oc_counter
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ---------------------------------------------------------------------------
|
|
299
|
+
# CI/CD config parsing
|
|
300
|
+
# ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _parse_ci_config(
|
|
304
|
+
project_root: Path,
|
|
305
|
+
constraints: list[Constraint],
|
|
306
|
+
oc_counter: int,
|
|
307
|
+
) -> int:
|
|
308
|
+
"""Detect CI/CD platform from workflow config files."""
|
|
309
|
+
# GitHub Actions
|
|
310
|
+
workflows_dir = project_root / ".github" / "workflows"
|
|
311
|
+
if workflows_dir.is_dir():
|
|
312
|
+
yml_files = list(workflows_dir.glob("*.yml")) + list(
|
|
313
|
+
workflows_dir.glob("*.yaml")
|
|
314
|
+
)
|
|
315
|
+
if yml_files:
|
|
316
|
+
oc_counter += 1
|
|
317
|
+
constraints.append(
|
|
318
|
+
Constraint(
|
|
319
|
+
id=f"OC-{oc_counter:02d}",
|
|
320
|
+
name="CI/CD: GitHub Actions",
|
|
321
|
+
status=Status.ACTIVE,
|
|
322
|
+
description="Project uses GitHub Actions for CI/CD",
|
|
323
|
+
tags=["ci-cd", "github"],
|
|
324
|
+
type=ConstraintType.OPERATIONAL,
|
|
325
|
+
rationale="Detected from .github/workflows/*.yml",
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
return oc_counter
|
|
329
|
+
|
|
330
|
+
# GitLab CI
|
|
331
|
+
gitlab_ci = project_root / ".gitlab-ci.yml"
|
|
332
|
+
if gitlab_ci.is_file():
|
|
333
|
+
oc_counter += 1
|
|
334
|
+
constraints.append(
|
|
335
|
+
Constraint(
|
|
336
|
+
id=f"OC-{oc_counter:02d}",
|
|
337
|
+
name="CI/CD: GitLab CI",
|
|
338
|
+
status=Status.ACTIVE,
|
|
339
|
+
description="Project uses GitLab CI/CD",
|
|
340
|
+
tags=["ci-cd", "gitlab"],
|
|
341
|
+
type=ConstraintType.OPERATIONAL,
|
|
342
|
+
rationale="Detected from .gitlab-ci.yml",
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
return oc_counter
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# ---------------------------------------------------------------------------
|
|
350
|
+
# setup.cfg fallback
|
|
351
|
+
# ---------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _parse_setup_cfg(
|
|
355
|
+
project_root: Path,
|
|
356
|
+
constraints: list[Constraint],
|
|
357
|
+
tc_counter: int,
|
|
358
|
+
oc_counter: int,
|
|
359
|
+
) -> tuple[int, int]:
|
|
360
|
+
"""Extract python_requires from setup.cfg as fallback.
|
|
361
|
+
|
|
362
|
+
Only adds a constraint if no Python version constraint was already found
|
|
363
|
+
from pyproject.toml.
|
|
364
|
+
"""
|
|
365
|
+
# Skip if we already have a python version constraint
|
|
366
|
+
if any(c.metric == "python_version" for c in constraints):
|
|
367
|
+
return tc_counter, oc_counter
|
|
368
|
+
|
|
369
|
+
setup_cfg_path = project_root / "setup.cfg"
|
|
370
|
+
if not setup_cfg_path.is_file():
|
|
371
|
+
return tc_counter, oc_counter
|
|
372
|
+
|
|
373
|
+
try:
|
|
374
|
+
content = setup_cfg_path.read_text(encoding="utf-8")
|
|
375
|
+
except OSError:
|
|
376
|
+
return tc_counter, oc_counter
|
|
377
|
+
|
|
378
|
+
# Look for python_requires under [options]
|
|
379
|
+
match = re.search(
|
|
380
|
+
r"^\s*python_requires\s*=\s*(.+)$", content, re.MULTILINE
|
|
381
|
+
)
|
|
382
|
+
if match:
|
|
383
|
+
requires = match.group(1).strip()
|
|
384
|
+
tc_counter += 1
|
|
385
|
+
constraints.append(
|
|
386
|
+
Constraint(
|
|
387
|
+
id=f"TC-{tc_counter:02d}",
|
|
388
|
+
name=f"Python {requires}",
|
|
389
|
+
status=Status.ACTIVE,
|
|
390
|
+
description=f"Project requires Python {requires}",
|
|
391
|
+
type=ConstraintType.TECHNOLOGY,
|
|
392
|
+
metric="python_version",
|
|
393
|
+
threshold=requires,
|
|
394
|
+
rationale="Detected from setup.cfg python_requires",
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
return tc_counter, oc_counter
|