codetrail-cli 1.4.1__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.
- codetrail/__init__.py +3 -0
- codetrail/__main__.py +5 -0
- codetrail/agents/__init__.py +4 -0
- codetrail/agents/convert.py +92 -0
- codetrail/agents/export.py +284 -0
- codetrail/agents/instructions.py +42 -0
- codetrail/agents/protocol.py +111 -0
- codetrail/agents/tools.py +185 -0
- codetrail/assistant/__init__.py +161 -0
- codetrail/assistant/claude_code.py +330 -0
- codetrail/assistant/codex.py +260 -0
- codetrail/assistant/environment.py +66 -0
- codetrail/assistant/estimate.py +225 -0
- codetrail/assistant/fake.py +74 -0
- codetrail/assistant/guard.py +80 -0
- codetrail/assistant/guard_hook.py +58 -0
- codetrail/assistant/local.py +245 -0
- codetrail/assistant/local_tools.py +177 -0
- codetrail/assistant/prompts.py +404 -0
- codetrail/assistant/routing.py +86 -0
- codetrail/assistant/runner.py +120 -0
- codetrail/assistant/status.py +186 -0
- codetrail/assistant/usage.py +93 -0
- codetrail/bridge.py +292 -0
- codetrail/cli.py +407 -0
- codetrail/config.py +485 -0
- codetrail/database/__init__.py +60 -0
- codetrail/database/migrations/0001_facts.sql +34 -0
- codetrail/database/migrations/0002_settings.sql +5 -0
- codetrail/database/migrations/0003_learning.sql +34 -0
- codetrail/database/migrations/0004_assistant_usage.sql +24 -0
- codetrail/database/migrations/0005_page_failures.sql +7 -0
- codetrail/database/migrations/0006_metric_values.sql +9 -0
- codetrail/errors.py +5 -0
- codetrail/extract/__init__.py +249 -0
- codetrail/extract/adr.py +98 -0
- codetrail/extract/github_actions.py +140 -0
- codetrail/extract/openapi.py +101 -0
- codetrail/extract/python.py +221 -0
- codetrail/extract/swift.py +204 -0
- codetrail/extract/terraform.py +166 -0
- codetrail/extract/typescript.py +464 -0
- codetrail/facts/__init__.py +112 -0
- codetrail/facts/store.py +209 -0
- codetrail/generate/__init__.py +1 -0
- codetrail/generate/failures.py +31 -0
- codetrail/generate/outline.py +159 -0
- codetrail/generate/revise.py +72 -0
- codetrail/generate/run.py +530 -0
- codetrail/generate/scope.py +135 -0
- codetrail/generate/validate.py +218 -0
- codetrail/guide/__init__.py +147 -0
- codetrail/learn/__init__.py +143 -0
- codetrail/learn/routes.py +139 -0
- codetrail/locales/ar/LC_MESSAGES/codetrail.mo +0 -0
- codetrail/locales/ar/LC_MESSAGES/codetrail.po +2236 -0
- codetrail/locales/codetrail.pot +1942 -0
- codetrail/lock.py +114 -0
- codetrail/metrics/__init__.py +6 -0
- codetrail/metrics/activity.py +96 -0
- codetrail/metrics/churn.py +156 -0
- codetrail/metrics/commits.py +45 -0
- codetrail/metrics/coverage.py +114 -0
- codetrail/metrics/decisions.py +77 -0
- codetrail/metrics/history.py +47 -0
- codetrail/metrics/inventory.py +63 -0
- codetrail/metrics/rationale.py +74 -0
- codetrail/metrics/releases.py +26 -0
- codetrail/metrics/report.py +111 -0
- codetrail/metrics/repository.py +128 -0
- codetrail/metrics/size.py +95 -0
- codetrail/py.typed +0 -0
- codetrail/remove.py +50 -0
- codetrail/repo/__init__.py +4 -0
- codetrail/repo/git.py +56 -0
- codetrail/repo/gitleaks.toml +6 -0
- codetrail/repo/history.py +262 -0
- codetrail/repo/mirror.py +137 -0
- codetrail/repo/refresh.py +41 -0
- codetrail/repo/rules.py +83 -0
- codetrail/repo/secrets.py +145 -0
- codetrail/repo/signal.py +65 -0
- codetrail/repo/source.py +166 -0
- codetrail/search.py +178 -0
- codetrail/server.py +45 -0
- codetrail/system.py +484 -0
- codetrail/update.py +276 -0
- codetrail/web/__init__.py +1 -0
- codetrail/web/app.py +897 -0
- codetrail/web/diagrams.py +414 -0
- codetrail/web/documentation.py +146 -0
- codetrail/web/i18n.py +44 -0
- codetrail/web/navigation.py +127 -0
- codetrail/web/render.py +126 -0
- codetrail/web/repository_view.py +230 -0
- codetrail/web/security.py +171 -0
- codetrail/web/static/brand/favicon.svg +1 -0
- codetrail/web/static/brand/mark.svg +1 -0
- codetrail/web/static/css/base.css +192 -0
- codetrail/web/static/css/components.css +2254 -0
- codetrail/web/static/css/content.css +310 -0
- codetrail/web/static/css/layout.css +410 -0
- codetrail/web/static/css/print.css +42 -0
- codetrail/web/static/css/tokens.css +228 -0
- codetrail/web/static/js/api.js +49 -0
- codetrail/web/static/js/ask.js +182 -0
- codetrail/web/static/js/d2-worker.js +34 -0
- codetrail/web/static/js/d2.js +28 -0
- codetrail/web/static/js/diagram.js +52 -0
- codetrail/web/static/js/documentation.js +24 -0
- codetrail/web/static/js/learning.js +47 -0
- codetrail/web/static/js/main.js +38 -0
- codetrail/web/static/js/outline.js +44 -0
- codetrail/web/static/js/palette.js +211 -0
- codetrail/web/static/js/repository.js +528 -0
- codetrail/web/static/js/resize.js +126 -0
- codetrail/web/static/js/shortcuts.js +87 -0
- codetrail/web/static/js/sidebar.js +194 -0
- codetrail/web/static/js/theme-init.js +27 -0
- codetrail/web/static/js/theme.js +42 -0
- codetrail/web/static/js/update.js +107 -0
- codetrail/web/static/vendor/d2/LICENSE +375 -0
- codetrail/web/static/vendor/d2/LICENSE-elk +263 -0
- codetrail/web/static/vendor/d2/LICENSE-wasm_exec +27 -0
- codetrail/web/static/vendor/d2/VERSION +12 -0
- codetrail/web/static/vendor/d2/d2.wasm +0 -0
- codetrail/web/static/vendor/d2/elk.js +105914 -0
- codetrail/web/static/vendor/d2/wasm_exec.js +656 -0
- codetrail/web/static/vendor/inter/InterVariable-Italic.woff2 +0 -0
- codetrail/web/static/vendor/inter/InterVariable.woff2 +0 -0
- codetrail/web/static/vendor/inter/LICENSE.txt +92 -0
- codetrail/web/static/vendor/inter/VERSION +6 -0
- codetrail/web/static/vendor/noto-sans-arabic/NotoSansArabic-wght.woff2 +0 -0
- codetrail/web/static/vendor/noto-sans-arabic/OFL.txt +93 -0
- codetrail/web/static/vendor/noto-sans-arabic/VERSION +12 -0
- codetrail/web/target_view.py +63 -0
- codetrail/web/templates/answers.html +25 -0
- codetrail/web/templates/area.html +46 -0
- codetrail/web/templates/base.html +37 -0
- codetrail/web/templates/decisions.html +30 -0
- codetrail/web/templates/diagram.html +10 -0
- codetrail/web/templates/digests.html +24 -0
- codetrail/web/templates/documentation.html +247 -0
- codetrail/web/templates/error.html +9 -0
- codetrail/web/templates/estimate.html +48 -0
- codetrail/web/templates/fact.html +29 -0
- codetrail/web/templates/home.html +196 -0
- codetrail/web/templates/layout/ask_panel.html +27 -0
- codetrail/web/templates/layout/header.html +32 -0
- codetrail/web/templates/layout/icons.html +27 -0
- codetrail/web/templates/layout/outline.html +4 -0
- codetrail/web/templates/layout/palette.html +27 -0
- codetrail/web/templates/layout/shortcuts.html +27 -0
- codetrail/web/templates/layout/sidebar.html +79 -0
- codetrail/web/templates/layout/update_dialog.html +18 -0
- codetrail/web/templates/macros.html +13 -0
- codetrail/web/templates/page.html +93 -0
- codetrail/web/templates/progress.html +46 -0
- codetrail/web/templates/repository.html +369 -0
- codetrail/web/templates/search.html +31 -0
- codetrail/web/templates/source.html +15 -0
- codetrail/web/templates/system.html +43 -0
- codetrail/web/templates/system_arrows.html +17 -0
- codetrail/web/templates/system_legend.html +9 -0
- codetrail/web/templates/update_log.html +30 -0
- codetrail/web/trend.py +22 -0
- codetrail_cli-1.4.1.dist-info/METADATA +372 -0
- codetrail_cli-1.4.1.dist-info/RECORD +171 -0
- codetrail_cli-1.4.1.dist-info/WHEEL +4 -0
- codetrail_cli-1.4.1.dist-info/entry_points.txt +3 -0
- codetrail_cli-1.4.1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Extractors turn a target's files into facts (design section 5).
|
|
2
|
+
|
|
3
|
+
An extractor sees one file at a time (`extract`), after being told once which files it will see (`prepare`), and
|
|
4
|
+
then turns the references it found into relations against every entity known from all extractors (`resolve`).
|
|
5
|
+
Per-file extraction keeps a later cache keyed by blob and extractor version possible without changing this interface.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
13
|
+
from dataclasses import dataclass, field, replace
|
|
14
|
+
from pathlib import Path, PurePosixPath
|
|
15
|
+
from typing import Any, Protocol
|
|
16
|
+
|
|
17
|
+
import yaml
|
|
18
|
+
|
|
19
|
+
from codetrail.facts import Entity, Relation, RelationKind, Source
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Reference:
|
|
24
|
+
"""Something a file points at by name, which `resolve` turns into a relation if it can."""
|
|
25
|
+
|
|
26
|
+
source_id: str
|
|
27
|
+
kind: RelationKind
|
|
28
|
+
target: str
|
|
29
|
+
sources: tuple[Source, ...] = ()
|
|
30
|
+
attributes: Mapping[str, Any] = field(default_factory=dict)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class FileFacts:
|
|
35
|
+
path: str
|
|
36
|
+
entities: tuple[Entity, ...] = ()
|
|
37
|
+
references: tuple[Reference, ...] = ()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Resolution:
|
|
42
|
+
relations: list[Relation]
|
|
43
|
+
unresolved: int = 0
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Extractor(Protocol):
|
|
47
|
+
name: str
|
|
48
|
+
version: int
|
|
49
|
+
|
|
50
|
+
def handles(self, path: str) -> bool: ...
|
|
51
|
+
|
|
52
|
+
def prepare(self, paths: Sequence[str]) -> None: ...
|
|
53
|
+
|
|
54
|
+
def extract(self, path: str, content: bytes) -> FileFacts: ...
|
|
55
|
+
|
|
56
|
+
def resolve(self, files: Sequence[FileFacts], known: Mapping[str, Entity]) -> Resolution: ...
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class Extraction:
|
|
61
|
+
entities: list[Entity]
|
|
62
|
+
relations: list[Relation]
|
|
63
|
+
warnings: list[str]
|
|
64
|
+
unresolved: dict[str, int]
|
|
65
|
+
system: dict[str, int] = field(default_factory=dict) # connections the system pass found, per rule
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
DEFAULT_MAX_FILE_BYTES = 1_000_000
|
|
69
|
+
URL_CREDENTIALS = re.compile(r"(://)[^/\s]*@")
|
|
70
|
+
LOCAL_SPECIFIERS = ("npm:", "workspace:", "file:", "link:", "portal:", "patch:")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def without_credentials(text: str) -> str:
|
|
74
|
+
"""A specifier or URL without user or password: scheme://user:pass@host and user:pass@host:path."""
|
|
75
|
+
text = URL_CREDENTIALS.sub(r"\1", text)
|
|
76
|
+
authority = text.split("/", 1)[0]
|
|
77
|
+
if "://" not in text and "@" in authority and ":" in authority and not text.startswith(LOCAL_SPECIFIERS):
|
|
78
|
+
text = text[authority.rindex("@") + 1 :]
|
|
79
|
+
return text
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _NoAliases(yaml.SafeLoader):
|
|
83
|
+
"""safe_load, refusing YAML aliases, so a small file can't expand into an enormous one (a billion-laughs file).
|
|
84
|
+
|
|
85
|
+
YAML 1.1 base-60 integers (`1:30`) stay text, as YAML 1.2 reads them: their conversion takes quadratic time.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
def compose_node(self, parent: Any, index: Any) -> Any:
|
|
89
|
+
if self.check_event(yaml.events.AliasEvent):
|
|
90
|
+
raise yaml.YAMLError("aliases are not read")
|
|
91
|
+
return super().compose_node(parent, index)
|
|
92
|
+
|
|
93
|
+
def construct_yaml_int(self, node: Any) -> Any:
|
|
94
|
+
value = self.construct_scalar(node)
|
|
95
|
+
return value if ":" in value else super().construct_yaml_int(node)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
_NoAliases.add_constructor("tag:yaml.org,2002:int", _NoAliases.construct_yaml_int)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load_yaml_without_aliases(text: str) -> tuple[yaml.Node | None, Any]:
|
|
102
|
+
"""A target's YAML document, read in one pass: its node tree (for line numbers) and its data.
|
|
103
|
+
|
|
104
|
+
Raises yaml.YAMLError on an alias or a tag safe_load refuses, and other errors on malformed scalars or deep
|
|
105
|
+
nesting, so a caller must treat any exception as an unreadable file (the runner makes it a warning).
|
|
106
|
+
"""
|
|
107
|
+
loader = _NoAliases(text)
|
|
108
|
+
try:
|
|
109
|
+
root = loader.get_single_node()
|
|
110
|
+
return root, loader.construct_document(root) if root is not None else None
|
|
111
|
+
finally:
|
|
112
|
+
loader.dispose()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
DEFAULT_MAX_ATTRIBUTE_CHARS = 300
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def run_extractors(
|
|
119
|
+
source: Path,
|
|
120
|
+
paths: Iterable[str],
|
|
121
|
+
extractors: Sequence[Extractor],
|
|
122
|
+
max_file_bytes: int = DEFAULT_MAX_FILE_BYTES,
|
|
123
|
+
max_attribute_chars: int = DEFAULT_MAX_ATTRIBUTE_CHARS,
|
|
124
|
+
) -> Extraction:
|
|
125
|
+
"""Runs every extractor over the listed files of `source`; nothing outside the list is read.
|
|
126
|
+
|
|
127
|
+
Files over `max_file_bytes` are skipped with a warning, and facts whose attributes aren't plain JSON become a
|
|
128
|
+
warning for their file, so no file can stall or break an update. Text attributes come from the target's files and
|
|
129
|
+
reach prompts and pages, so each is cut to `max_attribute_chars`, and a fact whose id is longer is skipped.
|
|
130
|
+
"""
|
|
131
|
+
listed = sorted(paths)
|
|
132
|
+
warnings: list[str] = []
|
|
133
|
+
entities: dict[str, Entity] = {}
|
|
134
|
+
found: dict[str, list[FileFacts]] = {}
|
|
135
|
+
for extractor in extractors:
|
|
136
|
+
handled = [path for path in listed if extractor.handles(path)]
|
|
137
|
+
found[extractor.name] = []
|
|
138
|
+
try:
|
|
139
|
+
extractor.prepare(handled)
|
|
140
|
+
except Exception as error: # one extractor's bad input never fails the update; its files are skipped
|
|
141
|
+
warnings.append(f"{extractor.name}: could not prepare ({type(error).__name__})")
|
|
142
|
+
continue
|
|
143
|
+
for path in handled:
|
|
144
|
+
file = source / PurePosixPath(path)
|
|
145
|
+
if file.stat().st_size > max_file_bytes:
|
|
146
|
+
warnings.append(f"{extractor.name}: {path}: skipped, larger than {max_file_bytes} bytes")
|
|
147
|
+
continue
|
|
148
|
+
try:
|
|
149
|
+
facts = extractor.extract(path, file.read_bytes())
|
|
150
|
+
for attributes in [entity.attributes for entity in facts.entities] + [
|
|
151
|
+
reference.attributes for reference in facts.references
|
|
152
|
+
]:
|
|
153
|
+
json.dumps(dict(attributes))
|
|
154
|
+
except Exception as error:
|
|
155
|
+
warnings.append(f"{extractor.name}: {path}: could not be read ({type(error).__name__})")
|
|
156
|
+
continue
|
|
157
|
+
facts = _cut_attributes(facts, max_attribute_chars)
|
|
158
|
+
if any(len(entity.id) > max_attribute_chars for entity in facts.entities):
|
|
159
|
+
warnings.append(
|
|
160
|
+
f"{extractor.name}: {path}: skipped a fact whose id is longer than {max_attribute_chars} characters"
|
|
161
|
+
)
|
|
162
|
+
facts = replace(facts, entities=tuple(e for e in facts.entities if len(e.id) <= max_attribute_chars))
|
|
163
|
+
found[extractor.name].append(facts)
|
|
164
|
+
for entity in facts.entities:
|
|
165
|
+
_merge(entities, entity, extractor.name, path, warnings)
|
|
166
|
+
relations: dict[tuple[str, str, str], Relation] = {}
|
|
167
|
+
unresolved: dict[str, int] = {}
|
|
168
|
+
for extractor in extractors:
|
|
169
|
+
try:
|
|
170
|
+
resolution = extractor.resolve(found[extractor.name], entities)
|
|
171
|
+
except Exception as error: # its entities stay; only its relations are lost
|
|
172
|
+
warnings.append(f"{extractor.name}: could not resolve references ({type(error).__name__})")
|
|
173
|
+
continue
|
|
174
|
+
dangling = [r for r in resolution.relations if r.source_id not in entities or r.target_id not in entities]
|
|
175
|
+
for relation in resolution.relations:
|
|
176
|
+
if relation not in dangling:
|
|
177
|
+
relations.setdefault(relation.key, relation)
|
|
178
|
+
if resolution.unresolved or dangling:
|
|
179
|
+
unresolved[extractor.name] = resolution.unresolved + len(dangling)
|
|
180
|
+
return Extraction(
|
|
181
|
+
entities=sorted(entities.values(), key=lambda entity: entity.id),
|
|
182
|
+
relations=sorted(relations.values(), key=lambda relation: relation.key),
|
|
183
|
+
warnings=warnings,
|
|
184
|
+
unresolved=unresolved,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def check_facts(
|
|
189
|
+
entities: Sequence[Entity], relations: Sequence[Relation], limit: int
|
|
190
|
+
) -> tuple[list[Entity], list[Relation], list[str]]:
|
|
191
|
+
"""The checks `run_extractors` applies, for facts made outside an extractor (the system pass, design 17.3).
|
|
192
|
+
|
|
193
|
+
Attributes must be plain JSON and are cut to `limit` characters; a fact whose id is longer is dropped, and so is
|
|
194
|
+
every relation whose ends are no longer both kept. Warnings name no content.
|
|
195
|
+
"""
|
|
196
|
+
kept: list[Entity] = []
|
|
197
|
+
warnings: list[str] = []
|
|
198
|
+
for entity in entities:
|
|
199
|
+
try:
|
|
200
|
+
json.dumps(dict(entity.attributes))
|
|
201
|
+
except TypeError, ValueError:
|
|
202
|
+
warnings.append(f"system: a fact's attributes weren't plain data ({entity.kind})")
|
|
203
|
+
continue
|
|
204
|
+
if len(entity.id) > limit:
|
|
205
|
+
warnings.append(f"system: skipped a fact whose id is longer than {limit} characters ({entity.kind})")
|
|
206
|
+
continue
|
|
207
|
+
kept.append(replace(entity, attributes=_cut(entity.attributes, limit)))
|
|
208
|
+
ids = {entity.id for entity in kept}
|
|
209
|
+
related = []
|
|
210
|
+
for relation in relations:
|
|
211
|
+
try:
|
|
212
|
+
json.dumps(dict(relation.attributes))
|
|
213
|
+
except TypeError, ValueError:
|
|
214
|
+
continue
|
|
215
|
+
if relation.source_id in ids and relation.target_id in ids:
|
|
216
|
+
related.append(replace(relation, attributes=_cut(relation.attributes, limit)))
|
|
217
|
+
return kept, related, warnings
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _cut(attributes: Mapping[str, Any], limit: int) -> dict[str, Any]:
|
|
221
|
+
def cut(value: Any) -> Any:
|
|
222
|
+
if isinstance(value, str) and len(value) > limit:
|
|
223
|
+
return value[: limit - 1] + "…"
|
|
224
|
+
return [cut(item) for item in value] if isinstance(value, list) else value
|
|
225
|
+
|
|
226
|
+
return {key: cut(value) for key, value in attributes.items()}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _cut_attributes(facts: FileFacts, limit: int) -> FileFacts:
|
|
230
|
+
def cut_all(attributes: Mapping[str, Any]) -> dict[str, Any]:
|
|
231
|
+
return _cut(attributes, limit)
|
|
232
|
+
|
|
233
|
+
return replace(
|
|
234
|
+
facts,
|
|
235
|
+
entities=tuple(replace(entity, attributes=cut_all(entity.attributes)) for entity in facts.entities),
|
|
236
|
+
references=tuple(replace(item, attributes=cut_all(item.attributes)) for item in facts.references),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _merge(entities: dict[str, Entity], entity: Entity, extractor: str, path: str, warnings: list[str]) -> None:
|
|
241
|
+
existing = entities.get(entity.id)
|
|
242
|
+
if existing is None:
|
|
243
|
+
entities[entity.id] = entity
|
|
244
|
+
return
|
|
245
|
+
if dict(existing.attributes) != dict(entity.attributes):
|
|
246
|
+
first = existing.sources[0].path if existing.sources else "another file"
|
|
247
|
+
warnings.append(f"{extractor}: {entity.id} has different attributes in {path}; kept {first}'s")
|
|
248
|
+
sources = existing.sources + tuple(source for source in entity.sources if source not in existing.sources)
|
|
249
|
+
entities[entity.id] = Entity(existing.id, existing.kind, existing.attributes, sources)
|
codetrail/extract/adr.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""The adr extractor: decisions, their status, and what supersedes what (design section 5.2).
|
|
2
|
+
|
|
3
|
+
A decision needs a number, from a heading such as `# 0023. Title` or a file name such as `0023-title.md`; files
|
|
4
|
+
without one (an index, a template) are skipped. Its status comes from a `Status:` line or the first line under a
|
|
5
|
+
`## Status` heading. The body stays text for Claude to read; only this metadata becomes facts.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Mapping, Sequence
|
|
12
|
+
from pathlib import PurePosixPath
|
|
13
|
+
|
|
14
|
+
from pathspec import GitIgnoreSpec
|
|
15
|
+
|
|
16
|
+
from codetrail.extract import FileFacts, Reference, Resolution
|
|
17
|
+
from codetrail.facts import Entity, EntityKind, Relation, RelationKind, Source
|
|
18
|
+
|
|
19
|
+
# Lines are right-stripped before matching, so no lazy group meets trailing whitespace (no quadratic backtracking).
|
|
20
|
+
HEADING = re.compile(r"^#\s+(?:ADR[- ]?)?(\d+)[.:]?\s+(.+)$", re.IGNORECASE)
|
|
21
|
+
FILE_NUMBER = re.compile(r"^(\d+)[-_]")
|
|
22
|
+
STATUS_LINE = re.compile(r"^(?:[-*]\s*)?\**status\**\s*:\s*\**\s*(.+)$", re.IGNORECASE)
|
|
23
|
+
DATE_LINE = re.compile(r"^(?:[-*]\s*)?\**date\**\s*:\s*\**\s*(\d{4}-\d{2}-\d{2})", re.IGNORECASE)
|
|
24
|
+
SUPERSEDED_BY = re.compile(r"superseded\s+by\s+(?:ADR[- ]?)?(\d+)", re.IGNORECASE)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def decision_id(number: str) -> str:
|
|
28
|
+
return f"decision:ADR-{int(number):04d}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AdrExtractor:
|
|
32
|
+
name = "adr"
|
|
33
|
+
version = 1
|
|
34
|
+
|
|
35
|
+
def __init__(self, globs: Sequence[str]) -> None:
|
|
36
|
+
self._spec = GitIgnoreSpec.from_lines(globs)
|
|
37
|
+
|
|
38
|
+
def handles(self, path: str) -> bool:
|
|
39
|
+
return path.endswith(".md") and self._spec.match_file(path)
|
|
40
|
+
|
|
41
|
+
def prepare(self, paths: Sequence[str]) -> None:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
def extract(self, path: str, content: bytes) -> FileFacts:
|
|
45
|
+
lines = [line.rstrip() for line in content.decode("utf-8", "replace").splitlines()]
|
|
46
|
+
number, title = None, None
|
|
47
|
+
for line in lines:
|
|
48
|
+
heading = HEADING.match(line)
|
|
49
|
+
if heading:
|
|
50
|
+
number, title = heading.group(1), heading.group(2)
|
|
51
|
+
break
|
|
52
|
+
if number is None:
|
|
53
|
+
from_name = FILE_NUMBER.match(PurePosixPath(path).name)
|
|
54
|
+
if from_name is None:
|
|
55
|
+
return FileFacts(path)
|
|
56
|
+
number = from_name.group(1)
|
|
57
|
+
title = next(
|
|
58
|
+
(line.lstrip("# ").strip() for line in lines if line.startswith("# ")), PurePosixPath(path).stem
|
|
59
|
+
)
|
|
60
|
+
status_text, date = _status(lines), _date(lines)
|
|
61
|
+
attributes = {"number": f"{int(number):04d}", "title": title or "", "path": path}
|
|
62
|
+
if status_text:
|
|
63
|
+
attributes["status"] = re.split(r"[\s(]", status_text.strip().lower(), maxsplit=1)[0]
|
|
64
|
+
if date:
|
|
65
|
+
attributes["date"] = date
|
|
66
|
+
identity = decision_id(number)
|
|
67
|
+
references = []
|
|
68
|
+
superseded = SUPERSEDED_BY.search(status_text or "")
|
|
69
|
+
if superseded:
|
|
70
|
+
references.append(Reference(decision_id(superseded.group(1)), RelationKind.SUPERSEDES, identity,
|
|
71
|
+
(Source(path),))) # fmt: skip
|
|
72
|
+
return FileFacts(path, (Entity(identity, EntityKind.DECISION, attributes, (Source(path),)),), tuple(references))
|
|
73
|
+
|
|
74
|
+
def resolve(self, files: Sequence[FileFacts], known: Mapping[str, Entity]) -> Resolution:
|
|
75
|
+
relations = [
|
|
76
|
+
Relation(reference.source_id, reference.kind, reference.target, {}, reference.sources)
|
|
77
|
+
for file in files
|
|
78
|
+
for reference in file.references
|
|
79
|
+
]
|
|
80
|
+
return Resolution(relations)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _status(lines: list[str]) -> str | None:
|
|
84
|
+
for index, line in enumerate(lines):
|
|
85
|
+
match = STATUS_LINE.match(line.strip())
|
|
86
|
+
if match:
|
|
87
|
+
return match.group(1)
|
|
88
|
+
if re.match(r"^#{2,}\s*status\s*$", line.strip(), re.IGNORECASE):
|
|
89
|
+
return next((following.strip() for following in lines[index + 1 :] if following.strip()), None)
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _date(lines: list[str]) -> str | None:
|
|
94
|
+
for line in lines:
|
|
95
|
+
match = DATE_LINE.match(line.strip())
|
|
96
|
+
if match:
|
|
97
|
+
return match.group(1)
|
|
98
|
+
return None
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""The github_actions extractor: deploy evidence from GitHub Actions workflows, and nothing else (design 17.2).
|
|
2
|
+
|
|
3
|
+
Workflows are read with a SafeLoader that refuses aliases. Each step that deploys (a known command, or a known deploy
|
|
4
|
+
action) becomes a `deployment` fact with its kind, its target name when one is given, the folder it runs in and its
|
|
5
|
+
line, which comes from the parser. Step text, `env` values and `secrets.*` references are never recorded: only the
|
|
6
|
+
kind, a plain target name, a job id in GitHub's grammar and a folder of plain path segments.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Mapping, Sequence
|
|
13
|
+
from pathlib import PurePosixPath
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from codetrail.extract import FileFacts, Resolution, load_yaml_without_aliases
|
|
19
|
+
from codetrail.facts import Entity, EntityKind, Relation, Source
|
|
20
|
+
|
|
21
|
+
# Only plain names reach a fact, so nothing secret-shaped, templated or instruction-like does.
|
|
22
|
+
PLAIN_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9-]{0,62}")
|
|
23
|
+
JOB_ID = re.compile(r"[A-Za-z_][A-Za-z0-9_-]{0,99}")
|
|
24
|
+
SEGMENT = re.compile(r"[A-Za-z0-9._-]+")
|
|
25
|
+
COMMANDS = [
|
|
26
|
+
("cloudflare", re.compile(r"\bwrangler(?:@[\w.^~-]+)?\s+(?:deploy|publish)\b")),
|
|
27
|
+
(
|
|
28
|
+
"google_cloud",
|
|
29
|
+
re.compile(r"\bgcloud\s+run\s+(?:deploy|services\s+replace|jobs\s+deploy)\s+(?P<target>[^\s\\]+)?"),
|
|
30
|
+
),
|
|
31
|
+
("terraform", re.compile(r"\bterraform\s+apply\b")),
|
|
32
|
+
("fly", re.compile(r"\bfly(?:ctl)?\s+deploy\b")),
|
|
33
|
+
("npm_script", re.compile(r"\b(?:npm\s+run|pnpm\s+run|yarn(?:\s+run)?)\s+deploy\b")), # `pnpm deploy` copies files
|
|
34
|
+
]
|
|
35
|
+
ACTIONS = {
|
|
36
|
+
"cloudflare/wrangler-action": "cloudflare",
|
|
37
|
+
"google-github-actions/deploy-cloudrun": "google_cloud",
|
|
38
|
+
"superfly/flyctl-actions": "fly",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class GitHubActionsExtractor:
|
|
43
|
+
name = "github_actions"
|
|
44
|
+
version = 1
|
|
45
|
+
|
|
46
|
+
def __init__(self, max_steps: int = 5000) -> None:
|
|
47
|
+
self._max_steps = max_steps
|
|
48
|
+
|
|
49
|
+
def handles(self, path: str) -> bool:
|
|
50
|
+
return path.startswith(".github/workflows/") and path.endswith((".yml", ".yaml")) and path.count("/") == 2
|
|
51
|
+
|
|
52
|
+
def prepare(self, paths: Sequence[str]) -> None:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
def extract(self, path: str, content: bytes) -> FileFacts:
|
|
56
|
+
root, data = load_yaml_without_aliases(content.decode("utf-8"))
|
|
57
|
+
jobs = data.get("jobs") if isinstance(data, dict) else None
|
|
58
|
+
if not isinstance(jobs, dict) or root is None:
|
|
59
|
+
return FileFacts(path)
|
|
60
|
+
lines = _step_lines(root)
|
|
61
|
+
entities: list[Entity] = []
|
|
62
|
+
visited = 0
|
|
63
|
+
for job_name, job in jobs.items():
|
|
64
|
+
if not isinstance(job_name, str) or not JOB_ID.fullmatch(job_name):
|
|
65
|
+
continue # GitHub's job-id grammar; anything else isn't a job, and its text never reaches a fact
|
|
66
|
+
if not isinstance(job, dict) or not isinstance(job.get("steps"), list):
|
|
67
|
+
continue
|
|
68
|
+
defaults = job.get("defaults")
|
|
69
|
+
run_defaults = defaults.get("run") if isinstance(defaults, dict) else None
|
|
70
|
+
job_folder = _folder(run_defaults.get("working-directory")) if isinstance(run_defaults, dict) else ""
|
|
71
|
+
for index, step in enumerate(job["steps"]):
|
|
72
|
+
visited += 1
|
|
73
|
+
if visited > self._max_steps:
|
|
74
|
+
return FileFacts(path, tuple(entities))
|
|
75
|
+
found = _deploy(step) if isinstance(step, dict) else None
|
|
76
|
+
if found is None:
|
|
77
|
+
continue
|
|
78
|
+
kind, target = found
|
|
79
|
+
folder = _folder(step.get("working-directory")) if "working-directory" in step else job_folder
|
|
80
|
+
attributes: dict[str, Any] = {"kind": kind, "job": job_name}
|
|
81
|
+
if folder is not None: # an unusable folder is no folder: no arrow is drawn on a guess
|
|
82
|
+
attributes["folder"] = folder
|
|
83
|
+
if target and PLAIN_NAME.fullmatch(target):
|
|
84
|
+
attributes["target"] = target
|
|
85
|
+
line = lines.get((job_name, index))
|
|
86
|
+
entities.append(Entity(f"deployment:{path}#{job_name}/{index}", EntityKind.DEPLOYMENT, attributes,
|
|
87
|
+
(Source(path, line, line) if line else Source(path),))) # fmt: skip
|
|
88
|
+
return FileFacts(path, tuple(entities))
|
|
89
|
+
|
|
90
|
+
def resolve(self, files: Sequence[FileFacts], known: Mapping[str, Any]) -> Resolution:
|
|
91
|
+
return Resolution(list[Relation]())
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _step_lines(root: yaml.Node) -> dict[tuple[str, int], int]:
|
|
95
|
+
"""Each step's line, from the parser's own positions: exact, and one pass over the document."""
|
|
96
|
+
lines: dict[tuple[str, int], int] = {}
|
|
97
|
+
jobs = _value(root, "jobs")
|
|
98
|
+
if not isinstance(jobs, yaml.MappingNode):
|
|
99
|
+
return lines
|
|
100
|
+
for key, job in jobs.value:
|
|
101
|
+
steps = _value(job, "steps")
|
|
102
|
+
if isinstance(key, yaml.ScalarNode) and isinstance(steps, yaml.SequenceNode):
|
|
103
|
+
for index, step in enumerate(steps.value):
|
|
104
|
+
lines[(str(key.value), index)] = step.start_mark.line + 1
|
|
105
|
+
return lines
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _value(node: yaml.Node, name: str) -> yaml.Node | None:
|
|
109
|
+
if not isinstance(node, yaml.MappingNode):
|
|
110
|
+
return None
|
|
111
|
+
return next((value for key, value in node.value if isinstance(key, yaml.ScalarNode) and key.value == name), None)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _folder(value: Any) -> str | None:
|
|
115
|
+
"""A working directory as a repository folder ("" is the root), or None unless every part is a plain segment."""
|
|
116
|
+
if value is None:
|
|
117
|
+
return ""
|
|
118
|
+
if not isinstance(value, str) or value.startswith("/"):
|
|
119
|
+
return None
|
|
120
|
+
parts = [part for part in PurePosixPath(value).parts if part != "."]
|
|
121
|
+
if any(part == ".." or not SEGMENT.fullmatch(part) for part in parts):
|
|
122
|
+
return None # templated, escaping, or not a plain folder name
|
|
123
|
+
return "/".join(parts)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _deploy(step: Mapping[str, Any]) -> tuple[str, str | None] | None:
|
|
127
|
+
uses = step.get("uses")
|
|
128
|
+
if isinstance(uses, str):
|
|
129
|
+
action = uses.split("@", 1)[0]
|
|
130
|
+
if action in ACTIONS:
|
|
131
|
+
settings = step.get("with") if isinstance(step.get("with"), dict) else {}
|
|
132
|
+
target = settings.get("service") if isinstance(settings, dict) else None
|
|
133
|
+
return ACTIONS[action], target if isinstance(target, str) else None
|
|
134
|
+
run = step.get("run")
|
|
135
|
+
if isinstance(run, str):
|
|
136
|
+
for kind, pattern in COMMANDS:
|
|
137
|
+
match = pattern.search(run)
|
|
138
|
+
if match:
|
|
139
|
+
return kind, match.groupdict().get("target")
|
|
140
|
+
return None
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""The openapi extractor: an OpenAPI document's routes, its schemas, and what uses what (design section 5.2).
|
|
2
|
+
|
|
3
|
+
Only local references (`#/components/schemas/<name>`) become relations; a remote or file `$ref` is never followed.
|
|
4
|
+
Documents are walked iteratively, to a fixed depth, so no nesting can exhaust the stack. YAML aliases are refused:
|
|
5
|
+
they let a few hundred bytes stand for an unbounded tree (or a loop), and OpenAPI documents don't need them.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
12
|
+
from pathlib import PurePosixPath
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from pathspec import GitIgnoreSpec
|
|
16
|
+
|
|
17
|
+
from codetrail.extract import FileFacts, Reference, Resolution, load_yaml_without_aliases
|
|
18
|
+
from codetrail.facts import Entity, EntityKind, Relation, RelationKind, Source
|
|
19
|
+
|
|
20
|
+
METHODS = ("get", "put", "post", "delete", "patch", "head", "options", "trace")
|
|
21
|
+
SCHEMA_REF = "#/components/schemas/"
|
|
22
|
+
MAX_DEPTH = 64
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def local_refs(value: Any) -> Iterator[str]:
|
|
26
|
+
"""The schema names of every local $ref inside `value`, walked iteratively to MAX_DEPTH."""
|
|
27
|
+
stack: list[tuple[Any, int]] = [(value, 0)]
|
|
28
|
+
while stack:
|
|
29
|
+
node, depth = stack.pop()
|
|
30
|
+
if depth > MAX_DEPTH:
|
|
31
|
+
continue
|
|
32
|
+
if isinstance(node, Mapping):
|
|
33
|
+
ref = node.get("$ref")
|
|
34
|
+
if isinstance(ref, str) and ref.startswith(SCHEMA_REF):
|
|
35
|
+
yield ref.removeprefix(SCHEMA_REF)
|
|
36
|
+
stack.extend((child, depth + 1) for child in node.values())
|
|
37
|
+
elif isinstance(node, list):
|
|
38
|
+
stack.extend((child, depth + 1) for child in node)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _text(value: Any) -> str:
|
|
42
|
+
return value if isinstance(value, str) else ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class OpenApiExtractor:
|
|
46
|
+
name = "openapi"
|
|
47
|
+
version = 1
|
|
48
|
+
|
|
49
|
+
def __init__(self, globs: Sequence[str]) -> None:
|
|
50
|
+
self._spec = GitIgnoreSpec.from_lines(globs)
|
|
51
|
+
|
|
52
|
+
def handles(self, path: str) -> bool:
|
|
53
|
+
return PurePosixPath(path).suffix in (".json", ".yaml", ".yml") and self._spec.match_file(path)
|
|
54
|
+
|
|
55
|
+
def prepare(self, paths: Sequence[str]) -> None:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
def extract(self, path: str, content: bytes) -> FileFacts:
|
|
59
|
+
text = content.decode("utf-8")
|
|
60
|
+
document = json.loads(text) if path.endswith(".json") else load_yaml_without_aliases(text)[1]
|
|
61
|
+
if not isinstance(document, Mapping) or not ("openapi" in document or "swagger" in document):
|
|
62
|
+
return FileFacts(path)
|
|
63
|
+
entities: list[Entity] = []
|
|
64
|
+
references: list[Reference] = []
|
|
65
|
+
source = (Source(path),)
|
|
66
|
+
for route_path, operations in (document.get("paths") or {}).items():
|
|
67
|
+
if not isinstance(operations, Mapping):
|
|
68
|
+
continue
|
|
69
|
+
for method, operation in operations.items():
|
|
70
|
+
if method not in METHODS or not isinstance(operation, Mapping):
|
|
71
|
+
continue
|
|
72
|
+
route_id = f"route:{method.upper()} {route_path}"
|
|
73
|
+
attributes = {
|
|
74
|
+
"method": method.upper(), "path": str(route_path),
|
|
75
|
+
"operation_id": _text(operation.get("operationId")), "summary": _text(operation.get("summary")),
|
|
76
|
+
"tags": [str(tag) for tag in operation.get("tags", []) if isinstance(tag, str)], "document": path,
|
|
77
|
+
} # fmt: skip
|
|
78
|
+
entities.append(Entity(route_id, EntityKind.ROUTE, attributes, source))
|
|
79
|
+
for name in sorted(set(local_refs(operation))):
|
|
80
|
+
references.append(Reference(route_id, RelationKind.USES_SCHEMA, f"schema:{name}", source))
|
|
81
|
+
schemas = ((document.get("components") or {}).get("schemas")) or {}
|
|
82
|
+
for name, schema in schemas.items():
|
|
83
|
+
schema_id = f"schema:{name}"
|
|
84
|
+
properties = schema.get("properties", {}) if isinstance(schema, Mapping) else {}
|
|
85
|
+
attributes = {"document": path, "properties": sorted(str(key) for key in properties)[:50]}
|
|
86
|
+
entities.append(Entity(schema_id, EntityKind.SCHEMA, attributes, source))
|
|
87
|
+
for referenced in sorted(set(local_refs(schema)) - {str(name)}):
|
|
88
|
+
references.append(Reference(schema_id, RelationKind.REFERENCES, f"schema:{referenced}", source))
|
|
89
|
+
return FileFacts(path, tuple(entities), tuple(references))
|
|
90
|
+
|
|
91
|
+
def resolve(self, files: Sequence[FileFacts], known: Mapping[str, Entity]) -> Resolution:
|
|
92
|
+
relations, unresolved = [], 0
|
|
93
|
+
for file in files:
|
|
94
|
+
for reference in file.references:
|
|
95
|
+
if reference.target in known:
|
|
96
|
+
relations.append(
|
|
97
|
+
Relation(reference.source_id, reference.kind, reference.target, {}, reference.sources)
|
|
98
|
+
)
|
|
99
|
+
else:
|
|
100
|
+
unresolved += 1
|
|
101
|
+
return Resolution(relations, unresolved)
|