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,910 @@
1
+ """`StyleCheck`: a file's code style measured against the project's style exemplars.
2
+
3
+ DESIGN.md 8.6 V3, task M2-04. A deterministic check (no model): `style_profile` derives a
4
+ `StyleProfile` from the files in the profile's `style.exemplars`, and `style_issues`
5
+ reports every place a file departs from it, as `Issue`s `style.<key>` on `file:line`
6
+ whose message names the value the exemplars use. `style_distance` sums the departures
7
+ into one number in [0, 1] (0: the file follows every measured habit).
8
+
9
+ The features (the keys, `chipgraph.core.config.models.STYLE_KEYS`):
10
+
11
+ | key | what is measured |
12
+ |---------------|------------------------------------------------------------------------|
13
+ | `indent` | indentation character and width (the most common indent step) |
14
+ | `begin` | `begin` at the end of the line, or on a line of its own |
15
+ | `ports` | port declarations: ANSI or not, one per line, direction keywords padded |
16
+ | | so the types line up |
17
+ | `reset` | reset style: asynchronous or synchronous, active low or high, its name |
18
+ | `reset_kind` | asynchronous or synchronous reset, only that: always an **error**, |
19
+ | | whatever `style.enforce` says (a wrong kind is a functional change) |
20
+ | `sequential` | the sequential block: `always_ff` or `always`, the clock edge |
21
+ | `suffix` | signal suffixes: the registered (`_q`, `_r`, `_reg`, `_ff`) and |
22
+ | | next-state (`_d`, `_next`, `_nxt`) ones, `_i`/`_o` on ports |
23
+ | `header` | a comment header before the module (a `timescale` may come first) |
24
+ | `blank_lines` | the most blank lines in a row |
25
+ | `line_length` | the longest line |
26
+ | `case` | the naming case of local signals: snake, camel or upper |
27
+
28
+ A feature the exemplars do not show (no sequential block, no next-state signal) is not
29
+ measured. The text is read with comments and strings blanked out, line numbers kept; it
30
+ is a style measure over SystemVerilog text, not a parser (lint and naming are the checks
31
+ that parse).
32
+
33
+ Deviations are `warning`s, except for the keys listed in the profile's `style.enforce`
34
+ (or arg `enforce`), which are `error`s and fail the check. An `info` issue
35
+ `style.distance` gives the distance.
36
+
37
+ Which files: `chipgraph.checks.format.target_files` (arg `files`, else the module's HDL
38
+ files in the block's approved plan); none (e.g. `chipgraph check` over a block) is
39
+ `skipped`. Which exemplars: arg `exemplars`, else the profile's `style.exemplars`; none is
40
+ `skipped`; a missing one is an `error`. A checked file that is also an exemplar is left
41
+ out of the exemplars it is measured against.
42
+
43
+ Profile example (`.chipgraph.yml`)::
44
+
45
+ style:
46
+ exemplars: [rtl/tiny_timer.sv, rtl/tiny_gpio.sv]
47
+ enforce: [indent, reset]
48
+ adapters:
49
+ style: { use: style }
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import re
55
+ import time
56
+ from collections import Counter
57
+ from collections.abc import Iterable, Mapping, Sequence
58
+ from dataclasses import dataclass, field
59
+ from pathlib import Path
60
+ from typing import Any, Literal
61
+
62
+ from pydantic import BaseModel, ConfigDict, Field
63
+
64
+ from chipgraph.checks._common import compute_idempotency_key, sha256_file
65
+ from chipgraph.checks.format import TargetError, target_files
66
+ from chipgraph.core.config.errors import ConfigError
67
+ from chipgraph.core.config.loader import load as load_profile
68
+ from chipgraph.core.config.models import STYLE_KEYS, StyleKey
69
+ from chipgraph.core.contracts import CheckResult, CheckSpec, Issue
70
+ from chipgraph.core.contracts.types import CheckStatus, Severity
71
+ from chipgraph.core.plugin_api.types import ToolContext
72
+
73
+ REG_SUFFIXES = ("_q", "_r", "_reg", "_ff")
74
+ """Suffixes that mark a registered signal."""
75
+ NEXT_SUFFIXES = ("_d", "_next", "_nxt")
76
+ """Suffixes that mark a register's next value."""
77
+ REPORTED_SUFFIXES = ("_q", "_d", "_r", "_next", "_reg", "_n", "_i", "_o")
78
+ """The suffixes whose frequency the profile reports."""
79
+ MAX_ISSUES_PER_KEY = 5
80
+ """Issues per feature and file; the rest are counted in the last one."""
81
+
82
+ Case = Literal["snake", "camel", "upper"]
83
+
84
+ _BLOCK_COMMENT = re.compile(r"/\*.*?\*/", re.DOTALL)
85
+ _LINE_COMMENT = re.compile(r"//[^\n]*")
86
+ _STRING = re.compile(r'"(?:\\.|[^"\\\n])*"')
87
+ _MODULE = re.compile(r"\bmodule\s+([A-Za-z_]\w*)")
88
+ _DIRECTION = re.compile(r"\b(input|output|inout)\b")
89
+ _PORT_DECL = re.compile(
90
+ r"\b(input|output|inout)\b\s*(?:(?:logic|wire|reg|bit|var)\b\s*)?(?:(?:signed|unsigned)\b\s*)?"
91
+ r"(?:\[[^\]]*\]\s*)*([A-Za-z_]\w*)"
92
+ )
93
+ _LOCAL_DECL = re.compile(
94
+ r"^\s*(?:logic|reg|wire|bit)\b\s*(?:(?:signed|unsigned)\b\s*)?(?:\[[^\]]*\]\s*)*"
95
+ r"([A-Za-z_]\w*(?:\s*(?:\[[^\]]*\]\s*)*,\s*[A-Za-z_]\w*)*)"
96
+ )
97
+ _SEQ = re.compile(
98
+ r"\b(always_ff|always)\s*@\s*\(\s*(posedge|negedge)\s+([A-Za-z_]\w*)"
99
+ r"(?:\s*(?:or|,)\s*(posedge|negedge)\s+([A-Za-z_]\w*))?\s*\)"
100
+ )
101
+ _SYNC_RESET = re.compile(
102
+ r"\bif\s*\(\s*([!~])?\s*([A-Za-z_]\w*)\s*(?:==\s*1'b([01])\s*)?\)", re.IGNORECASE
103
+ )
104
+ _NONBLOCKING = re.compile(
105
+ r"(?:^|[;:]|\bbegin\b|\belse\b|\))\s*([A-Za-z_]\w*)\s*(?:\[[^\]]*\]\s*)*<=", re.MULTILINE
106
+ )
107
+ _COMB = re.compile(r"\balways_comb\b")
108
+ _BLOCKING = re.compile(
109
+ r"(?:^|[;:]|\bbegin\b|\belse\b|\))\s*([A-Za-z_]\w*)\s*(?:\[[^\]]*\]\s*)*=(?![=])", re.MULTILINE
110
+ )
111
+ _RESETLIKE = re.compile(r"rst|reset", re.IGNORECASE)
112
+
113
+
114
+ # --- reading a file ----------------------------------------------------------------------
115
+
116
+
117
+ def _blank(match: re.Match[str]) -> str:
118
+ """The match with every character but a newline replaced by a space."""
119
+ return re.sub(r"[^\n]", " ", match.group(0))
120
+
121
+
122
+ def code_text(text: str) -> str:
123
+ """`text` with comments and strings blanked out; lines and columns kept."""
124
+ text = _STRING.sub(_blank, text)
125
+ text = _BLOCK_COMMENT.sub(_blank, text)
126
+ return _LINE_COMMENT.sub(_blank, text)
127
+
128
+
129
+ def _line_of(text: str, offset: int) -> int:
130
+ return text.count("\n", 0, offset) + 1
131
+
132
+
133
+ def _case(name: str) -> Case | None:
134
+ if re.fullmatch(r"[a-z][a-z0-9_]*", name):
135
+ return "snake"
136
+ if re.fullmatch(r"[A-Z][A-Z0-9_]*", name):
137
+ return "upper"
138
+ if re.fullmatch(r"[a-z][A-Za-z0-9]*", name) and re.search(r"[A-Z]", name):
139
+ return "camel"
140
+ return None
141
+
142
+
143
+ def _suffix(name: str, suffixes: Sequence[str]) -> str | None:
144
+ return next((s for s in sorted(suffixes, key=len, reverse=True) if name.endswith(s)), None)
145
+
146
+
147
+ @dataclass
148
+ class SeqBlock:
149
+ """One sequential block: its keyword, clock, and reset as written."""
150
+
151
+ line: int
152
+ keyword: str
153
+ clock_edge: str
154
+ clock: str
155
+ reset_kind: Literal["async", "sync"] | None
156
+ reset_active: Literal["low", "high"] | None
157
+ reset: str | None
158
+
159
+
160
+ @dataclass
161
+ class Observed:
162
+ """What one file shows of each feature (the raw facts `style_profile` votes over)."""
163
+
164
+ path: str
165
+ lines: list[str]
166
+ indents: list[tuple[int, str]] = field(default_factory=list)
167
+ """(line, leading whitespace) of every indented line of code."""
168
+ steps: list[tuple[int, int]] = field(default_factory=list)
169
+ """(line, step) for every increase of the indent between two lines of code."""
170
+ tab_lines: int = 0
171
+ begins: list[tuple[int, str]] = field(default_factory=list)
172
+ """(line, 'same_line' | 'next_line') for every `begin`."""
173
+ ansi: bool | None = None
174
+ one_per_line: bool | None = None
175
+ aligned: bool | None = None
176
+ port_line: int = 1
177
+ ports: list[tuple[int, str, str]] = field(default_factory=list)
178
+ """(line, direction, name) of every header port."""
179
+ seq: list[SeqBlock] = field(default_factory=list)
180
+ registered: list[tuple[int, str]] = field(default_factory=list)
181
+ """(line, name) of every signal assigned with `<=` (first assignment)."""
182
+ combinational: list[tuple[int, str]] = field(default_factory=list)
183
+ """(line, name) of every local signal assigned with `=` in an `always_comb`."""
184
+ locals: list[tuple[int, str]] = field(default_factory=list)
185
+ """(line, name) of every local signal declaration."""
186
+ header: bool = False
187
+ blank_runs: list[tuple[int, int]] = field(default_factory=list)
188
+ """(first line, length) of every run of blank lines."""
189
+ long_lines: list[tuple[int, int]] = field(default_factory=list)
190
+ """(line, length) of every line, longest first."""
191
+
192
+
193
+ def _module_header(code: str) -> tuple[int, int] | None:
194
+ """The span of the first module header's port list `( ... );`, or None."""
195
+ match = _MODULE.search(code)
196
+ if match is None:
197
+ return None
198
+ pos = match.end()
199
+ params = re.compile(r"\s*#\s*\(").match(code, pos)
200
+ if params is not None: # skip the parameter list
201
+ depth, i = 1, params.end()
202
+ while i < len(code) and depth:
203
+ depth += {"(": 1, ")": -1}.get(code[i], 0)
204
+ i += 1
205
+ pos = i
206
+ open_ = code.find("(", pos)
207
+ semi = code.find(";", pos)
208
+ if open_ < 0 or (0 <= semi < open_):
209
+ return None
210
+ depth, i = 1, open_ + 1
211
+ while i < len(code) and depth:
212
+ depth += {"(": 1, ")": -1}.get(code[i], 0)
213
+ i += 1
214
+ return open_ + 1, i - 1
215
+
216
+
217
+ def observe(text: str, path: str = "<text>") -> Observed:
218
+ """Every fact `style_profile` and `style_issues` read from one file's text."""
219
+ code = code_text(text)
220
+ raw_lines = text.splitlines()
221
+ code_lines = code.splitlines()
222
+ obs = Observed(path=path, lines=raw_lines)
223
+
224
+ # indentation, `begin` placement
225
+ previous: int | None = None
226
+ for number, line in enumerate(code_lines, start=1):
227
+ if not line.strip():
228
+ continue
229
+ lead = line[: len(line) - len(line.lstrip())]
230
+ width = len(lead.expandtabs(8))
231
+ if lead:
232
+ obs.indents.append((number, lead))
233
+ if "\t" in lead:
234
+ obs.tab_lines += 1
235
+ if previous is not None and width > previous:
236
+ obs.steps.append((number, width - previous))
237
+ previous = width
238
+ for match in re.finditer(r"\bbegin\b", line):
239
+ alone = not line[: match.start()].strip()
240
+ obs.begins.append((number, "next_line" if alone else "same_line"))
241
+
242
+ # the header comment, blank lines, line lengths
243
+ # A comment before the module (after a `timescale` or an include, say) is a header.
244
+ head: list[str] = []
245
+ for line in raw_lines:
246
+ if re.match(r"\s*(?:module|interface|package)\b", line):
247
+ break
248
+ head.append(line.strip())
249
+ obs.header = any(line.startswith(("//", "/*")) for line in head)
250
+ run_start, run = 0, 0
251
+ for number, line in enumerate([*raw_lines, "x"], start=1):
252
+ if not line.strip():
253
+ run_start, run = (number if run == 0 else run_start), run + 1
254
+ continue
255
+ if run:
256
+ obs.blank_runs.append((run_start, run))
257
+ run = 0
258
+ obs.long_lines = sorted(
259
+ ((n, len(line.rstrip())) for n, line in enumerate(raw_lines, start=1)),
260
+ key=lambda item: (-item[1], item[0]),
261
+ )
262
+
263
+ # the port list
264
+ header = _module_header(code)
265
+ if header is not None:
266
+ start, end = header
267
+ body = code[start:end]
268
+ base_line = _line_of(code, start)
269
+ obs.port_line = base_line
270
+ obs.ansi = bool(_DIRECTION.search(body))
271
+ if obs.ansi:
272
+ columns: list[int] = []
273
+ lengths: set[int] = set()
274
+ many = False
275
+ for offset, line in enumerate(body.split("\n")):
276
+ found = list(_DIRECTION.finditer(line))
277
+ if len(found) > 1:
278
+ many = True
279
+ for match in found:
280
+ rest = line[match.end() :]
281
+ columns.append(match.end() + len(rest) - len(rest.lstrip()))
282
+ lengths.add(len(match.group(1)))
283
+ for port in _PORT_DECL.finditer(line):
284
+ obs.ports.append((base_line + offset, port.group(1), port.group(2)))
285
+ obs.one_per_line = not many
286
+ # Padded or not shows only when directions of different lengths are declared.
287
+ if len(lengths) > 1:
288
+ obs.aligned = len(set(columns)) == 1
289
+
290
+ port_names = {name for _, _, name in obs.ports}
291
+
292
+ # sequential blocks and their resets
293
+ for seq_m in _SEQ.finditer(code):
294
+ at = _line_of(code, seq_m.start())
295
+ keyword, edge, clock = seq_m.group(1), seq_m.group(2), seq_m.group(3)
296
+ kind: Literal["async", "sync"] | None = None
297
+ active: Literal["low", "high"] | None = None
298
+ reset: str | None = None
299
+ if seq_m.group(4) is not None:
300
+ second_edge, second = seq_m.group(4), seq_m.group(5)
301
+ if _RESETLIKE.search(clock) and not _RESETLIKE.search(second):
302
+ clock, second, edge, second_edge = second, clock, second_edge, edge
303
+ kind, reset = "async", second
304
+ active = "low" if second_edge == "negedge" else "high"
305
+ else:
306
+ window = code[seq_m.end() : seq_m.end() + 400]
307
+ cond = _SYNC_RESET.search(window)
308
+ if cond is not None and _RESETLIKE.search(cond.group(2)):
309
+ kind, reset = "sync", cond.group(2)
310
+ negated = bool(cond.group(1)) != (cond.group(3) == "0")
311
+ active = "low" if negated else "high"
312
+ obs.seq.append(SeqBlock(at, keyword, edge, clock, kind, active, reset))
313
+
314
+ # registered and combinational signals, local declarations
315
+ seen: set[str] = set()
316
+ for nb in _NONBLOCKING.finditer(code):
317
+ name = nb.group(1)
318
+ if name in seen or name in ("if", "else", "begin", "end", "case"):
319
+ continue
320
+ seen.add(name)
321
+ obs.registered.append((_line_of(code, nb.start(1)), name))
322
+ for number, line in enumerate(code_lines, start=1):
323
+ decl = _LOCAL_DECL.match(line)
324
+ if decl is None:
325
+ continue
326
+ for name in re.findall(r"[A-Za-z_]\w*", re.sub(r"\[[^\]]*\]", " ", decl.group(1))):
327
+ if name not in port_names and name not in ("signed", "unsigned"):
328
+ obs.locals.append((number, name))
329
+ local_names = {name for _, name in obs.locals}
330
+ comb_seen: set[str] = set()
331
+ for comb in _COMB.finditer(code):
332
+ stop = min((m.start() for m in re.finditer(r"\balways", code[comb.end() :])), default=None)
333
+ chunk_end = comb.end() + stop if stop is not None else len(code)
334
+ chunk = code[comb.end() : chunk_end]
335
+ for match in _BLOCKING.finditer(chunk):
336
+ name = match.group(1)
337
+ if name in local_names and name not in comb_seen and name not in seen:
338
+ comb_seen.add(name)
339
+ obs.combinational.append((_line_of(code, comb.end() + match.start(1)), name))
340
+ return obs
341
+
342
+
343
+ # --- the profile -------------------------------------------------------------------------
344
+
345
+
346
+ class PortStyle(BaseModel):
347
+ """How the exemplars declare ports."""
348
+
349
+ model_config = ConfigDict(extra="forbid", frozen=True)
350
+
351
+ ansi: bool = Field(description="Ports declared in the module header (ANSI style).")
352
+ one_per_line: bool = Field(description="At most one port declaration per line.")
353
+ aligned: bool = Field(description="Direction keywords padded so the types line up.")
354
+
355
+
356
+ class ResetStyle(BaseModel):
357
+ """How the exemplars reset their registers."""
358
+
359
+ model_config = ConfigDict(extra="forbid", frozen=True)
360
+
361
+ kind: Literal["async", "sync"] = Field(description="Asynchronous or synchronous reset.")
362
+ active: Literal["low", "high"] = Field(description="The active level.")
363
+ name: str = Field(description="The reset signal's name.")
364
+
365
+
366
+ class SequentialStyle(BaseModel):
367
+ """How the exemplars write a sequential block."""
368
+
369
+ model_config = ConfigDict(extra="forbid", frozen=True)
370
+
371
+ keyword: str = Field(description="'always_ff' or 'always'.")
372
+ clock_edge: Literal["posedge", "negedge"] = Field(description="The clock edge.")
373
+ clock: str = Field(description="The clock signal's name.")
374
+ template: str = Field(description="The block's head as written, e.g. 'always_ff @(...)'.")
375
+
376
+
377
+ class StyleProfile(BaseModel):
378
+ """The style habits a set of exemplar files share (`style_profile`)."""
379
+
380
+ model_config = ConfigDict(extra="forbid", frozen=True)
381
+
382
+ schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
383
+ exemplars: tuple[str, ...] = Field(description="The files it was derived from.")
384
+ indent_char: Literal["space", "tab"] | None = Field(default=None, description="Indent char.")
385
+ indent_width: int | None = Field(default=None, description="The indent step, in columns.")
386
+ begin: Literal["same_line", "next_line"] | None = Field(
387
+ default=None, description="Where `begin` goes."
388
+ )
389
+ ports: PortStyle | None = Field(default=None, description="Port declarations.")
390
+ reset: ResetStyle | None = Field(default=None, description="The reset style.")
391
+ sequential: SequentialStyle | None = Field(default=None, description="Sequential blocks.")
392
+ reg_suffix: str | None = Field(default=None, description="Suffix of registered signals.")
393
+ next_suffix: str | None = Field(default=None, description="Suffix of next-state signals.")
394
+ port_suffixes: bool | None = Field(
395
+ default=None, description="Inputs end in _i and outputs in _o."
396
+ )
397
+ suffix_freq: dict[str, float] = Field(
398
+ default_factory=dict, description="Share of declared names with each suffix."
399
+ )
400
+ header: bool | None = Field(default=None, description="A comment header comes first.")
401
+ max_blank_lines: int | None = Field(default=None, description="Most blank lines in a row.")
402
+ max_line_length: int | None = Field(default=None, description="The longest line.")
403
+ local_case: Case | None = Field(default=None, description="Naming case of local signals.")
404
+
405
+ def describe(self) -> dict[str, str]:
406
+ """Each measured feature as text, by key (what the RTL Author's context shows)."""
407
+ out: dict[str, str] = {}
408
+ if self.indent_width is not None:
409
+ out["indent"] = f"{self.indent_width} {self.indent_char or 'space'}s per level"
410
+ if self.begin is not None:
411
+ out["begin"] = (
412
+ "`begin` at the end of the line" if self.begin == "same_line" else "`begin` alone"
413
+ )
414
+ if self.ports is not None:
415
+ p = self.ports
416
+ out["ports"] = ", ".join(
417
+ [
418
+ "ANSI ports in the header" if p.ansi else "non-ANSI ports",
419
+ "one per line" if p.one_per_line else "several per line",
420
+ "direction keywords padded so the types line up" if p.aligned else "unaligned",
421
+ ]
422
+ )
423
+ if self.reset is not None:
424
+ r = self.reset
425
+ out["reset"] = f"{r.kind}hronous, active {r.active}, named {r.name}"
426
+ if self.sequential is not None:
427
+ out["sequential"] = self.sequential.template
428
+ parts = []
429
+ if self.reg_suffix is not None:
430
+ parts.append(f"registers end in {self.reg_suffix}")
431
+ if self.next_suffix is not None:
432
+ parts.append(f"next values end in {self.next_suffix}")
433
+ if self.port_suffixes:
434
+ parts.append("inputs end in _i, outputs in _o")
435
+ if parts:
436
+ out["suffix"] = ", ".join(parts)
437
+ if self.header is not None:
438
+ out["header"] = "a comment header first" if self.header else "no comment header"
439
+ if self.max_blank_lines is not None:
440
+ out["blank_lines"] = f"at most {self.max_blank_lines} blank line(s) in a row"
441
+ if self.max_line_length is not None:
442
+ out["line_length"] = f"lines of at most {self.max_line_length} characters"
443
+ if self.local_case is not None:
444
+ out["case"] = f"local signals in {self.local_case} case"
445
+ return out
446
+
447
+
448
+ def _majority[T](values: Iterable[T], share: float = 0.5) -> T | None:
449
+ counts = Counter(values)
450
+ if not counts:
451
+ return None
452
+ value, count = counts.most_common(1)[0]
453
+ return value if count / sum(counts.values()) > share else None
454
+
455
+
456
+ def _width(obs: Iterable[Observed]) -> int | None:
457
+ return _majority(step for o in obs for _, step in o.steps)
458
+
459
+
460
+ def style_profile(exemplars: Mapping[str, str] | Sequence[Observed]) -> StyleProfile:
461
+ """The `StyleProfile` of exemplar files: path to text, or `observe`d files.
462
+
463
+ Pure and deterministic: every feature is the habit most of the exemplars' instances
464
+ show (more than half; else it is not measured).
465
+ """
466
+ files = (
467
+ [observe(text, path) for path, text in sorted(exemplars.items())]
468
+ if isinstance(exemplars, Mapping)
469
+ else list(exemplars)
470
+ )
471
+ indented = sum(len(o.indents) for o in files)
472
+ tabs = sum(o.tab_lines for o in files)
473
+ ports = [
474
+ (o.ansi, o.one_per_line, o.aligned)
475
+ for o in files
476
+ if o.ansi is not None and o.one_per_line is not None and o.aligned is not None
477
+ ]
478
+ port_style = _majority(ports)
479
+ blocks = [b for o in files for b in o.seq]
480
+ resets = [(b.reset_kind, b.reset_active, b.reset) for b in blocks if b.reset_kind is not None]
481
+ reset = _majority(resets)
482
+ seq = _majority((b.keyword, b.clock_edge, b.clock) for b in blocks)
483
+ registered = [name for o in files for _, name in o.registered]
484
+ reg = _majority(_suffix(n, REG_SUFFIXES) for n in registered)
485
+ combinational = [name for o in files for _, name in o.combinational]
486
+ nxt = _majority(_suffix(n, NEXT_SUFFIXES) for n in combinational)
487
+ port_names = [(d, n) for o in files for _, d, n in o.ports]
488
+ io = [
489
+ n.endswith("_i") if d == "input" else n.endswith("_o")
490
+ for d, n in port_names
491
+ if d in ("input", "output") and not _RESETLIKE.search(n) and n not in _clocks(blocks)
492
+ ]
493
+ declared = [*(n for _, n in port_names), *(n for o in files for _, n in o.locals)]
494
+ freq = (
495
+ {
496
+ s: round(sum(1 for n in declared if n.endswith(s)) / len(declared), 3)
497
+ for s in REPORTED_SUFFIXES
498
+ }
499
+ if declared
500
+ else {}
501
+ )
502
+ locals_ = [name for o in files for _, name in o.locals]
503
+ return StyleProfile(
504
+ exemplars=tuple(o.path for o in files),
505
+ indent_char=("tab" if tabs * 2 > indented else "space") if indented else None,
506
+ indent_width=_width(files),
507
+ begin=_begin(files),
508
+ ports=PortStyle(ansi=port_style[0], one_per_line=port_style[1], aligned=port_style[2])
509
+ if port_style is not None
510
+ else None,
511
+ reset=ResetStyle(kind=reset[0], active=reset[1], name=reset[2])
512
+ if reset is not None and reset[0] and reset[1] and reset[2]
513
+ else None,
514
+ sequential=SequentialStyle(
515
+ keyword=seq[0],
516
+ clock_edge=seq[1], # type: ignore[arg-type]
517
+ clock=seq[2],
518
+ template=_template(seq, reset),
519
+ )
520
+ if seq is not None
521
+ else None,
522
+ reg_suffix=reg,
523
+ next_suffix=nxt,
524
+ port_suffixes=(sum(io) / len(io) >= 0.8) if io else None,
525
+ suffix_freq=freq,
526
+ header=_majority(o.header for o in files),
527
+ max_blank_lines=max((n for o in files for _, n in o.blank_runs), default=0)
528
+ if files
529
+ else None,
530
+ max_line_length=max((o.long_lines[0][1] for o in files if o.long_lines), default=None),
531
+ local_case=_majority(c for c in (_case(n) for n in locals_) if c is not None),
532
+ )
533
+
534
+
535
+ def _begin(files: Iterable[Observed]) -> Literal["same_line", "next_line"] | None:
536
+ found = _majority(kind for o in files for _, kind in o.begins)
537
+ return "same_line" if found == "same_line" else "next_line" if found == "next_line" else None
538
+
539
+
540
+ def _clocks(blocks: Iterable[SeqBlock]) -> set[str]:
541
+ return {b.clock for b in blocks}
542
+
543
+
544
+ def _template(seq: tuple[str, str, str], reset: tuple[Any, Any, Any] | None) -> str:
545
+ keyword, edge, clock = seq
546
+ if reset is not None and reset[0] == "async":
547
+ reset_edge = "negedge" if reset[1] == "low" else "posedge"
548
+ return f"{keyword} @({edge} {clock} or {reset_edge} {reset[2]})"
549
+ return f"{keyword} @({edge} {clock})"
550
+
551
+
552
+ # --- deviations --------------------------------------------------------------------------
553
+
554
+
555
+ @dataclass(frozen=True)
556
+ class Deviation:
557
+ """One place a file departs from the profile."""
558
+
559
+ key: StyleKey
560
+ line: int
561
+ msg: str
562
+
563
+
564
+ def deviations(profile: StyleProfile, obs: Observed) -> tuple[list[Deviation], dict[str, float]]:
565
+ """Every deviation of `obs` from `profile`, and each measured feature's share of them.
566
+
567
+ The share is in [0, 1]: 0 when the file follows the habit everywhere it shows it.
568
+ A feature the profile or the file does not show is not in the shares.
569
+ """
570
+ found: list[Deviation] = []
571
+ share: dict[str, float] = {}
572
+
573
+ def add(key: StyleKey, line: int, msg: str) -> None:
574
+ found.append(Deviation(key, line, msg))
575
+
576
+ # indent
577
+ if profile.indent_width is not None and obs.indents:
578
+ bad = 0.0
579
+ char = "tab" if obs.tab_lines * 2 > len(obs.indents) else "space"
580
+ if profile.indent_char is not None and char != profile.indent_char:
581
+ line = next(n for n, lead in obs.indents if (("\t" in lead) == (char == "tab")))
582
+ add("indent", line, f"indents with {char}s (exemplars: {profile.indent_char}s)")
583
+ bad = 1.0
584
+ width = _width([obs])
585
+ if width is not None and width != profile.indent_width:
586
+ line = next(n for n, step in obs.steps if step == width)
587
+ add(
588
+ "indent",
589
+ line,
590
+ f"indents {width} columns per level (exemplars: {profile.indent_width})",
591
+ )
592
+ bad = 1.0
593
+ share["indent"] = bad
594
+ # begin
595
+ if profile.begin is not None and obs.begins:
596
+ off = [(n, k) for n, k in obs.begins if k != profile.begin]
597
+ where = "at the end of the line" if profile.begin == "same_line" else "on its own line"
598
+ for n, _ in off:
599
+ add("begin", n, f"`begin` placement differs (exemplars: `begin` {where})")
600
+ share["begin"] = len(off) / len(obs.begins)
601
+ # ports
602
+ if profile.ports is not None and obs.ansi is not None:
603
+ want = profile.ports
604
+ have = (obs.ansi, obs.one_per_line, obs.aligned)
605
+ names = ("ANSI ports in the header", "one port per line", "padded direction keywords")
606
+ misses = 0
607
+ wants = (want.ansi, want.one_per_line, want.aligned)
608
+ for name, w, h in zip(names, wants, have, strict=True):
609
+ if h is not None and h != w:
610
+ misses += 1
611
+ add("ports", obs.port_line, f"port style: {name} is {h} (exemplars: {w})")
612
+ share["ports"] = misses / 3
613
+ # reset and sequential blocks
614
+ if obs.seq:
615
+ if profile.reset is not None:
616
+ want_r = profile.reset
617
+ resets = [b for b in obs.seq if b.reset_kind is not None]
618
+ off_r = 0
619
+ for b in resets:
620
+ parts = []
621
+ kind_off = b.reset_kind != want_r.kind
622
+ if kind_off:
623
+ add(
624
+ "reset_kind",
625
+ b.line,
626
+ f"reset is {b.reset_kind}hronous, the exemplars' is {want_r.kind}hronous: "
627
+ "a different kind of reset changes the behaviour, not only the style",
628
+ )
629
+ if b.reset_active != want_r.active:
630
+ parts.append(f"active {b.reset_active} (exemplars: active {want_r.active})")
631
+ if b.reset != want_r.name:
632
+ parts.append(f"named {b.reset} (exemplars: {want_r.name})")
633
+ if parts:
634
+ add("reset", b.line, "reset is " + ", ".join(parts))
635
+ if parts or kind_off:
636
+ off_r += 1
637
+ missing = [b for b in obs.seq if b.reset_kind is None]
638
+ for b in missing[:1]:
639
+ if not resets:
640
+ add("reset", b.line, f"no reset found (exemplars: {want_r.kind}hronous)")
641
+ share["reset"] = (off_r + (len(missing) if not resets else 0)) / len(obs.seq)
642
+ if profile.sequential is not None:
643
+ want_s = profile.sequential
644
+ off_s = 0
645
+ for b in obs.seq:
646
+ parts = []
647
+ if b.keyword != want_s.keyword:
648
+ parts.append(f"`{b.keyword}` (exemplars: `{want_s.keyword}`)")
649
+ if b.clock_edge != want_s.clock_edge or b.clock != want_s.clock:
650
+ parts.append(
651
+ f"clocked on {b.clock_edge} {b.clock} "
652
+ f"(exemplars: {want_s.clock_edge} {want_s.clock})"
653
+ )
654
+ if parts:
655
+ off_s += 1
656
+ add("sequential", b.line, "sequential block " + ", ".join(parts))
657
+ share["sequential"] = off_s / len(obs.seq)
658
+ # suffixes
659
+ checked = 0
660
+ off_x = 0
661
+ if profile.reg_suffix is not None and obs.registered:
662
+ for n, name in obs.registered:
663
+ checked += 1
664
+ if not name.endswith(profile.reg_suffix):
665
+ off_x += 1
666
+ add(
667
+ "suffix",
668
+ n,
669
+ f"register {name!r} does not end in {profile.reg_suffix} "
670
+ f"(exemplars: registers end in {profile.reg_suffix})",
671
+ )
672
+ if profile.next_suffix is not None:
673
+ for n, name in obs.combinational:
674
+ other = _suffix(name, NEXT_SUFFIXES) or _suffix(name, REG_SUFFIXES)
675
+ if other is None:
676
+ continue
677
+ checked += 1
678
+ if other != profile.next_suffix:
679
+ off_x += 1
680
+ add(
681
+ "suffix",
682
+ n,
683
+ f"{name!r} ends in {other} "
684
+ f"(exemplars: next values end in {profile.next_suffix})",
685
+ )
686
+ if profile.port_suffixes is not None and obs.ports:
687
+ clocks = _clocks(obs.seq)
688
+ for n, d, name in obs.ports:
689
+ if d not in ("input", "output") or _RESETLIKE.search(name) or name in clocks:
690
+ continue
691
+ checked += 1
692
+ has = name.endswith("_i") if d == "input" else name.endswith("_o")
693
+ if has != profile.port_suffixes:
694
+ off_x += 1
695
+ want_txt = "end in _i/_o" if profile.port_suffixes else "carry no _i/_o suffix"
696
+ add("suffix", n, f"port {name!r}: exemplar ports {want_txt}")
697
+ if checked:
698
+ share["suffix"] = off_x / checked
699
+ # header
700
+ if profile.header is not None:
701
+ ok = obs.header == profile.header
702
+ if not ok:
703
+ add(
704
+ "header",
705
+ 1,
706
+ "a comment header "
707
+ + ("is missing" if profile.header else "comes first; the exemplars have none"),
708
+ )
709
+ share["header"] = 0.0 if ok else 1.0
710
+ # blank lines
711
+ if profile.max_blank_lines is not None:
712
+ runs = [(n, k) for n, k in obs.blank_runs if k > profile.max_blank_lines]
713
+ for n, k in runs:
714
+ add(
715
+ "blank_lines",
716
+ n,
717
+ f"{k} blank lines in a row (exemplars: at most {profile.max_blank_lines})",
718
+ )
719
+ share["blank_lines"] = 1.0 if runs else 0.0
720
+ # line length
721
+ if profile.max_line_length is not None and obs.long_lines:
722
+ over = [(n, k) for n, k in obs.long_lines if k > profile.max_line_length]
723
+ for n, k in sorted(over):
724
+ add(
725
+ "line_length",
726
+ n,
727
+ f"line of {k} characters (exemplars: at most {profile.max_line_length})",
728
+ )
729
+ share["line_length"] = 1.0 if over else 0.0
730
+ # case of locals
731
+ if profile.local_case is not None and obs.locals:
732
+ off_c = [(n, name) for n, name in obs.locals if _case(name) != profile.local_case]
733
+ for n, name in off_c:
734
+ add(
735
+ "case",
736
+ n,
737
+ f"local signal {name!r} is not {profile.local_case} case "
738
+ f"(exemplars: {profile.local_case} case)",
739
+ )
740
+ share["case"] = len(off_c) / len(obs.locals)
741
+ return found, share
742
+
743
+
744
+ def style_distance(profile: StyleProfile, file: str | Observed) -> float:
745
+ """How far a file (its text, or `observe`d) is from `profile`: the mean, over the
746
+ features both show, of the share of the file that departs from the habit; 0 to 1."""
747
+ obs = observe(file) if isinstance(file, str) else file
748
+ _, share = deviations(profile, obs)
749
+ return round(sum(share.values()) / len(share), 4) if share else 0.0
750
+
751
+
752
+ ALWAYS_ERRORS: frozenset[str] = frozenset({"reset_kind"})
753
+ """Keys reported as errors whatever `style.enforce` lists: a deviation that changes behaviour."""
754
+
755
+
756
+ def style_issues(
757
+ profile: StyleProfile,
758
+ obs: Observed,
759
+ *,
760
+ enforce: Iterable[str] = (),
761
+ ) -> list[Issue]:
762
+ """The deviations of one file as `Issue`s (`error` for the keys in `enforce`)."""
763
+ strict = set(enforce)
764
+ found, _ = deviations(profile, obs)
765
+ by_key: dict[str, list[Deviation]] = {}
766
+ for dev in found:
767
+ by_key.setdefault(dev.key, []).append(dev)
768
+ issues: list[Issue] = []
769
+ for key in STYLE_KEYS:
770
+ devs = by_key.get(key, [])
771
+ severity: Severity = "error" if key in strict or key in ALWAYS_ERRORS else "warning"
772
+ for dev in devs[:MAX_ISSUES_PER_KEY]:
773
+ issues.append(
774
+ Issue(
775
+ file=obs.path,
776
+ line=dev.line,
777
+ rule=f"style.{key}",
778
+ severity=severity,
779
+ msg=dev.msg,
780
+ )
781
+ )
782
+ if len(devs) > MAX_ISSUES_PER_KEY:
783
+ issues[-1] = issues[-1].model_copy(
784
+ update={"msg": f"{issues[-1].msg} (and {len(devs) - MAX_ISSUES_PER_KEY} more)"}
785
+ )
786
+ return issues
787
+
788
+
789
+ # --- the check -----------------------------------------------------------------------------
790
+
791
+
792
+ def _settings(args: Mapping[str, Any], root: Path) -> tuple[list[str], list[str]]:
793
+ """The exemplars and the enforced keys: args first, else the profile's `style`."""
794
+ exemplars = args.get("exemplars")
795
+ enforce = args.get("enforce")
796
+ if exemplars is None or enforce is None:
797
+ resolved = load_profile(root)
798
+ style = resolved.profile.style if resolved is not None else None
799
+ if exemplars is None:
800
+ exemplars = list(style.exemplars) if style is not None else []
801
+ if enforce is None:
802
+ enforce = list(style.enforce) if style is not None else []
803
+ for name, value in (("exemplars", exemplars), ("enforce", enforce)):
804
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
805
+ raise TargetError(f"args[{name!r}] must be a list of strings")
806
+ unknown = sorted(set(enforce) - set(STYLE_KEYS))
807
+ if unknown:
808
+ raise TargetError(f"unknown style keys in enforce: {', '.join(unknown)}")
809
+ return list(exemplars), list(enforce)
810
+
811
+
812
+ def _result(
813
+ spec: CheckSpec,
814
+ status: CheckStatus,
815
+ issues: list[Issue],
816
+ start: float,
817
+ hashes: list[tuple[str, str]],
818
+ ) -> CheckResult:
819
+ return CheckResult(
820
+ check_id=spec.id,
821
+ status=status,
822
+ issues=tuple(issues),
823
+ duration_s=time.monotonic() - start,
824
+ idempotency_key=compute_idempotency_key(spec.id, spec.args, hashes),
825
+ )
826
+
827
+
828
+ def _read(root: Path, rel: str) -> str:
829
+ return (root / rel).read_text(encoding="utf-8", errors="replace")
830
+
831
+
832
+ class StyleCheck:
833
+ """Measures a task's output files against the project's style exemplars."""
834
+
835
+ id = "style"
836
+ name = "Style"
837
+
838
+ async def run(self, spec: CheckSpec, ctx: ToolContext) -> CheckResult:
839
+ start = time.monotonic()
840
+ root = ctx.repo_root
841
+ try:
842
+ files = target_files(spec.args, ctx.params, root)
843
+ exemplars, enforce = _settings(spec.args, root)
844
+ except (TargetError, ConfigError) as exc:
845
+ return _result(spec, "error", [Issue(rule="style.args", msg=str(exc))], start, [])
846
+ if files is None:
847
+ note = "nothing to measure: the style check runs on an agent task's outputs"
848
+ return _result(
849
+ spec,
850
+ "skipped",
851
+ [Issue(severity="info", rule="style.no_files", msg=note)],
852
+ start,
853
+ [],
854
+ )
855
+ if not exemplars:
856
+ note = "no style.exemplars in the profile: nothing to measure the files against"
857
+ return _result(
858
+ spec,
859
+ "skipped",
860
+ [Issue(severity="info", rule="style.no_exemplars", msg=note)],
861
+ start,
862
+ [],
863
+ )
864
+ missing = [e for e in exemplars if not (root / e).is_file()]
865
+ if missing:
866
+ msg = f"style exemplars not found: {', '.join(missing)}"
867
+ return _result(spec, "error", [Issue(rule="style.exemplars", msg=msg)], start, [])
868
+
869
+ issues: list[Issue] = []
870
+ hashes = [(e, sha256_file(root / e)) for e in exemplars]
871
+ cache: dict[tuple[str, ...], StyleProfile] = {}
872
+ for rel in files:
873
+ if not (root / rel).is_file():
874
+ continue # the engine's own output check reports a missing output
875
+ own = tuple(e for e in exemplars if e != rel) or tuple(exemplars)
876
+ if own not in cache:
877
+ cache[own] = style_profile({e: _read(root, e) for e in own})
878
+ profile = cache[own]
879
+ obs = observe(_read(root, rel), rel)
880
+ issues.extend(style_issues(profile, obs, enforce=enforce))
881
+ issues.append(
882
+ Issue(
883
+ file=rel,
884
+ severity="info",
885
+ rule="style.distance",
886
+ msg=f"style distance to the exemplars: {style_distance(profile, obs):.4f}",
887
+ )
888
+ )
889
+ hashes.append((rel, sha256_file(root / rel)))
890
+ status: CheckStatus = "fail" if any(i.severity == "error" for i in issues) else "pass"
891
+ return _result(spec, status, issues, start, hashes)
892
+
893
+
894
+ __all__ = [
895
+ "NEXT_SUFFIXES",
896
+ "REG_SUFFIXES",
897
+ "Deviation",
898
+ "Observed",
899
+ "PortStyle",
900
+ "ResetStyle",
901
+ "SequentialStyle",
902
+ "StyleCheck",
903
+ "StyleProfile",
904
+ "code_text",
905
+ "deviations",
906
+ "observe",
907
+ "style_distance",
908
+ "style_issues",
909
+ "style_profile",
910
+ ]