chipgraph 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. chipgraph/__init__.py +7 -0
  2. chipgraph/adapters/__init__.py +1 -0
  3. chipgraph/adapters/format/__init__.py +6 -0
  4. chipgraph/adapters/format/__main__.py +16 -0
  5. chipgraph/adapters/format/_yaml_lines.py +54 -0
  6. chipgraph/adapters/format/chip_yaml.py +323 -0
  7. chipgraph/adapters/format/qsoc_contract.py +246 -0
  8. chipgraph/adapters/llm/__init__.py +58 -0
  9. chipgraph/adapters/llm/_budget.py +173 -0
  10. chipgraph/adapters/llm/_errors.py +50 -0
  11. chipgraph/adapters/llm/_nda.py +30 -0
  12. chipgraph/adapters/llm/_retry.py +85 -0
  13. chipgraph/adapters/llm/anthropic.py +247 -0
  14. chipgraph/adapters/llm/decide_backend.py +179 -0
  15. chipgraph/adapters/llm/fake.py +87 -0
  16. chipgraph/adapters/llm/guard.py +68 -0
  17. chipgraph/adapters/parser/__init__.py +22 -0
  18. chipgraph/adapters/parser/cocotb.py +304 -0
  19. chipgraph/adapters/parser/generic_regex.py +75 -0
  20. chipgraph/adapters/parser/verible.py +55 -0
  21. chipgraph/adapters/parser/verilator.py +46 -0
  22. chipgraph/adapters/review/__init__.py +7 -0
  23. chipgraph/adapters/review/file.py +123 -0
  24. chipgraph/adapters/review/github.py +886 -0
  25. chipgraph/adapters/runner/__init__.py +7 -0
  26. chipgraph/adapters/runner/local.py +122 -0
  27. chipgraph/adapters/runtime/__init__.py +5 -0
  28. chipgraph/adapters/runtime/claude_code/__init__.py +24 -0
  29. chipgraph/adapters/runtime/claude_code/agents.py +158 -0
  30. chipgraph/adapters/runtime/claude_code/decisions.py +280 -0
  31. chipgraph/adapters/runtime/claude_code/guarded.py +310 -0
  32. chipgraph/adapters/runtime/claude_code/isolation.py +482 -0
  33. chipgraph/adapters/runtime/claude_code/loop.py +342 -0
  34. chipgraph/adapters/runtime/claude_code/previous.py +419 -0
  35. chipgraph/adapters/runtime/claude_code/review.py +492 -0
  36. chipgraph/adapters/runtime/claude_code/runtime.py +192 -0
  37. chipgraph/adapters/runtime/claude_code/service.py +2285 -0
  38. chipgraph/adapters/tool/__init__.py +7 -0
  39. chipgraph/adapters/tool/cmd.py +229 -0
  40. chipgraph/adapters/tool/edalize.py +773 -0
  41. chipgraph/adapters/tool/filelist.py +259 -0
  42. chipgraph/adapters/tool/pyslang.py +485 -0
  43. chipgraph/adapters/vcs/__init__.py +1 -0
  44. chipgraph/adapters/vcs/git.py +521 -0
  45. chipgraph/app/__init__.py +10 -0
  46. chipgraph/app/baseline.py +339 -0
  47. chipgraph/app/build.py +332 -0
  48. chipgraph/app/checks.py +202 -0
  49. chipgraph/app/context.py +191 -0
  50. chipgraph/app/errors.py +11 -0
  51. chipgraph/app/executors.py +79 -0
  52. chipgraph/app/findings.py +203 -0
  53. chipgraph/app/ingest.py +523 -0
  54. chipgraph/checks/__init__.py +52 -0
  55. chipgraph/checks/_cdc_analyze.py +272 -0
  56. chipgraph/checks/_cdc_elaborate.py +455 -0
  57. chipgraph/checks/_common.py +268 -0
  58. chipgraph/checks/_cross.py +96 -0
  59. chipgraph/checks/_model.py +64 -0
  60. chipgraph/checks/_naming_pyslang.py +235 -0
  61. chipgraph/checks/_naming_rules.py +127 -0
  62. chipgraph/checks/_xref_syntax.py +260 -0
  63. chipgraph/checks/cdc_struct.py +235 -0
  64. chipgraph/checks/connect.py +353 -0
  65. chipgraph/checks/cross_chip.py +454 -0
  66. chipgraph/checks/duplicate.py +153 -0
  67. chipgraph/checks/filelist.py +323 -0
  68. chipgraph/checks/format.py +268 -0
  69. chipgraph/checks/generated.py +151 -0
  70. chipgraph/checks/hardcode.py +233 -0
  71. chipgraph/checks/layout.py +155 -0
  72. chipgraph/checks/naming.py +311 -0
  73. chipgraph/checks/plan_check.py +901 -0
  74. chipgraph/checks/ports_diff.py +203 -0
  75. chipgraph/checks/spec_schema.py +277 -0
  76. chipgraph/checks/style.py +910 -0
  77. chipgraph/checks/tb_static.py +599 -0
  78. chipgraph/checks/trace.py +224 -0
  79. chipgraph/checks/vendor_guard.py +369 -0
  80. chipgraph/checks/wrap_check.py +486 -0
  81. chipgraph/cli/__init__.py +1688 -0
  82. chipgraph/cli/next_steps.py +137 -0
  83. chipgraph/core/__init__.py +1 -0
  84. chipgraph/core/config/__init__.py +93 -0
  85. chipgraph/core/config/defaults.py +14 -0
  86. chipgraph/core/config/errors.py +32 -0
  87. chipgraph/core/config/loader.py +766 -0
  88. chipgraph/core/config/models.py +845 -0
  89. chipgraph/core/config/templates.py +340 -0
  90. chipgraph/core/contracts/__init__.py +75 -0
  91. chipgraph/core/contracts/_paths.py +26 -0
  92. chipgraph/core/contracts/agent.py +49 -0
  93. chipgraph/core/contracts/artifact.py +68 -0
  94. chipgraph/core/contracts/check.py +76 -0
  95. chipgraph/core/contracts/event.py +70 -0
  96. chipgraph/core/contracts/export.py +128 -0
  97. chipgraph/core/contracts/finding.py +171 -0
  98. chipgraph/core/contracts/gate.py +50 -0
  99. chipgraph/core/contracts/rule.py +177 -0
  100. chipgraph/core/contracts/types.py +45 -0
  101. chipgraph/core/engine/__init__.py +70 -0
  102. chipgraph/core/engine/agent_rule.py +1362 -0
  103. chipgraph/core/engine/baseline.py +283 -0
  104. chipgraph/core/engine/decide.py +462 -0
  105. chipgraph/core/engine/fanout.py +1358 -0
  106. chipgraph/core/engine/gate.py +170 -0
  107. chipgraph/core/engine/graph.py +647 -0
  108. chipgraph/core/engine/records.py +60 -0
  109. chipgraph/core/engine/rules.py +92 -0
  110. chipgraph/core/engine/scheduler.py +637 -0
  111. chipgraph/core/engine/select.py +1239 -0
  112. chipgraph/core/engine/targets.py +342 -0
  113. chipgraph/core/model/__init__.py +98 -0
  114. chipgraph/core/model/entities.py +244 -0
  115. chipgraph/core/model/export.py +16 -0
  116. chipgraph/core/model/ingest.py +693 -0
  117. chipgraph/core/model/json_value.py +4 -0
  118. chipgraph/core/model/keys.py +95 -0
  119. chipgraph/core/model/model.py +153 -0
  120. chipgraph/core/model/provenance.py +27 -0
  121. chipgraph/core/model/query.py +583 -0
  122. chipgraph/core/model/registry.py +80 -0
  123. chipgraph/core/model/relations.py +61 -0
  124. chipgraph/core/model/store.py +279 -0
  125. chipgraph/core/plugin_api/__init__.py +81 -0
  126. chipgraph/core/plugin_api/local.py +167 -0
  127. chipgraph/core/plugin_api/pack.py +159 -0
  128. chipgraph/core/plugin_api/protocols.py +177 -0
  129. chipgraph/core/plugin_api/registry.py +111 -0
  130. chipgraph/core/plugin_api/types.py +94 -0
  131. chipgraph/core/runtime/__init__.py +41 -0
  132. chipgraph/core/runtime/executor.py +79 -0
  133. chipgraph/core/runtime/queue.py +565 -0
  134. chipgraph/core/runtime/roles/__init__.py +91 -0
  135. chipgraph/core/runtime/roles/data/author.md +41 -0
  136. chipgraph/core/runtime/roles/data/critic.md +56 -0
  137. chipgraph/core/runtime/roles/data/planner.md +38 -0
  138. chipgraph/core/runtime/roles/data/researcher.md +37 -0
  139. chipgraph/core/runtime/roles/data/rtl-author.md +64 -0
  140. chipgraph/core/runtime/roles/data/tb-author.md +48 -0
  141. chipgraph/core/runtime/roles/data/triage.md +34 -0
  142. chipgraph/core/runtime/roles/policy.py +299 -0
  143. chipgraph/core/runtime/roles/registry.py +52 -0
  144. chipgraph/core/runtime/roles/review.py +275 -0
  145. chipgraph/core/runtime/roles/schema.py +82 -0
  146. chipgraph/core/runtime/roles/skills.py +134 -0
  147. chipgraph/core/runtime/roles/spec.py +232 -0
  148. chipgraph/core/state/__init__.py +1 -0
  149. chipgraph/core/state/artifacts.py +156 -0
  150. chipgraph/core/state/backend.py +117 -0
  151. chipgraph/core/state/findings.py +151 -0
  152. chipgraph/core/state/handoff.py +411 -0
  153. chipgraph/core/state/idempotency.py +59 -0
  154. chipgraph/core/state/journal.py +238 -0
  155. chipgraph/core/state/layout.py +82 -0
  156. chipgraph/core/state/lock.py +195 -0
  157. chipgraph/core/state/trace.py +225 -0
  158. chipgraph/learn/__init__.py +96 -0
  159. chipgraph/learn/draft.py +187 -0
  160. chipgraph/learn/infer.py +580 -0
  161. chipgraph/learn/models.py +145 -0
  162. chipgraph/learn/try_run.py +228 -0
  163. chipgraph/mcp/__init__.py +12 -0
  164. chipgraph/mcp/model_tools.py +167 -0
  165. chipgraph/mcp/server.py +787 -0
  166. chipgraph/orgs/qnsc/naming-v1.yml +113 -0
  167. chipgraph/orgs/qnsc/profile.yml +5 -0
  168. chipgraph/packs/__init__.py +8 -0
  169. chipgraph/packs/assist/__init__.py +16 -0
  170. chipgraph/packs/assist/ask/__init__.py +58 -0
  171. chipgraph/packs/assist/ask/_db.py +57 -0
  172. chipgraph/packs/assist/ask/_project.py +230 -0
  173. chipgraph/packs/assist/ask/answer.py +266 -0
  174. chipgraph/packs/assist/ask/check.py +182 -0
  175. chipgraph/packs/assist/ask/contract.py +115 -0
  176. chipgraph/packs/assist/ask/documents.py +157 -0
  177. chipgraph/packs/assist/ask/fts.py +80 -0
  178. chipgraph/packs/assist/ask/retrieve.py +331 -0
  179. chipgraph/packs/assist/audit/__init__.py +383 -0
  180. chipgraph/packs/assist/pack.yml +13 -0
  181. chipgraph/packs/assist/triage/__init__.py +64 -0
  182. chipgraph/packs/assist/triage/contract.py +94 -0
  183. chipgraph/packs/assist/triage/parse.py +371 -0
  184. chipgraph/packs/assist/triage/question.py +384 -0
  185. chipgraph/packs/assist/triage/report.py +160 -0
  186. chipgraph/packs/assist/triage/rules.py +447 -0
  187. chipgraph/packs/assist/triage/run.py +224 -0
  188. chipgraph/packs/digital_rtl/__init__.py +19 -0
  189. chipgraph/packs/digital_rtl/layout.py +144 -0
  190. chipgraph/packs/digital_rtl/pack.yml +25 -0
  191. chipgraph/packs/digital_rtl/plan/__init__.py +34 -0
  192. chipgraph/packs/digital_rtl/plan/__main__.py +16 -0
  193. chipgraph/packs/digital_rtl/plan/context.py +82 -0
  194. chipgraph/packs/digital_rtl/plan/expand.py +94 -0
  195. chipgraph/packs/digital_rtl/plan/model.py +150 -0
  196. chipgraph/packs/digital_rtl/plan/resolver.py +400 -0
  197. chipgraph/packs/digital_rtl/plan/show.py +176 -0
  198. chipgraph/packs/digital_rtl/pr/__init__.py +11 -0
  199. chipgraph/packs/digital_rtl/pr/body.py +165 -0
  200. chipgraph/packs/digital_rtl/pr/gate.py +110 -0
  201. chipgraph/packs/digital_rtl/pr/open.py +397 -0
  202. chipgraph/packs/digital_rtl/rtl/__init__.py +9 -0
  203. chipgraph/packs/digital_rtl/rtl/context.py +272 -0
  204. chipgraph/packs/digital_rtl/rules/open_pr.yml +30 -0
  205. chipgraph/packs/digital_rtl/rules/plan.yml +28 -0
  206. chipgraph/packs/digital_rtl/rules/plan_expand.yml +29 -0
  207. chipgraph/packs/digital_rtl/rules/pr_gate.yml +27 -0
  208. chipgraph/packs/digital_rtl/rules/review.yml +31 -0
  209. chipgraph/packs/digital_rtl/rules/rtl_module.yml +53 -0
  210. chipgraph/packs/digital_rtl/skills/lang-sv-rtl.md +58 -0
  211. chipgraph/packs/digital_rtl/skills/org-naming.md +29 -0
  212. chipgraph/packs/digital_rtl/skills/plan-modules.md +63 -0
  213. chipgraph/packs/digital_rtl/skills/review-diff.md +41 -0
  214. chipgraph/packs/digital_rtl/targets/plan.yml +11 -0
  215. chipgraph/packs/digital_rtl/targets/rtl.yml +20 -0
  216. chipgraph/packs/digital_rtl/wrapper/__init__.py +24 -0
  217. chipgraph/packs/digital_rtl/wrapper/common.py +171 -0
  218. chipgraph/packs/digital_rtl/wrapper/context.py +222 -0
  219. chipgraph/packs/digital_rtl/wrapper/emacs.py +626 -0
  220. chipgraph/packs/digital_rtl/wrapper/expansion.py +304 -0
  221. chipgraph/packs/digital_rtl/wrapper/lisp.py +504 -0
  222. chipgraph/packs/digital_rtl/wrapper/resolver.py +60 -0
  223. chipgraph/packs/digital_rtl/wrapper/rules/wrapper.yml +41 -0
  224. chipgraph/packs/digital_rtl/wrapper/rules/wrapper_gen.yml +35 -0
  225. chipgraph/packs/digital_rtl/wrapper/skills/wrapper-auto.md +120 -0
  226. chipgraph/packs/dv/__init__.py +15 -0
  227. chipgraph/packs/dv/declaration.py +392 -0
  228. chipgraph/packs/dv/interface.py +596 -0
  229. chipgraph/packs/dv/pack.yml +14 -0
  230. chipgraph/packs/dv/rules/tb_module.yml +42 -0
  231. chipgraph/packs/dv/skills/dv/cocotb.md +72 -0
  232. chipgraph/packs/spec_core/__init__.py +9 -0
  233. chipgraph/packs/spec_core/extract/__init__.py +12 -0
  234. chipgraph/packs/spec_core/extract/_markdown.py +371 -0
  235. chipgraph/packs/spec_core/extract/_text.py +116 -0
  236. chipgraph/packs/spec_core/extract/mas.py +975 -0
  237. chipgraph/packs/spec_core/gen/__init__.py +11 -0
  238. chipgraph/packs/spec_core/gen/agents_md/__init__.py +148 -0
  239. chipgraph/packs/spec_core/gen/agents_md/_context.py +239 -0
  240. chipgraph/packs/spec_core/gen/agents_md/_markers.py +131 -0
  241. chipgraph/packs/spec_core/gen/agents_md/agents_md.md.j2 +178 -0
  242. chipgraph/packs/spec_core/gen/diagram/__init__.py +110 -0
  243. chipgraph/packs/spec_core/gen/diagram/__main__.py +44 -0
  244. chipgraph/packs/spec_core/gen/diagram/_diagrams.py +403 -0
  245. chipgraph/packs/spec_core/gen/diagram/_scene.py +163 -0
  246. chipgraph/packs/spec_core/pack.yml +6 -0
  247. chipgraph/py.typed +0 -0
  248. chipgraph-0.1.0.dist-info/METADATA +53 -0
  249. chipgraph-0.1.0.dist-info/RECORD +252 -0
  250. chipgraph-0.1.0.dist-info/WHEEL +4 -0
  251. chipgraph-0.1.0.dist-info/entry_points.txt +61 -0
  252. chipgraph-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,521 @@
