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,275 @@
1
+ """`ReviewReport`: a review task's structured reply (DESIGN.md 4.8, 5.1).
2
+
3
+ A role that writes no files (the Critic) replies with one JSON object, a `ReviewReport`;
4
+ the engine checks it against what the role was shown and writes it as the task's one
5
+ output (the role's `engine_writes`). What it was shown is a `ReviewScope`: the target,
6
+ the base and head refs, the files of the diff and the new-side line ranges of its hunks
7
+ (`diff_hunks` reads them from a unified diff).
8
+
9
+ `review_problems(report, scope)` is the pure validator; it lists every problem at once:
10
+
11
+ - `target`, `base` or `head` other than the scope's;
12
+ - `reviewed` empty, or (with no comments) missing a file of the diff: "no findings" is a
13
+ claim about every file, so it must say it looked at them all;
14
+ - a duplicate comment id, an empty claim;
15
+ - an empty or unquoted `evidence`: it must quote what the claim rests on, as
16
+ `path:line text` (or `path:start-end text`, or `model:<key> text`);
17
+ - a comment on a file not in `reviewed`, or on a `file:line` outside the diff's hunks;
18
+ - verdict `approve` with a `blocker` or `major` comment, and `changes_requested` with no
19
+ comment.
20
+
21
+ `parse_review(raw)` turns the reply (an object or its JSON text) into a `ReviewReport`,
22
+ or the reasons it is not one. Generic: no harness, tool or project names.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import posixpath
29
+ import re
30
+ from collections.abc import Iterable, Mapping
31
+ from typing import Annotated, Any, Literal
32
+
33
+ from pydantic import BaseModel, ConfigDict, Field, StringConstraints, ValidationError
34
+
35
+ Severity = Literal["blocker", "major", "minor", "nit"]
36
+ """How much a comment matters: `blocker` and `major` request changes; `minor` and `nit` do not."""
37
+
38
+ BLOCKING_SEVERITIES: frozenset[str] = frozenset({"blocker", "major"})
39
+ """Severities an `approve` verdict must not carry."""
40
+
41
+ Verdict = Literal["approve", "changes_requested"]
42
+
43
+ CommentId = Annotated[str, StringConstraints(pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")]
44
+ """A comment id, unique in its review, e.g. `R1`."""
45
+
46
+ Category = Annotated[str, StringConstraints(pattern=r"^[a-z][a-z0-9_]*$")]
47
+ """A comment category, lower_snake_case; the review skill lists the ones to use."""
48
+
49
+ _EVIDENCE_RE = re.compile(
50
+ r"^\s*(?:(?P<path>[^\s:]+):(?P<line>\d+)(?:-(?P<end>\d+))?|model:(?P<key>\S+))\s+\S"
51
+ )
52
+ _HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(?P<start>\d+)(?:,(?P<count>\d+))? @@")
53
+
54
+
55
+ class ReviewComment(BaseModel):
56
+ """One finding: where it is, what is wrong, what it rests on, what to do."""
57
+
58
+ model_config = ConfigDict(extra="forbid", frozen=True)
59
+
60
+ id: CommentId = Field(description="Unique in the review, e.g. 'R1'.")
61
+ severity: Severity = Field(description="blocker | major (request changes), minor | nit.")
62
+ category: Category = Field(
63
+ description="What kind of problem, lower_snake_case (the review skill lists them)."
64
+ )
65
+ file: str = Field(min_length=1, description="Repo-relative file the comment is on.")
66
+ line: int = Field(ge=1, description="Line in the new version of `file`, inside the diff.")
67
+ claim: str = Field(description="What is wrong, in one or two sentences.")
68
+ evidence: str = Field(
69
+ description=(
70
+ "What the claim rests on, quoted: 'path:line text' (a spec or code line), "
71
+ "'path:start-end text' or 'model:<key> text'. Mandatory."
72
+ )
73
+ )
74
+ suggestion: str = Field(default="", description="What to change, if known.")
75
+ req_id: str | None = Field(
76
+ default=None, description="The requirement id the comment is about, if any."
77
+ )
78
+
79
+
80
+ class ReviewReport(BaseModel):
81
+ """A review task's reply: what was reviewed, the comments, the verdict."""
82
+
83
+ model_config = ConfigDict(extra="forbid", frozen=True)
84
+
85
+ schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
86
+ target: str = Field(min_length=1, description="What was reviewed (the block), as given.")
87
+ base: str = Field(min_length=1, description="The base ref of the diff, as given.")
88
+ head: str = Field(min_length=1, description="The head ref of the diff, as given.")
89
+ reviewed: tuple[str, ...] = Field(
90
+ description="Repo-relative files looked at; every file of the diff when no comments."
91
+ )
92
+ comments: tuple[ReviewComment, ...] = Field(
93
+ default=(), description="The findings; empty means no findings."
94
+ )
95
+ summary: str = Field(description="One short paragraph: what changed and the verdict's reason.")
96
+ verdict: Verdict = Field(description="approve, or changes_requested.")
97
+
98
+
99
+ class ReviewScope(BaseModel):
100
+ """What a review task was shown: the refs and the diff's files and hunks."""
101
+
102
+ model_config = ConfigDict(extra="forbid", frozen=True)
103
+
104
+ schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
105
+ target: str = Field(description="What is reviewed (the block).")
106
+ base: str = Field(description="The base ref of the diff.")
107
+ head: str = Field(description="The head ref of the diff.")
108
+ hunks: dict[str, tuple[tuple[int, int], ...]] = Field(
109
+ default_factory=dict,
110
+ description="Each file of the diff to the new-side line ranges of its hunks (inclusive).",
111
+ )
112
+
113
+ @property
114
+ def files(self) -> tuple[str, ...]:
115
+ """The files of the diff, sorted."""
116
+ return tuple(sorted(self.hunks))
117
+
118
+
119
+ def diff_hunks(diff: str) -> dict[str, tuple[tuple[int, int], ...]]:
120
+ """The new-side line ranges (inclusive, context lines included) per file of a diff.
121
+
122
+ `diff` is unified diff text (`git diff` output). A deleted file has no new side and is
123
+ left out; a hunk that only removes lines covers the line before and after the cut.
124
+ """
125
+ hunks: dict[str, list[tuple[int, int]]] = {}
126
+ current: str | None = None
127
+ prev = ""
128
+ for line in diff.splitlines():
129
+ header, prev = line.startswith("+++ ") and prev.startswith("--- "), line
130
+ if header:
131
+ new = line[4:].strip()
132
+ if new == "/dev/null":
133
+ current = None
134
+ else:
135
+ current = new[2:] if new.startswith("b/") else new
136
+ hunks.setdefault(current, [])
137
+ continue
138
+ match = _HUNK_RE.match(line)
139
+ if match and current is not None:
140
+ start = int(match.group("start"))
141
+ count = int(match.group("count") or "1")
142
+ if count == 0:
143
+ hunks[current].append((max(start, 1), start + 1))
144
+ else:
145
+ hunks[current].append((start, start + count - 1))
146
+ return {path: tuple(ranges) for path, ranges in hunks.items()}
147
+
148
+
149
+ def _clean_path(path: str) -> bool:
150
+ text = path.strip()
151
+ if not text or text.startswith("/") or "\\" in text:
152
+ return False
153
+ norm = posixpath.normpath(text)
154
+ return norm == text and norm not in (".", "..") and not norm.startswith("../")
155
+
156
+
157
+ def _ranges(ranges: Iterable[tuple[int, int]]) -> str:
158
+ return ", ".join(f"{a}-{b}" if a != b else str(a) for a, b in ranges)
159
+
160
+
161
+ def evidence_ok(evidence: str) -> bool:
162
+ """Whether `evidence` quotes a source: `path:line text`, `path:a-b text`, `model:<key> text`."""
163
+ return bool(_EVIDENCE_RE.match(evidence))
164
+
165
+
166
+ def review_problems(report: ReviewReport, scope: ReviewScope) -> list[str]:
167
+ """Every reason `report` is not a valid review of `scope` (empty: valid)."""
168
+ problems: list[str] = []
169
+ for name in ("target", "base", "head"):
170
+ got, want = getattr(report, name), getattr(scope, name)
171
+ if got != want:
172
+ problems.append(f"{name} {got!r} is not the task's {name} {want!r}")
173
+
174
+ reviewed = set(report.reviewed)
175
+ if not report.reviewed:
176
+ problems.append("reviewed is empty: list the files you looked at")
177
+ bad = [p for p in report.reviewed if not _clean_path(p)]
178
+ if bad:
179
+ problems.append(f"reviewed has paths that are not repo-relative: {', '.join(bad)}")
180
+ if not report.comments:
181
+ missing = [f for f in scope.files if f not in reviewed]
182
+ if missing:
183
+ problems.append(
184
+ "a review with no comments says the whole diff is fine, so reviewed must "
185
+ f"list every file of the diff; missing: {', '.join(missing)}"
186
+ )
187
+
188
+ seen: set[str] = set()
189
+ for comment in report.comments:
190
+ where = f"comment {comment.id}"
191
+ if comment.id in seen:
192
+ problems.append(f"{where}: duplicate comment id {comment.id!r}")
193
+ seen.add(comment.id)
194
+ if not comment.claim.strip():
195
+ problems.append(f"{where}: claim is empty")
196
+ if not comment.evidence.strip():
197
+ problems.append(
198
+ f"{where}: evidence is empty; quote the spec line or code the claim rests "
199
+ "on, as 'path:line text'"
200
+ )
201
+ elif not evidence_ok(comment.evidence):
202
+ problems.append(
203
+ f"{where}: evidence {comment.evidence[:80]!r} does not quote a source; "
204
+ "write 'path:line text' (or 'path:start-end text', 'model:<key> text')"
205
+ )
206
+ if comment.file not in reviewed:
207
+ problems.append(f"{where}: file {comment.file!r} is not in reviewed")
208
+ ranges = scope.hunks.get(comment.file)
209
+ if ranges is None:
210
+ problems.append(
211
+ f"{where}: {comment.file}:{comment.line} is outside the diff "
212
+ f"({comment.file!r} is not one of its files: {', '.join(scope.files) or 'none'})"
213
+ )
214
+ elif not any(a <= comment.line <= b for a, b in ranges):
215
+ problems.append(
216
+ f"{where}: {comment.file}:{comment.line} is outside the diff (its hunks "
217
+ f"cover lines {_ranges(ranges)} of {comment.file})"
218
+ )
219
+
220
+ blocking = [c.id for c in report.comments if c.severity in BLOCKING_SEVERITIES]
221
+ if report.verdict == "approve" and blocking:
222
+ problems.append(
223
+ f"verdict 'approve' with blocker or major comments ({', '.join(blocking)}); "
224
+ "use 'changes_requested' or lower their severity"
225
+ )
226
+ if report.verdict == "changes_requested" and not report.comments:
227
+ problems.append("verdict 'changes_requested' with no comments; say what to change")
228
+ return problems
229
+
230
+
231
+ def _validation_lines(exc: ValidationError) -> list[str]:
232
+ lines = []
233
+ for error in exc.errors():
234
+ loc = ".".join(str(p) for p in error.get("loc", ())) or "review"
235
+ lines.append(f"review.{loc}: {error.get('msg', 'invalid')}")
236
+ return lines
237
+
238
+
239
+ def parse_review(raw: Mapping[str, Any] | str) -> tuple[ReviewReport | None, list[str]]:
240
+ """The reply as a `ReviewReport`, or `None` and why it is not one.
241
+
242
+ `raw` is the reply object, or its JSON text (a fenced ```json block is unwrapped).
243
+ """
244
+ data: Any = raw
245
+ if isinstance(raw, str):
246
+ text = raw.strip()
247
+ fenced = re.fullmatch(r"```(?:json)?\s*\n(?P<body>.*)\n```", text, flags=re.DOTALL)
248
+ if fenced:
249
+ text = fenced.group("body")
250
+ try:
251
+ data = json.loads(text)
252
+ except json.JSONDecodeError as exc:
253
+ return None, [f"review is not valid JSON: {exc}"]
254
+ if not isinstance(data, Mapping):
255
+ return None, [f"review must be a JSON object, not {type(data).__name__}"]
256
+ try:
257
+ return ReviewReport.model_validate(dict(data)), []
258
+ except ValidationError as exc:
259
+ return None, _validation_lines(exc)
260
+
261
+
262
+ __all__ = [
263
+ "BLOCKING_SEVERITIES",
264
+ "Category",
265
+ "CommentId",
266
+ "ReviewComment",
267
+ "ReviewReport",
268
+ "ReviewScope",
269
+ "Severity",
270
+ "Verdict",
271
+ "diff_hunks",
272
+ "evidence_ok",
273
+ "parse_review",
274
+ "review_problems",
275
+ ]
@@ -0,0 +1,82 @@
1
+ """JSON Schema files for the role and skill files and the review reply, under `schemas/roles/`.
2
+
3
+ python -m chipgraph.core.runtime.roles.schema [--check] [--out DIR]
4
+
5
+ `make schemas` runs it after the contract schemas; `make schemas-check` with `--check`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from chipgraph.core.runtime.roles.review import ReviewReport
16
+ from chipgraph.core.runtime.roles.skills import SkillSpec
17
+ from chipgraph.core.runtime.roles.spec import RoleSpec
18
+
19
+ SCHEMA_MODELS = (RoleSpec, SkillSpec, ReviewReport)
20
+ """The models whose schemas are committed, as `<Model>.schema.json`."""
21
+
22
+
23
+ def generate() -> dict[str, str]:
24
+ """File name to content, for every schema file."""
25
+ files = {}
26
+ for model in SCHEMA_MODELS:
27
+ schema = model.model_json_schema()
28
+ files[f"{model.__name__}.schema.json"] = json.dumps(schema, indent=2, sort_keys=True) + "\n"
29
+ return files
30
+
31
+
32
+ def _repo_schemas_dir() -> Path:
33
+ here = Path(__file__).resolve()
34
+ for candidate in here.parents:
35
+ if (candidate / "pyproject.toml").is_file():
36
+ return candidate / "schemas" / "roles"
37
+ raise FileNotFoundError(f"no pyproject.toml found above {here}")
38
+
39
+
40
+ def check(out_dir: Path) -> list[str]:
41
+ """Problems with the committed files in `out_dir` (empty: up to date)."""
42
+ expected = generate()
43
+ problems = []
44
+ for name, content in expected.items():
45
+ path = out_dir / name
46
+ if not path.is_file():
47
+ problems.append(f"missing: roles/{name}")
48
+ elif path.read_text(encoding="utf-8") != content:
49
+ problems.append(f"outdated: roles/{name}")
50
+ if out_dir.is_dir():
51
+ for path in sorted(out_dir.glob("*.schema.json")):
52
+ if path.name not in expected:
53
+ problems.append(f"extra: roles/{path.name}")
54
+ return problems
55
+
56
+
57
+ def main(argv: list[str] | None = None) -> int:
58
+ parser = argparse.ArgumentParser(description=__doc__)
59
+ parser.add_argument(
60
+ "--check", action="store_true", help="check, do not write; exit 1 if out of date"
61
+ )
62
+ parser.add_argument("--out", type=Path, default=None, help="default: schemas/roles/")
63
+ args = parser.parse_args(argv)
64
+ out_dir: Path = args.out if args.out is not None else _repo_schemas_dir()
65
+ if args.check:
66
+ problems = check(out_dir)
67
+ for problem in problems:
68
+ print(f" {problem}", file=sys.stderr)
69
+ if problems:
70
+ print("role schemas out of date: run `make schemas`", file=sys.stderr)
71
+ return 1
72
+ print(f"role schemas up to date in {out_dir}")
73
+ return 0
74
+ out_dir.mkdir(parents=True, exist_ok=True)
75
+ for name, content in generate().items():
76
+ (out_dir / name).write_text(content, encoding="utf-8")
77
+ print(f"wrote role schemas to {out_dir}")
78
+ return 0
79
+
80
+
81
+ if __name__ == "__main__":
82
+ raise SystemExit(main())
@@ -0,0 +1,134 @@
1
+ """Skills: per-artifact-kind instructions a role loads for a task (DESIGN.md 5.1, D8).
2
+
3
+ A skill is a markdown file with a YAML frontmatter::
4
+
5
+ ---
6
+ id: lang-sv/rtl # <namespace>/<name>
7
+ description: One line.
8
+ roles: [author] # the roles that may use it
9
+ version: 0.1.0
10
+ ---
11
+ The instructions (the body).
12
+
13
+ Packs list skill files, or directories of `*.md` skill files, in `provides.skills`.
14
+ `load_skills(packs)` reads every skill of the given packs; `SkillSet.resolve(ids, role)`
15
+ returns the skills a task names, and refuses an unknown id or a skill its role may not
16
+ use.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections.abc import Iterable, Mapping
22
+ from pathlib import Path
23
+ from types import MappingProxyType
24
+ from typing import Annotated, Literal
25
+
26
+ import yaml
27
+ from pydantic import BaseModel, ConfigDict, Field, StringConstraints, ValidationError
28
+
29
+ from chipgraph.core.plugin_api.pack import Pack
30
+ from chipgraph.core.runtime.roles.registry import find_role
31
+ from chipgraph.core.runtime.roles.spec import RoleId, split_frontmatter
32
+
33
+ SkillId = Annotated[str, StringConstraints(pattern=r"^[a-z0-9][a-z0-9-]*/[a-z0-9][a-z0-9_-]*$")]
34
+ """A skill id, `<namespace>/<name>`, e.g. `lang-sv/rtl`."""
35
+
36
+
37
+ class SkillError(Exception):
38
+ """A broken skill file, a duplicate or unknown id, or a skill its role may not use."""
39
+
40
+
41
+ class SkillSpec(BaseModel):
42
+ """One skill: its frontmatter plus the instructions (the file's body)."""
43
+
44
+ model_config = ConfigDict(extra="forbid", frozen=True)
45
+
46
+ schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
47
+ id: SkillId = Field(description="The skill id, '<namespace>/<name>'.")
48
+ description: str = Field(min_length=1, description="One line: what the skill is for.")
49
+ roles: tuple[RoleId, ...] = Field(min_length=1, description="The roles that may use it.")
50
+ version: Annotated[str, StringConstraints(pattern=r"^\d+\.\d+\.\d+$")] = Field(
51
+ description="The skill's version, MAJOR.MINOR.PATCH."
52
+ )
53
+ text: str = Field(min_length=1, description="The instructions: the file's body.")
54
+
55
+
56
+ def parse_skill_file(path: Path) -> SkillSpec:
57
+ """Load one skill file. Raises `SkillError` for a bad file or an unknown role."""
58
+ try:
59
+ data, body = split_frontmatter(path.read_text(encoding="utf-8"), source=str(path))
60
+ if "text" in data:
61
+ raise ValueError(f"{path}: the text is the file's body, not a frontmatter key")
62
+ skill = SkillSpec.model_validate({**data, "text": body})
63
+ except (OSError, ValueError, ValidationError, yaml.YAMLError) as exc:
64
+ raise SkillError(f"invalid skill file {path}: {exc}") from exc
65
+ unknown = [r for r in skill.roles if find_role(r) is None]
66
+ if unknown:
67
+ raise SkillError(f"skill {skill.id!r} in {path} names unknown roles: {', '.join(unknown)}")
68
+ return skill
69
+
70
+
71
+ def _skill_files(path: Path) -> list[Path]:
72
+ return sorted(path.glob("*.md")) if path.is_dir() else [path]
73
+
74
+
75
+ class SkillSet:
76
+ """The skills of a set of packs, by id."""
77
+
78
+ def __init__(self, skills: Mapping[str, SkillSpec], sources: Mapping[str, str]) -> None:
79
+ self.skills = MappingProxyType(dict(skills))
80
+ self._sources = MappingProxyType(dict(sources))
81
+
82
+ def source(self, skill_id: str) -> str:
83
+ """Where a skill was loaded from (pack name and file)."""
84
+ return self._sources[skill_id]
85
+
86
+ def get(self, skill_id: str) -> SkillSpec:
87
+ """The skill `skill_id`; raises `SkillError` if no loaded pack provides it."""
88
+ skill = self.skills.get(skill_id)
89
+ if skill is None:
90
+ known = ", ".join(sorted(self.skills)) or "none"
91
+ raise SkillError(
92
+ f"unknown skill {skill_id!r}: no pack of this project provides it "
93
+ f"(skills available: {known})"
94
+ )
95
+ return skill
96
+
97
+ def resolve(self, skill_ids: Iterable[str], role: str) -> tuple[SkillSpec, ...]:
98
+ """The skills `skill_ids` for a task of `role`, in order.
99
+
100
+ Raises `SkillError` for an unknown id, or a skill whose `roles` does not list
101
+ `role` (a `<namespace>/` prefix on the role is ignored).
102
+ """
103
+ role_id = role.rsplit("/", 1)[-1]
104
+ found: list[SkillSpec] = []
105
+ for skill_id in skill_ids:
106
+ skill = self.get(skill_id)
107
+ if role_id not in skill.roles:
108
+ raise SkillError(
109
+ f"skill {skill_id!r} is not for role {role_id!r} "
110
+ f"(it may be used by: {', '.join(skill.roles)})"
111
+ )
112
+ found.append(skill)
113
+ return tuple(found)
114
+
115
+
116
+ def load_skills(packs: Iterable[Pack]) -> SkillSet:
117
+ """Every skill the packs provide. Raises `SkillError` for a broken file or a duplicate id."""
118
+ skills: dict[str, SkillSpec] = {}
119
+ sources: dict[str, str] = {}
120
+ for pack in packs:
121
+ for rel in pack.manifest.provides.skills:
122
+ for path in _skill_files(pack.path(rel)):
123
+ skill = parse_skill_file(path)
124
+ where = f"pack {pack.manifest.name!r} ({path})"
125
+ if skill.id in skills:
126
+ raise SkillError(
127
+ f"duplicate skill id {skill.id!r}: in {sources[skill.id]} and {where}"
128
+ )
129
+ skills[skill.id] = skill
130
+ sources[skill.id] = where
131
+ return SkillSet(skills, sources)
132
+
133
+
134
+ __all__ = ["SkillError", "SkillId", "SkillSet", "SkillSpec", "load_skills", "parse_skill_file"]