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.
Files changed (171) hide show
  1. codetrail/__init__.py +3 -0
  2. codetrail/__main__.py +5 -0
  3. codetrail/agents/__init__.py +4 -0
  4. codetrail/agents/convert.py +92 -0
  5. codetrail/agents/export.py +284 -0
  6. codetrail/agents/instructions.py +42 -0
  7. codetrail/agents/protocol.py +111 -0
  8. codetrail/agents/tools.py +185 -0
  9. codetrail/assistant/__init__.py +161 -0
  10. codetrail/assistant/claude_code.py +330 -0
  11. codetrail/assistant/codex.py +260 -0
  12. codetrail/assistant/environment.py +66 -0
  13. codetrail/assistant/estimate.py +225 -0
  14. codetrail/assistant/fake.py +74 -0
  15. codetrail/assistant/guard.py +80 -0
  16. codetrail/assistant/guard_hook.py +58 -0
  17. codetrail/assistant/local.py +245 -0
  18. codetrail/assistant/local_tools.py +177 -0
  19. codetrail/assistant/prompts.py +404 -0
  20. codetrail/assistant/routing.py +86 -0
  21. codetrail/assistant/runner.py +120 -0
  22. codetrail/assistant/status.py +186 -0
  23. codetrail/assistant/usage.py +93 -0
  24. codetrail/bridge.py +292 -0
  25. codetrail/cli.py +407 -0
  26. codetrail/config.py +485 -0
  27. codetrail/database/__init__.py +60 -0
  28. codetrail/database/migrations/0001_facts.sql +34 -0
  29. codetrail/database/migrations/0002_settings.sql +5 -0
  30. codetrail/database/migrations/0003_learning.sql +34 -0
  31. codetrail/database/migrations/0004_assistant_usage.sql +24 -0
  32. codetrail/database/migrations/0005_page_failures.sql +7 -0
  33. codetrail/database/migrations/0006_metric_values.sql +9 -0
  34. codetrail/errors.py +5 -0
  35. codetrail/extract/__init__.py +249 -0
  36. codetrail/extract/adr.py +98 -0
  37. codetrail/extract/github_actions.py +140 -0
  38. codetrail/extract/openapi.py +101 -0
  39. codetrail/extract/python.py +221 -0
  40. codetrail/extract/swift.py +204 -0
  41. codetrail/extract/terraform.py +166 -0
  42. codetrail/extract/typescript.py +464 -0
  43. codetrail/facts/__init__.py +112 -0
  44. codetrail/facts/store.py +209 -0
  45. codetrail/generate/__init__.py +1 -0
  46. codetrail/generate/failures.py +31 -0
  47. codetrail/generate/outline.py +159 -0
  48. codetrail/generate/revise.py +72 -0
  49. codetrail/generate/run.py +530 -0
  50. codetrail/generate/scope.py +135 -0
  51. codetrail/generate/validate.py +218 -0
  52. codetrail/guide/__init__.py +147 -0
  53. codetrail/learn/__init__.py +143 -0
  54. codetrail/learn/routes.py +139 -0
  55. codetrail/locales/ar/LC_MESSAGES/codetrail.mo +0 -0
  56. codetrail/locales/ar/LC_MESSAGES/codetrail.po +2236 -0
  57. codetrail/locales/codetrail.pot +1942 -0
  58. codetrail/lock.py +114 -0
  59. codetrail/metrics/__init__.py +6 -0
  60. codetrail/metrics/activity.py +96 -0
  61. codetrail/metrics/churn.py +156 -0
  62. codetrail/metrics/commits.py +45 -0
  63. codetrail/metrics/coverage.py +114 -0
  64. codetrail/metrics/decisions.py +77 -0
  65. codetrail/metrics/history.py +47 -0
  66. codetrail/metrics/inventory.py +63 -0
  67. codetrail/metrics/rationale.py +74 -0
  68. codetrail/metrics/releases.py +26 -0
  69. codetrail/metrics/report.py +111 -0
  70. codetrail/metrics/repository.py +128 -0
  71. codetrail/metrics/size.py +95 -0
  72. codetrail/py.typed +0 -0
  73. codetrail/remove.py +50 -0
  74. codetrail/repo/__init__.py +4 -0
  75. codetrail/repo/git.py +56 -0
  76. codetrail/repo/gitleaks.toml +6 -0
  77. codetrail/repo/history.py +262 -0
  78. codetrail/repo/mirror.py +137 -0
  79. codetrail/repo/refresh.py +41 -0
  80. codetrail/repo/rules.py +83 -0
  81. codetrail/repo/secrets.py +145 -0
  82. codetrail/repo/signal.py +65 -0
  83. codetrail/repo/source.py +166 -0
  84. codetrail/search.py +178 -0
  85. codetrail/server.py +45 -0
  86. codetrail/system.py +484 -0
  87. codetrail/update.py +276 -0
  88. codetrail/web/__init__.py +1 -0
  89. codetrail/web/app.py +897 -0
  90. codetrail/web/diagrams.py +414 -0
  91. codetrail/web/documentation.py +146 -0
  92. codetrail/web/i18n.py +44 -0
  93. codetrail/web/navigation.py +127 -0
  94. codetrail/web/render.py +126 -0
  95. codetrail/web/repository_view.py +230 -0
  96. codetrail/web/security.py +171 -0
  97. codetrail/web/static/brand/favicon.svg +1 -0
  98. codetrail/web/static/brand/mark.svg +1 -0
  99. codetrail/web/static/css/base.css +192 -0
  100. codetrail/web/static/css/components.css +2254 -0
  101. codetrail/web/static/css/content.css +310 -0
  102. codetrail/web/static/css/layout.css +410 -0
  103. codetrail/web/static/css/print.css +42 -0
  104. codetrail/web/static/css/tokens.css +228 -0
  105. codetrail/web/static/js/api.js +49 -0
  106. codetrail/web/static/js/ask.js +182 -0
  107. codetrail/web/static/js/d2-worker.js +34 -0
  108. codetrail/web/static/js/d2.js +28 -0
  109. codetrail/web/static/js/diagram.js +52 -0
  110. codetrail/web/static/js/documentation.js +24 -0
  111. codetrail/web/static/js/learning.js +47 -0
  112. codetrail/web/static/js/main.js +38 -0
  113. codetrail/web/static/js/outline.js +44 -0
  114. codetrail/web/static/js/palette.js +211 -0
  115. codetrail/web/static/js/repository.js +528 -0
  116. codetrail/web/static/js/resize.js +126 -0
  117. codetrail/web/static/js/shortcuts.js +87 -0
  118. codetrail/web/static/js/sidebar.js +194 -0
  119. codetrail/web/static/js/theme-init.js +27 -0
  120. codetrail/web/static/js/theme.js +42 -0
  121. codetrail/web/static/js/update.js +107 -0
  122. codetrail/web/static/vendor/d2/LICENSE +375 -0
  123. codetrail/web/static/vendor/d2/LICENSE-elk +263 -0
  124. codetrail/web/static/vendor/d2/LICENSE-wasm_exec +27 -0
  125. codetrail/web/static/vendor/d2/VERSION +12 -0
  126. codetrail/web/static/vendor/d2/d2.wasm +0 -0
  127. codetrail/web/static/vendor/d2/elk.js +105914 -0
  128. codetrail/web/static/vendor/d2/wasm_exec.js +656 -0
  129. codetrail/web/static/vendor/inter/InterVariable-Italic.woff2 +0 -0
  130. codetrail/web/static/vendor/inter/InterVariable.woff2 +0 -0
  131. codetrail/web/static/vendor/inter/LICENSE.txt +92 -0
  132. codetrail/web/static/vendor/inter/VERSION +6 -0
  133. codetrail/web/static/vendor/noto-sans-arabic/NotoSansArabic-wght.woff2 +0 -0
  134. codetrail/web/static/vendor/noto-sans-arabic/OFL.txt +93 -0
  135. codetrail/web/static/vendor/noto-sans-arabic/VERSION +12 -0
  136. codetrail/web/target_view.py +63 -0
  137. codetrail/web/templates/answers.html +25 -0
  138. codetrail/web/templates/area.html +46 -0
  139. codetrail/web/templates/base.html +37 -0
  140. codetrail/web/templates/decisions.html +30 -0
  141. codetrail/web/templates/diagram.html +10 -0
  142. codetrail/web/templates/digests.html +24 -0
  143. codetrail/web/templates/documentation.html +247 -0
  144. codetrail/web/templates/error.html +9 -0
  145. codetrail/web/templates/estimate.html +48 -0
  146. codetrail/web/templates/fact.html +29 -0
  147. codetrail/web/templates/home.html +196 -0
  148. codetrail/web/templates/layout/ask_panel.html +27 -0
  149. codetrail/web/templates/layout/header.html +32 -0
  150. codetrail/web/templates/layout/icons.html +27 -0
  151. codetrail/web/templates/layout/outline.html +4 -0
  152. codetrail/web/templates/layout/palette.html +27 -0
  153. codetrail/web/templates/layout/shortcuts.html +27 -0
  154. codetrail/web/templates/layout/sidebar.html +79 -0
  155. codetrail/web/templates/layout/update_dialog.html +18 -0
  156. codetrail/web/templates/macros.html +13 -0
  157. codetrail/web/templates/page.html +93 -0
  158. codetrail/web/templates/progress.html +46 -0
  159. codetrail/web/templates/repository.html +369 -0
  160. codetrail/web/templates/search.html +31 -0
  161. codetrail/web/templates/source.html +15 -0
  162. codetrail/web/templates/system.html +43 -0
  163. codetrail/web/templates/system_arrows.html +17 -0
  164. codetrail/web/templates/system_legend.html +9 -0
  165. codetrail/web/templates/update_log.html +30 -0
  166. codetrail/web/trend.py +22 -0
  167. codetrail_cli-1.4.1.dist-info/METADATA +372 -0
  168. codetrail_cli-1.4.1.dist-info/RECORD +171 -0
  169. codetrail_cli-1.4.1.dist-info/WHEEL +4 -0
  170. codetrail_cli-1.4.1.dist-info/entry_points.txt +3 -0
  171. 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)
@@ -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)