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.
- chipgraph/__init__.py +7 -0
- chipgraph/adapters/__init__.py +1 -0
- chipgraph/adapters/format/__init__.py +6 -0
- chipgraph/adapters/format/__main__.py +16 -0
- chipgraph/adapters/format/_yaml_lines.py +54 -0
- chipgraph/adapters/format/chip_yaml.py +323 -0
- chipgraph/adapters/format/qsoc_contract.py +246 -0
- chipgraph/adapters/llm/__init__.py +58 -0
- chipgraph/adapters/llm/_budget.py +173 -0
- chipgraph/adapters/llm/_errors.py +50 -0
- chipgraph/adapters/llm/_nda.py +30 -0
- chipgraph/adapters/llm/_retry.py +85 -0
- chipgraph/adapters/llm/anthropic.py +247 -0
- chipgraph/adapters/llm/decide_backend.py +179 -0
- chipgraph/adapters/llm/fake.py +87 -0
- chipgraph/adapters/llm/guard.py +68 -0
- chipgraph/adapters/parser/__init__.py +22 -0
- chipgraph/adapters/parser/cocotb.py +304 -0
- chipgraph/adapters/parser/generic_regex.py +75 -0
- chipgraph/adapters/parser/verible.py +55 -0
- chipgraph/adapters/parser/verilator.py +46 -0
- chipgraph/adapters/review/__init__.py +7 -0
- chipgraph/adapters/review/file.py +123 -0
- chipgraph/adapters/review/github.py +886 -0
- chipgraph/adapters/runner/__init__.py +7 -0
- chipgraph/adapters/runner/local.py +122 -0
- chipgraph/adapters/runtime/__init__.py +5 -0
- chipgraph/adapters/runtime/claude_code/__init__.py +24 -0
- chipgraph/adapters/runtime/claude_code/agents.py +158 -0
- chipgraph/adapters/runtime/claude_code/decisions.py +280 -0
- chipgraph/adapters/runtime/claude_code/guarded.py +310 -0
- chipgraph/adapters/runtime/claude_code/isolation.py +482 -0
- chipgraph/adapters/runtime/claude_code/loop.py +342 -0
- chipgraph/adapters/runtime/claude_code/previous.py +419 -0
- chipgraph/adapters/runtime/claude_code/review.py +492 -0
- chipgraph/adapters/runtime/claude_code/runtime.py +192 -0
- chipgraph/adapters/runtime/claude_code/service.py +2285 -0
- chipgraph/adapters/tool/__init__.py +7 -0
- chipgraph/adapters/tool/cmd.py +229 -0
- chipgraph/adapters/tool/edalize.py +773 -0
- chipgraph/adapters/tool/filelist.py +259 -0
- chipgraph/adapters/tool/pyslang.py +485 -0
- chipgraph/adapters/vcs/__init__.py +1 -0
- chipgraph/adapters/vcs/git.py +521 -0
- chipgraph/app/__init__.py +10 -0
- chipgraph/app/baseline.py +339 -0
- chipgraph/app/build.py +332 -0
- chipgraph/app/checks.py +202 -0
- chipgraph/app/context.py +191 -0
- chipgraph/app/errors.py +11 -0
- chipgraph/app/executors.py +79 -0
- chipgraph/app/findings.py +203 -0
- chipgraph/app/ingest.py +523 -0
- chipgraph/checks/__init__.py +52 -0
- chipgraph/checks/_cdc_analyze.py +272 -0
- chipgraph/checks/_cdc_elaborate.py +455 -0
- chipgraph/checks/_common.py +268 -0
- chipgraph/checks/_cross.py +96 -0
- chipgraph/checks/_model.py +64 -0
- chipgraph/checks/_naming_pyslang.py +235 -0
- chipgraph/checks/_naming_rules.py +127 -0
- chipgraph/checks/_xref_syntax.py +260 -0
- chipgraph/checks/cdc_struct.py +235 -0
- chipgraph/checks/connect.py +353 -0
- chipgraph/checks/cross_chip.py +454 -0
- chipgraph/checks/duplicate.py +153 -0
- chipgraph/checks/filelist.py +323 -0
- chipgraph/checks/format.py +268 -0
- chipgraph/checks/generated.py +151 -0
- chipgraph/checks/hardcode.py +233 -0
- chipgraph/checks/layout.py +155 -0
- chipgraph/checks/naming.py +311 -0
- chipgraph/checks/plan_check.py +901 -0
- chipgraph/checks/ports_diff.py +203 -0
- chipgraph/checks/spec_schema.py +277 -0
- chipgraph/checks/style.py +910 -0
- chipgraph/checks/tb_static.py +599 -0
- chipgraph/checks/trace.py +224 -0
- chipgraph/checks/vendor_guard.py +369 -0
- chipgraph/checks/wrap_check.py +486 -0
- chipgraph/cli/__init__.py +1688 -0
- chipgraph/cli/next_steps.py +137 -0
- chipgraph/core/__init__.py +1 -0
- chipgraph/core/config/__init__.py +93 -0
- chipgraph/core/config/defaults.py +14 -0
- chipgraph/core/config/errors.py +32 -0
- chipgraph/core/config/loader.py +766 -0
- chipgraph/core/config/models.py +845 -0
- chipgraph/core/config/templates.py +340 -0
- chipgraph/core/contracts/__init__.py +75 -0
- chipgraph/core/contracts/_paths.py +26 -0
- chipgraph/core/contracts/agent.py +49 -0
- chipgraph/core/contracts/artifact.py +68 -0
- chipgraph/core/contracts/check.py +76 -0
- chipgraph/core/contracts/event.py +70 -0
- chipgraph/core/contracts/export.py +128 -0
- chipgraph/core/contracts/finding.py +171 -0
- chipgraph/core/contracts/gate.py +50 -0
- chipgraph/core/contracts/rule.py +177 -0
- chipgraph/core/contracts/types.py +45 -0
- chipgraph/core/engine/__init__.py +70 -0
- chipgraph/core/engine/agent_rule.py +1362 -0
- chipgraph/core/engine/baseline.py +283 -0
- chipgraph/core/engine/decide.py +462 -0
- chipgraph/core/engine/fanout.py +1358 -0
- chipgraph/core/engine/gate.py +170 -0
- chipgraph/core/engine/graph.py +647 -0
- chipgraph/core/engine/records.py +60 -0
- chipgraph/core/engine/rules.py +92 -0
- chipgraph/core/engine/scheduler.py +637 -0
- chipgraph/core/engine/select.py +1239 -0
- chipgraph/core/engine/targets.py +342 -0
- chipgraph/core/model/__init__.py +98 -0
- chipgraph/core/model/entities.py +244 -0
- chipgraph/core/model/export.py +16 -0
- chipgraph/core/model/ingest.py +693 -0
- chipgraph/core/model/json_value.py +4 -0
- chipgraph/core/model/keys.py +95 -0
- chipgraph/core/model/model.py +153 -0
- chipgraph/core/model/provenance.py +27 -0
- chipgraph/core/model/query.py +583 -0
- chipgraph/core/model/registry.py +80 -0
- chipgraph/core/model/relations.py +61 -0
- chipgraph/core/model/store.py +279 -0
- chipgraph/core/plugin_api/__init__.py +81 -0
- chipgraph/core/plugin_api/local.py +167 -0
- chipgraph/core/plugin_api/pack.py +159 -0
- chipgraph/core/plugin_api/protocols.py +177 -0
- chipgraph/core/plugin_api/registry.py +111 -0
- chipgraph/core/plugin_api/types.py +94 -0
- chipgraph/core/runtime/__init__.py +41 -0
- chipgraph/core/runtime/executor.py +79 -0
- chipgraph/core/runtime/queue.py +565 -0
- chipgraph/core/runtime/roles/__init__.py +91 -0
- chipgraph/core/runtime/roles/data/author.md +41 -0
- chipgraph/core/runtime/roles/data/critic.md +56 -0
- chipgraph/core/runtime/roles/data/planner.md +38 -0
- chipgraph/core/runtime/roles/data/researcher.md +37 -0
- chipgraph/core/runtime/roles/data/rtl-author.md +64 -0
- chipgraph/core/runtime/roles/data/tb-author.md +48 -0
- chipgraph/core/runtime/roles/data/triage.md +34 -0
- chipgraph/core/runtime/roles/policy.py +299 -0
- chipgraph/core/runtime/roles/registry.py +52 -0
- chipgraph/core/runtime/roles/review.py +275 -0
- chipgraph/core/runtime/roles/schema.py +82 -0
- chipgraph/core/runtime/roles/skills.py +134 -0
- chipgraph/core/runtime/roles/spec.py +232 -0
- chipgraph/core/state/__init__.py +1 -0
- chipgraph/core/state/artifacts.py +156 -0
- chipgraph/core/state/backend.py +117 -0
- chipgraph/core/state/findings.py +151 -0
- chipgraph/core/state/handoff.py +411 -0
- chipgraph/core/state/idempotency.py +59 -0
- chipgraph/core/state/journal.py +238 -0
- chipgraph/core/state/layout.py +82 -0
- chipgraph/core/state/lock.py +195 -0
- chipgraph/core/state/trace.py +225 -0
- chipgraph/learn/__init__.py +96 -0
- chipgraph/learn/draft.py +187 -0
- chipgraph/learn/infer.py +580 -0
- chipgraph/learn/models.py +145 -0
- chipgraph/learn/try_run.py +228 -0
- chipgraph/mcp/__init__.py +12 -0
- chipgraph/mcp/model_tools.py +167 -0
- chipgraph/mcp/server.py +787 -0
- chipgraph/orgs/qnsc/naming-v1.yml +113 -0
- chipgraph/orgs/qnsc/profile.yml +5 -0
- chipgraph/packs/__init__.py +8 -0
- chipgraph/packs/assist/__init__.py +16 -0
- chipgraph/packs/assist/ask/__init__.py +58 -0
- chipgraph/packs/assist/ask/_db.py +57 -0
- chipgraph/packs/assist/ask/_project.py +230 -0
- chipgraph/packs/assist/ask/answer.py +266 -0
- chipgraph/packs/assist/ask/check.py +182 -0
- chipgraph/packs/assist/ask/contract.py +115 -0
- chipgraph/packs/assist/ask/documents.py +157 -0
- chipgraph/packs/assist/ask/fts.py +80 -0
- chipgraph/packs/assist/ask/retrieve.py +331 -0
- chipgraph/packs/assist/audit/__init__.py +383 -0
- chipgraph/packs/assist/pack.yml +13 -0
- chipgraph/packs/assist/triage/__init__.py +64 -0
- chipgraph/packs/assist/triage/contract.py +94 -0
- chipgraph/packs/assist/triage/parse.py +371 -0
- chipgraph/packs/assist/triage/question.py +384 -0
- chipgraph/packs/assist/triage/report.py +160 -0
- chipgraph/packs/assist/triage/rules.py +447 -0
- chipgraph/packs/assist/triage/run.py +224 -0
- chipgraph/packs/digital_rtl/__init__.py +19 -0
- chipgraph/packs/digital_rtl/layout.py +144 -0
- chipgraph/packs/digital_rtl/pack.yml +25 -0
- chipgraph/packs/digital_rtl/plan/__init__.py +34 -0
- chipgraph/packs/digital_rtl/plan/__main__.py +16 -0
- chipgraph/packs/digital_rtl/plan/context.py +82 -0
- chipgraph/packs/digital_rtl/plan/expand.py +94 -0
- chipgraph/packs/digital_rtl/plan/model.py +150 -0
- chipgraph/packs/digital_rtl/plan/resolver.py +400 -0
- chipgraph/packs/digital_rtl/plan/show.py +176 -0
- chipgraph/packs/digital_rtl/pr/__init__.py +11 -0
- chipgraph/packs/digital_rtl/pr/body.py +165 -0
- chipgraph/packs/digital_rtl/pr/gate.py +110 -0
- chipgraph/packs/digital_rtl/pr/open.py +397 -0
- chipgraph/packs/digital_rtl/rtl/__init__.py +9 -0
- chipgraph/packs/digital_rtl/rtl/context.py +272 -0
- chipgraph/packs/digital_rtl/rules/open_pr.yml +30 -0
- chipgraph/packs/digital_rtl/rules/plan.yml +28 -0
- chipgraph/packs/digital_rtl/rules/plan_expand.yml +29 -0
- chipgraph/packs/digital_rtl/rules/pr_gate.yml +27 -0
- chipgraph/packs/digital_rtl/rules/review.yml +31 -0
- chipgraph/packs/digital_rtl/rules/rtl_module.yml +53 -0
- chipgraph/packs/digital_rtl/skills/lang-sv-rtl.md +58 -0
- chipgraph/packs/digital_rtl/skills/org-naming.md +29 -0
- chipgraph/packs/digital_rtl/skills/plan-modules.md +63 -0
- chipgraph/packs/digital_rtl/skills/review-diff.md +41 -0
- chipgraph/packs/digital_rtl/targets/plan.yml +11 -0
- chipgraph/packs/digital_rtl/targets/rtl.yml +20 -0
- chipgraph/packs/digital_rtl/wrapper/__init__.py +24 -0
- chipgraph/packs/digital_rtl/wrapper/common.py +171 -0
- chipgraph/packs/digital_rtl/wrapper/context.py +222 -0
- chipgraph/packs/digital_rtl/wrapper/emacs.py +626 -0
- chipgraph/packs/digital_rtl/wrapper/expansion.py +304 -0
- chipgraph/packs/digital_rtl/wrapper/lisp.py +504 -0
- chipgraph/packs/digital_rtl/wrapper/resolver.py +60 -0
- chipgraph/packs/digital_rtl/wrapper/rules/wrapper.yml +41 -0
- chipgraph/packs/digital_rtl/wrapper/rules/wrapper_gen.yml +35 -0
- chipgraph/packs/digital_rtl/wrapper/skills/wrapper-auto.md +120 -0
- chipgraph/packs/dv/__init__.py +15 -0
- chipgraph/packs/dv/declaration.py +392 -0
- chipgraph/packs/dv/interface.py +596 -0
- chipgraph/packs/dv/pack.yml +14 -0
- chipgraph/packs/dv/rules/tb_module.yml +42 -0
- chipgraph/packs/dv/skills/dv/cocotb.md +72 -0
- chipgraph/packs/spec_core/__init__.py +9 -0
- chipgraph/packs/spec_core/extract/__init__.py +12 -0
- chipgraph/packs/spec_core/extract/_markdown.py +371 -0
- chipgraph/packs/spec_core/extract/_text.py +116 -0
- chipgraph/packs/spec_core/extract/mas.py +975 -0
- chipgraph/packs/spec_core/gen/__init__.py +11 -0
- chipgraph/packs/spec_core/gen/agents_md/__init__.py +148 -0
- chipgraph/packs/spec_core/gen/agents_md/_context.py +239 -0
- chipgraph/packs/spec_core/gen/agents_md/_markers.py +131 -0
- chipgraph/packs/spec_core/gen/agents_md/agents_md.md.j2 +178 -0
- chipgraph/packs/spec_core/gen/diagram/__init__.py +110 -0
- chipgraph/packs/spec_core/gen/diagram/__main__.py +44 -0
- chipgraph/packs/spec_core/gen/diagram/_diagrams.py +403 -0
- chipgraph/packs/spec_core/gen/diagram/_scene.py +163 -0
- chipgraph/packs/spec_core/pack.yml +6 -0
- chipgraph/py.typed +0 -0
- chipgraph-0.1.0.dist-info/METADATA +53 -0
- chipgraph-0.1.0.dist-info/RECORD +252 -0
- chipgraph-0.1.0.dist-info/WHEEL +4 -0
- chipgraph-0.1.0.dist-info/entry_points.txt +61 -0
- 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
|
+
]
|