1
+ """The `git` VCS adapter: head, changed files, and disposable worktrees.
2
+
3
+ Implements `chipgraph.core.plugin_api.protocols.VcsAdapter`, and the fan-out port
4
+ `chipgraph.core.engine.fanout.FanoutVcs` (worktrees at a commit, commits, patches,
5
+ the result branch). All git invocations go through `subprocess.run` with argument lists
6
+ (never `shell=True`); a failed command raises `VcsError` naming the command and its
7
+ stderr.
8
+
9
+ `push_branch` (M2-11a) publishes a result branch: a fast-forward push, never a force,
10
+ with an HTTPS token given to git only through a `GIT_ASKPASS` helper (`askpass_helper`).
11
+
12
+ The fan-out methods never touch the working tree, index or HEAD of the user's checkout:
13
+ they run in their own worktree (or only add a ref), with no hooks
14
+ (`core.hooksPath=/dev/null`, `--no-verify`), no signing, no auto gc, and an environment
15
+ from which variables that would redirect git (`GIT_DIR`, `GIT_INDEX_FILE`, ...) are
16
+ removed.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import base64
22
+ import os
23
+ import re
24
+ import shutil
25
+ import subprocess
26
+ import tempfile
27
+ import urllib.parse
28
+ from collections.abc import Sequence
29
+ from pathlib import Path
30
+
31
+ from chipgraph.core.engine.fanout import FileChange, FileStatus
32
+
33
+ _NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
34
+
35
+
36
+ class VcsError(Exception):
37
+ """Raised when a git command fails, or a workspace operation is misused."""
38
+
39
+
40
+ def main_repo(path: Path) -> Path:
41
+ """Return the main repository directory for the (possibly worktree) checkout at `path`."""
42
+ result = _run(["git", "-C", str(path), "rev-parse", "--git-common-dir"], cwd=path)
43
+ common_dir = Path(result.stdout.strip())
44
+ if not common_dir.is_absolute():
45
+ common_dir = (path / common_dir).resolve()
46
+ # `--git-common-dir` points at the `.git` directory; the repo root is its parent,
47
+ # unless the repo's `.git` dir does not sit directly under the worktree root (rare
48
+ # for our purposes), in which case the common dir itself is still a safe `-C` target.
49
+ if common_dir.name == ".git":
50
+ return common_dir.parent
51
+ return common_dir
52
+
53
+
54
+ def _run(cmd: list[str], *, cwd: Path) -> subprocess.CompletedProcess[str]:
55
+ try:
56
+ return subprocess.run(cmd, check=True, capture_output=True, text=True, cwd=cwd)
57
+ except subprocess.CalledProcessError as exc:
58
+ raise VcsError(f"command {' '.join(cmd)!r} failed in {cwd}: {exc.stderr.strip()}") from exc
59
+ except FileNotFoundError as exc:
60
+ raise VcsError(f"command {' '.join(cmd)!r} failed in {cwd}: {exc}") from exc
61
+
62
+
63
+ # Variables that point git at another repo, index or object store: a fan-out must
64
+ # never inherit them (e.g. when chipgraph runs from inside a git hook).
65
+ _REDIRECT_VARS = (
66
+ "GIT_DIR",
67
+ "GIT_WORK_TREE",
68
+ "GIT_INDEX_FILE",
69
+ "GIT_OBJECT_DIRECTORY",
70
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
71
+ "GIT_COMMON_DIR",
72
+ "GIT_NAMESPACE",
73
+ "GIT_PREFIX",
74
+ "GIT_CEILING_DIRECTORIES",
75
+ )
76
+ _SAFE_CONFIG = (
77
+ "-c",
78
+ f"core.hooksPath={os.devnull}",
79
+ "-c",
80
+ "commit.gpgsign=false",
81
+ "-c",
82
+ "gc.auto=0",
83
+ "-c",
84
+ "maintenance.auto=false",
85
+ "-c",
86
+ "diff.noprefix=false",
87
+ "-c",
88
+ "diff.mnemonicPrefix=false",
89
+ "-c",
90
+ "core.autocrlf=false",
91
+ )
92
+ _IDENTITY = {
93
+ "GIT_AUTHOR_NAME": "chipgraph",
94
+ "GIT_AUTHOR_EMAIL": "chipgraph@localhost",
95
+ "GIT_COMMITTER_NAME": "chipgraph",
96
+ "GIT_COMMITTER_EMAIL": "chipgraph@localhost",
97
+ }
98
+ _STATUS: dict[str, FileStatus] = {
99
+ "A": "added",
100
+ "M": "modified",
101
+ "D": "deleted",
102
+ "T": "type_changed",
103
+ }
104
+
105
+
106
+ _TOKEN_VARS = (
107
+ "CHIPGRAPH_GITHUB_TOKEN",
108
+ "GITHUB_TOKEN",
109
+ "GH_TOKEN",
110
+ "GIT_ASKPASS",
111
+ "CHIPGRAPH_ASKPASS_VALUE",
112
+ )
113
+ """Variables no git command inherits: tokens, and any other askpass helper. Only
114
+ `push_branch` gives git a token, in its own environment, for that one command."""
115
+
116
+
117
+ def _fanout_env() -> dict[str, str]:
118
+ env = {k: v for k, v in os.environ.items() if k not in _REDIRECT_VARS and k not in _TOKEN_VARS}
119
+ # The identity comes from the environment when set there, else a fixed one; the
120
+ # user's git config identity is not needed (and may be missing on a CI runner).
121
+ for key, value in _IDENTITY.items():
122
+ env.setdefault(key, value)
123
+ return env
124
+
125
+
126
+ def _git(
127
+ args: Sequence[str], *, cwd: Path, input: bytes | None = None, check: bool = True
128
+ ) -> subprocess.CompletedProcess[bytes]:
129
+ """Run `git <safe config> <args>` in `cwd` for the fan-out; bytes in and out."""
130
+ cmd = ["git", *_SAFE_CONFIG, *args]
131
+ try:
132
+ return subprocess.run(
133
+ cmd, check=check, capture_output=True, cwd=cwd, input=input, env=_fanout_env()
134
+ )
135
+ except subprocess.CalledProcessError as exc:
136
+ stderr = exc.stderr.decode("utf-8", "replace").strip()
137
+ raise VcsError(f"command {' '.join(cmd)!r} failed in {cwd}: {stderr}") from exc
138
+ except FileNotFoundError as exc:
139
+ raise VcsError(f"command {' '.join(cmd)!r} failed in {cwd}: {exc}") from exc
140
+
141
+
142
+ def _out(result: subprocess.CompletedProcess[bytes]) -> str:
143
+ return result.stdout.decode("utf-8", "surrogateescape").strip()
144
+
145
+
146
+ def _parse_raw_z(output: bytes) -> tuple[FileChange, ...]:
147
+ """Parse `git diff --raw -z --no-renames` output into `FileChange`s, sorted by path."""
148
+ fields = output.decode("utf-8", "surrogateescape").split("\0")
149
+ changes: list[FileChange] = []
150
+ i = 0
151
+ while i + 1 < len(fields):
152
+ meta, path = fields[i], fields[i + 1]
153
+ i += 2
154
+ if not meta.startswith(":"):
155
+ continue
156
+ old_mode, new_mode, _old, _new, status = meta[1:].split(" ", 4)
157
+ changes.append(
158
+ FileChange(
159
+ path=path,
160
+ status=_STATUS.get(status[:1], "modified"),
161
+ old_mode=None if old_mode == "000000" else old_mode,
162
+ new_mode=None if new_mode == "000000" else new_mode,
163
+ )
164
+ )
165
+ return tuple(sorted(changes, key=lambda c: c.path))
166
+
167
+
168
+ def _parse_porcelain_z(output: str) -> set[str]:
169
+ """Parse `git status --porcelain=v1 -z` output into a set of repo-relative paths."""
170
+ paths: set[str] = set()
171
+ fields = output.split("\0")
172
+ i = 0
173
+ while i < len(fields):
174
+ entry = fields[i]
175
+ if entry == "":
176
+ i += 1
177
+ continue
178
+ status = entry[:2]
179
+ path = entry[3:]
180
+ paths.add(path)
181
+ i += 1
182
+ if status[0] in ("R", "C") and i < len(fields) and fields[i] != "":
183
+ # Renamed/copied entries carry the original path as a following NUL field.
184
+ paths.add(fields[i])
185
+ i += 1
186
+ return paths
187
+
188
+
189
+ class GitVcs:
190
+ """The `git` implementation of `VcsAdapter` and of the fan-out port `FanoutVcs`."""
191
+
192
+ name = "git"
193
+
194
+ def __init__(self, workspaces_dir: Path | None = None) -> None:
195
+ self._workspaces_dir = workspaces_dir
196
+
197
+ def head(self, root: Path) -> str:
198
+ """Return the current HEAD revision (full sha) of the repo at `root`."""
199
+ result = _run(["git", "rev-parse", "HEAD"], cwd=root)
200
+ return result.stdout.strip()
201
+
202
+ def changed_files(self, root: Path, since: str | None = None) -> tuple[str, ...]:
203
+ """Return repo-relative paths changed in the working tree/index, and since `since`."""
204
+ paths: set[str] = set()
205
+
206
+ status = _run(["git", "status", "--porcelain=v1", "-z"], cwd=root)
207
+ paths |= _parse_porcelain_z(status.stdout)
208
+
209
+ if since is not None:
210
+ diff = _run(["git", "diff", "--name-only", "-z", since, "HEAD"], cwd=root)
211
+ paths |= {p for p in diff.stdout.split("\0") if p}
212
+
213
+ return tuple(sorted(paths))
214
+
215
+ def status_all(self, root: Path) -> tuple[str, ...]:
216
+ """Every path that differs from HEAD in the tree at `root` (each untracked file too).
217
+
218
+ Ignored files are not listed.
219
+ """
220
+ status = _run(["git", "status", "--porcelain=v1", "-z", "--untracked-files=all"], cwd=root)
221
+ return tuple(sorted(_parse_porcelain_z(status.stdout)))
222
+
223
+ def create_workspace(self, root: Path, name: str) -> Path:
224
+ """Create a detached worktree named `name` under the workspaces directory."""
225
+ if not _NAME_RE.match(name):
226
+ raise VcsError(f"invalid workspace name {name!r}: must match {_NAME_RE.pattern!r}")
227
+ base = self._workspaces_dir or (root / ".chipgraph" / "state" / "worktrees")
228
+ target = base / name
229
+ if target.exists():
230
+ raise VcsError(f"workspace path already exists: {target}")
231
+ base.mkdir(parents=True, exist_ok=True)
232
+ _run(["git", "worktree", "add", "--detach", str(target), "HEAD"], cwd=root)
233
+ return target
234
+
235
+ def remove_workspace(self, path: Path) -> None:
236
+ """Remove a worktree previously created by `create_workspace`."""
237
+ repo = main_repo(path)
238
+ _run(["git", "worktree", "remove", "--force", str(path)], cwd=repo)
239
+ _run(["git", "worktree", "prune"], cwd=repo)
240
+
241
+ # --- the fan-out port (`chipgraph.core.engine.fanout.FanoutVcs`) -------------------
242
+
243
+ def create_branch_workspace(self, root: Path, base: str, name: str, dest: Path) -> Path:
244
+ """A detached worktree of `root` at commit `base`, at `dest / name`."""
245
+ if not _NAME_RE.match(name):
246
+ raise VcsError(f"invalid workspace name {name!r}: must match {_NAME_RE.pattern!r}")
247
+ target = dest / name
248
+ if target.exists():
249
+ raise VcsError(f"workspace path already exists: {target}")
250
+ dest.mkdir(parents=True, exist_ok=True)
251
+ _git(["worktree", "add", "--detach", "--quiet", str(target), base], cwd=root)
252
+ return target
253
+
254
+ def remove(self, path: Path) -> None:
255
+ """Remove the worktree at `path` (even with changes, even if locked); no-op if gone."""
256
+ if not path.exists():
257
+ return
258
+ try:
259
+ repo = main_repo(path)
260
+ _git(["worktree", "remove", "--force", "--force", str(path)], cwd=repo)
261
+ except VcsError:
262
+ pass
263
+ if path.exists():
264
+ shutil.rmtree(path, ignore_errors=True)
265
+
266
+ def prune(self, root: Path) -> None:
267
+ """Forget worktrees of `root` whose directory is gone."""
268
+ _git(["worktree", "prune"], cwd=root)
269
+
270
+ def list_workspaces(self, root: Path) -> tuple[Path, ...]:
271
+ """Every linked worktree of the repo at `root` (not the main one)."""
272
+ listing = _out(_git(["worktree", "list", "--porcelain", "-z"], cwd=root))
273
+ paths = [
274
+ Path(entry[len("worktree ") :])
275
+ for entry in listing.split("\0")
276
+ if entry.startswith("worktree ")
277
+ ]
278
+ return tuple(paths[1:])
279
+
280
+ def commit_all(
281
+ self,
282
+ path: Path,
283
+ message: str,
284
+ paths: Sequence[str] | None = None,
285
+ *,
286
+ include_ignored: Sequence[str] = (),
287
+ ) -> str:
288
+ """Commit the worktree at `path`; HEAD when nothing is staged. No hooks run."""
289
+ if paths is None:
290
+ _git(["add", "-A"], cwd=path)
291
+ staged = list(include_ignored)
292
+ else:
293
+ staged = list(paths)
294
+ for rel in staged:
295
+ if (path / rel).exists() or (path / rel).is_symlink():
296
+ _git(["add", "-f", "--", rel], cwd=path)
297
+ else:
298
+ _git(["rm", "--cached", "-q", "--ignore-unmatch", "--", rel], cwd=path)
299
+ quiet = _git(["diff", "--cached", "--quiet"], cwd=path, check=False)
300
+ if quiet.returncode == 0:
301
+ return _out(_git(["rev-parse", "HEAD"], cwd=path))
302
+ _git(["commit", "-q", "--no-verify", "--no-gpg-sign", "-m", message], cwd=path)
303
+ return _out(_git(["rev-parse", "HEAD"], cwd=path))
304
+
305
+ def diff(self, path: Path, since: str, until: str) -> tuple[FileChange, ...]:
306
+ """The files changed between two commits, sorted by path."""
307
+ result = _git(["diff", "--raw", "-z", "--no-renames", since, until], cwd=path)
308
+ return _parse_raw_z(result.stdout)
309
+
310
+ def patch(self, path: Path, since: str, until: str, paths: Sequence[str]) -> bytes:
311
+ """A binary-safe patch of `paths` between two commits."""
312
+ if not paths:
313
+ return b""
314
+ result = _git(
315
+ [
316
+ "diff",
317
+ "--binary",
318
+ "--full-index",
319
+ "--no-renames",
320
+ "--no-color",
321
+ "--no-ext-diff",
322
+ "--no-textconv",
323
+ since,
324
+ until,
325
+ "--",
326
+ *paths,
327
+ ],
328
+ cwd=path,
329
+ )
330
+ return result.stdout
331
+
332
+ def apply_patch(self, path: Path, patch: bytes) -> str | None:
333
+ """Apply `patch` to the worktree at `path`, all or nothing; why not, if it fails."""
334
+ if not patch:
335
+ return None
336
+ result = _git(
337
+ ["apply", "--binary", "--whitespace=nowarn", "-"], cwd=path, input=patch, check=False
338
+ )
339
+ if result.returncode == 0:
340
+ return None
341
+ return result.stderr.decode("utf-8", "replace").strip() or "the patch does not apply"
342
+
343
+ def tree_hash(self, path: Path, commit: str) -> str:
344
+ """The tree id of `commit`."""
345
+ return _out(_git(["rev-parse", f"{commit}^{{tree}}"], cwd=path))
346
+
347
+ def create_branch(self, root: Path, name: str, commit: str) -> None:
348
+ """Create branch `name` at `commit`; fails if it exists. HEAD is not moved."""
349
+ _git(["check-ref-format", "--branch", name], cwd=root)
350
+ self._refuse_checked_out(root, name)
351
+ _git(["branch", "--no-track", "--", name, commit], cwd=root)
352
+
353
+ def checked_out_branches(self, root: Path) -> frozenset[str]:
354
+ """Branches checked out in `root`'s repository or in any of its linked worktrees.
355
+
356
+ From `git worktree list --porcelain`; a detached worktree has no branch.
357
+ """
358
+ out = _out(_git(["worktree", "list", "--porcelain"], cwd=root))
359
+ prefix = "branch refs/heads/"
360
+ return frozenset(
361
+ line[len(prefix) :] for line in out.splitlines() if line.startswith(prefix)
362
+ )
363
+
364
+ def _refuse_checked_out(self, root: Path, name: str) -> None:
365
+ if name in self.checked_out_branches(root):
366
+ raise VcsError(
367
+ f"refusing to touch branch {name!r}: it is checked out in a worktree "
368
+ "(moving it would change that worktree's HEAD under it)"
369
+ )
370
+
371
+ # --- publishing a result branch (M2-11a) -----------------------------------------
372
+
373
+ def branch_tip(self, root: Path, name: str) -> str | None:
374
+ """The commit branch `name` points at, or `None` if there is no such branch."""
375
+ _git(["check-ref-format", "--branch", name], cwd=root)
376
+ result = _git(
377
+ ["rev-parse", "--verify", "--quiet", f"refs/heads/{name}^{{commit}}"],
378
+ cwd=root,
379
+ check=False,
380
+ )
381
+ if result.returncode != 0:
382
+ return None
383
+ return _out(result) or None
384
+
385
+ def advance_branch(self, root: Path, name: str, commit: str, old: str) -> None:
386
+ """Move branch `name` from `old` to `commit`, only if `commit` descends from `old`.
387
+
388
+ A fast-forward only, and only if the branch is still at `old` (compare and swap);
389
+ HEAD, the index and the working tree are not touched.
390
+ """
391
+ self._refuse_checked_out(root, name)
392
+ if commit == old:
393
+ return
394
+ ancestor = _git(["merge-base", "--is-ancestor", old, commit], cwd=root, check=False)
395
+ if ancestor.returncode != 0:
396
+ raise VcsError(
397
+ f"refusing to move branch {name!r}: {commit} does not descend from {old}"
398
+ )
399
+ _git(["update-ref", f"refs/heads/{name}", commit, old], cwd=root)
400
+
401
+ def remote_url(self, root: Path, remote: str) -> str:
402
+ """The URL git pushes to for `remote` (raises `VcsError` if there is none)."""
403
+ return _out(_git(["remote", "get-url", "--push", "--", remote], cwd=root))
404
+
405
+ def push_branch(
406
+ self,
407
+ root: Path,
408
+ remote: str,
409
+ commit: str,
410
+ branch: str,
411
+ *,
412
+ token: str | None = None,
413
+ username: str = "x-access-token",
414
+ ) -> None:
415
+ """Push `commit` to `refs/heads/<branch>` of `remote`: a fast-forward, never a force.
416
+
417
+ With `token` (an HTTPS remote), git gets it through a `GIT_ASKPASS` helper script
418
+ in a private temporary directory, removed afterwards, which prints the token from one
419
+ environment variable of the git process: never in the command line, the URL, git
420
+ config, a file or an error message. No credential
421
+ helper runs (so nothing is stored), no terminal prompt, no hook. A remote that
422
+ refuses (not a fast-forward, no access) raises `VcsError`.
423
+ """
424
+ _git(["check-ref-format", "--branch", branch], cwd=root)
425
+ args = [
426
+ "-c",
427
+ "credential.helper=",
428
+ "-c",
429
+ "core.askPass=",
430
+ "push",
431
+ "--porcelain",
432
+ "--no-verify",
433
+ "--",
434
+ remote,
435
+ f"{commit}:refs/heads/{branch}",
436
+ ]
437
+ env = {k: v for k, v in _fanout_env().items() if k not in _TOKEN_VARS}
438
+ env["GIT_TERMINAL_PROMPT"] = "0"
439
+ env.pop("SSH_ASKPASS", None)
440
+ if token is None:
441
+ env.pop("GIT_ASKPASS", None)
442
+ _push(args, root, env, secret=None)
443
+ return
444
+ with tempfile.TemporaryDirectory(prefix="chipgraph-askpass-") as tmp:
445
+ helper = askpass_helper(Path(tmp), username=username)
446
+ env["GIT_ASKPASS"] = str(helper)
447
+ env[ASKPASS_VALUE_VAR] = token
448
+ _push(args, root, env, secret=token)
449
+
450
+
451
+ def secret_forms(secret: str) -> tuple[str, ...]:
452
+ """Every spelling of `secret` that may show up in a message: as given, URL-encoded
453
+ (`quote`, `quote_plus`), base64 (standard and URL-safe, padded and not), and the
454
+ base64 of the HTTP Basic `user:token` pair that a header or a URL may carry."""
455
+ raw = secret.encode("utf-8")
456
+ forms = {secret, urllib.parse.quote(secret, safe=""), urllib.parse.quote_plus(secret)}
457
+ for blob in (raw, b"x-access-token:" + raw, b":" + raw):
458
+ for encoder in (base64.b64encode, base64.urlsafe_b64encode):
459
+ text = encoder(blob).decode("ascii")
460
+ forms.update({text, text.rstrip("=")})
461
+ return tuple(sorted((f for f in forms if f), key=len, reverse=True))
462
+
463
+
464
+ def redact_secret(text: str, secret: str | None) -> str:
465
+ """`text` with every form of `secret` (see `secret_forms`) replaced by `***`."""
466
+ if not secret:
467
+ return text
468
+ for form in secret_forms(secret):
469
+ text = text.replace(form, "***")
470
+ return text
471
+
472
+
473
+ def url_host(url: str) -> str | None:
474
+ """The host of an `http(s)://` URL, lower-case, without port or user info."""
475
+ parts = urllib.parse.urlsplit(url)
476
+ return parts.hostname.lower() if parts.scheme in ("http", "https") and parts.hostname else None
477
+
478
+
479
+ ASKPASS_VALUE_VAR = "CHIPGRAPH_ASKPASS_VALUE"
480
+ """The variable of the git process that the askpass helper prints as the password."""
481
+
482
+
483
+ def askpass_helper(directory: Path, *, username: str = "x-access-token") -> Path:
484
+ """Write a `GIT_ASKPASS` helper in `directory` (private): it prints `username` for git's
485
+ username prompt and the value of `ASKPASS_VALUE_VAR` for its password prompt.
486
+
487
+ The script holds no secret and nothing secret is written to disk: the token is given to
488
+ the git process only as that one environment variable (never in the command line, the
489
+ URL or git's config), and is gone when the process ends.
490
+ """
491
+ directory.chmod(0o700)
492
+ script = directory / "askpass.sh"
493
+ script.write_text(
494
+ "#!/bin/sh\n"
495
+ 'case "$1" in\n'
496
+ f" Username*|username*) printf '%s\\n' {_sh_quote(username)} ;;\n"
497
+ f" *) printf '%s\\n' \"${{{ASKPASS_VALUE_VAR}}}\" ;;\n"
498
+ "esac\n",
499
+ encoding="utf-8",
500
+ )
501
+ script.chmod(0o700)
502
+ return script
503
+
504
+
505
+ def _sh_quote(text: str) -> str:
506
+ return "'" + text.replace("'", "'\"'\"'") + "'"
507
+
508
+
509
+ def _push(args: Sequence[str], root: Path, env: dict[str, str], *, secret: str | None) -> None:
510
+ cmd = ["git", *_SAFE_CONFIG, *args]
511
+ try:
512
+ subprocess.run(cmd, check=True, capture_output=True, cwd=root, env=env)
513
+ except subprocess.CalledProcessError as exc:
514
+ stderr = exc.stderr.decode("utf-8", "replace").strip()
515
+ stdout = exc.stdout.decode("utf-8", "replace").strip()
516
+ message = redact_secret(
517
+ f"git push to {args[-2]!r} failed in {root}: {stderr or stdout}", secret
518
+ )
519
+ raise VcsError(message) from None
520
+ except FileNotFoundError as exc:
521
+ raise VcsError(f"git push failed in {root}: {exc}") from None
@@ -0,0 +1,10 @@
1
+ """The application layer: wires `chipgraph.core` to `chipgraph.adapters` and
2
+ `chipgraph.checks` for a concrete project, on behalf of the CLI (and, later, the MCP
3
+ server). Core must not import this package (see `pyproject.toml`'s import-linter
4
+ contract): this layer is where the engine actually meets tools, checks and a project's
5
+ `.chipgraph.yml`.
6
+ """
7
+
8
+ from chipgraph.app.errors import AppError
9
+
10
+ __all__ = ["AppError"]