rtl-aid 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.
- rtl_aid/__init__.py +4 -0
- rtl_aid/cli.py +94 -0
- rtl_aid/core.py +364 -0
- rtl_aid/lint.py +184 -0
- rtl_aid-0.1.0.dist-info/METADATA +263 -0
- rtl_aid-0.1.0.dist-info/RECORD +10 -0
- rtl_aid-0.1.0.dist-info/WHEEL +5 -0
- rtl_aid-0.1.0.dist-info/entry_points.txt +3 -0
- rtl_aid-0.1.0.dist-info/licenses/LICENSE +21 -0
- rtl_aid-0.1.0.dist-info/top_level.txt +1 -0
rtl_aid/__init__.py
ADDED
rtl_aid/cli.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
from .core import VerilogWikiParser
|
|
3
|
+
|
|
4
|
+
def main():
|
|
5
|
+
parser = argparse.ArgumentParser()
|
|
6
|
+
|
|
7
|
+
group = parser.add_mutually_exclusive_group(required=True)
|
|
8
|
+
|
|
9
|
+
group.add_argument(
|
|
10
|
+
"-d", "--dir",
|
|
11
|
+
nargs="+",
|
|
12
|
+
metavar="DIR",
|
|
13
|
+
help="One or more directories to recursively scan for Verilog (.v) files"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
group.add_argument(
|
|
17
|
+
"-f", "--file",
|
|
18
|
+
nargs="+",
|
|
19
|
+
metavar="FILE",
|
|
20
|
+
help="One or more specific Verilog files to parse (no directory traversal)"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"-o", "--out",
|
|
25
|
+
default="temp/docs/modules",
|
|
26
|
+
metavar="OUT_DIR",
|
|
27
|
+
help="Output directory for generated markdown docs (default: temp/docs/modules)"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"-v",
|
|
32
|
+
action="count",
|
|
33
|
+
default=0,
|
|
34
|
+
help="Verbose mode: -v shows modified files, -vv shows detailed section diffs"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--ci",
|
|
39
|
+
action="store_true",
|
|
40
|
+
help="Enable CI mode: fail (exit 1) on issues like missing docs, no IO, or invalid structures"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--print-errors",
|
|
45
|
+
action="store_true",
|
|
46
|
+
help="Print errors to stdout in addition to the error log file"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--json-graph",
|
|
51
|
+
action="store_true",
|
|
52
|
+
help="Generate dependency graph as JSON (graph.json) in output directory"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"--exclude",
|
|
57
|
+
nargs="+",
|
|
58
|
+
metavar="EXCLUDE",
|
|
59
|
+
help="Directories or files to exclude from scanning"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--dry-run",
|
|
64
|
+
action="store_true",
|
|
65
|
+
help="Show what would be written without touching any files"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
args = parser.parse_args()
|
|
69
|
+
|
|
70
|
+
paths = args.dir if args.dir else args.file
|
|
71
|
+
|
|
72
|
+
v = VerilogWikiParser(
|
|
73
|
+
paths,
|
|
74
|
+
verbose=args.v,
|
|
75
|
+
ci=args.ci,
|
|
76
|
+
json_graph=args.json_graph,
|
|
77
|
+
print_errors=args.print_errors,
|
|
78
|
+
exclude=args.exclude,
|
|
79
|
+
dry_run=args.dry_run,
|
|
80
|
+
)
|
|
81
|
+
v.scan()
|
|
82
|
+
v.generate_markdown(args.out)
|
|
83
|
+
v.write_json(args.out)
|
|
84
|
+
v.write_log()
|
|
85
|
+
v.run_ci_checks()
|
|
86
|
+
|
|
87
|
+
total = len(v.modules)
|
|
88
|
+
written = len(v.modified_files)
|
|
89
|
+
unchanged = total - written
|
|
90
|
+
action = "would be written" if args.dry_run else "written"
|
|
91
|
+
print(f"\n{total} module(s) processed — {written} doc(s) {action}, {unchanged} unchanged")
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
main()
|
rtl_aid/core.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
class VerilogWikiParser(object):
|
|
8
|
+
def __init__(self, paths, verbose=0, ci=False, json_graph=False, print_errors=False, exclude=None, dry_run=False):
|
|
9
|
+
self.paths = paths
|
|
10
|
+
self.modules = {}
|
|
11
|
+
self.called_by = {}
|
|
12
|
+
self.modified_files = []
|
|
13
|
+
self.verbose = verbose
|
|
14
|
+
self.ci = ci
|
|
15
|
+
self.json_graph = json_graph
|
|
16
|
+
self.print_errors = print_errors
|
|
17
|
+
self.exclude = exclude or []
|
|
18
|
+
self.issues = []
|
|
19
|
+
self.dry_run = dry_run
|
|
20
|
+
|
|
21
|
+
# -------------------------
|
|
22
|
+
# CLEAN COMMENTS
|
|
23
|
+
# -------------------------
|
|
24
|
+
def clean(self, text):
|
|
25
|
+
text = re.sub(r"//[^\n]*", "\n", text)
|
|
26
|
+
text = re.sub(r"/\*.*?\*/", "", text, flags=re.S)
|
|
27
|
+
return text
|
|
28
|
+
|
|
29
|
+
# -------------------------
|
|
30
|
+
# PARSING
|
|
31
|
+
# -------------------------
|
|
32
|
+
def extract_module_and_ports(self, text):
|
|
33
|
+
pattern = r"module\s+(\w+)\s*(#\s*\((.*?)\))?\s*\((.*?)\)\s*;"
|
|
34
|
+
m = re.search(pattern, text, flags=re.S)
|
|
35
|
+
if not m:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
mod_name = m.group(1)
|
|
39
|
+
param_block = m.group(3) or ""
|
|
40
|
+
port_block = m.group(4)
|
|
41
|
+
|
|
42
|
+
inputs, outputs, inouts = [], [], []
|
|
43
|
+
parameters = []
|
|
44
|
+
|
|
45
|
+
for p in re.split(r",\s*\n|,\s*", param_block):
|
|
46
|
+
p = p.strip()
|
|
47
|
+
if p.startswith("parameter"):
|
|
48
|
+
param_str = re.sub(r"\bparameter\b", "", p).strip()
|
|
49
|
+
param_str = re.sub(r"^(integer|logic|reg|wire|bit|byte|shortint|int|longint|signed|unsigned)\s+", "", param_str)
|
|
50
|
+
parameters.append(param_str.strip())
|
|
51
|
+
|
|
52
|
+
ports = re.split(r",\s*\n|,\s*", port_block)
|
|
53
|
+
|
|
54
|
+
current_direction = None
|
|
55
|
+
for p in ports:
|
|
56
|
+
p = p.strip()
|
|
57
|
+
if not p:
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
p = re.sub(r"`\w+\s+", "", p)
|
|
61
|
+
tokens = p.split()
|
|
62
|
+
if not tokens:
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
name = tokens[-1]
|
|
66
|
+
|
|
67
|
+
if "input" in tokens:
|
|
68
|
+
current_direction = "input"
|
|
69
|
+
elif "output" in tokens:
|
|
70
|
+
current_direction = "output"
|
|
71
|
+
elif "inout" in tokens:
|
|
72
|
+
current_direction = "inout"
|
|
73
|
+
|
|
74
|
+
if current_direction == "input":
|
|
75
|
+
inputs.append(name)
|
|
76
|
+
elif current_direction == "output":
|
|
77
|
+
outputs.append(name)
|
|
78
|
+
elif current_direction == "inout":
|
|
79
|
+
inouts.append(name)
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
"name": mod_name,
|
|
83
|
+
"inputs": inputs,
|
|
84
|
+
"outputs": outputs,
|
|
85
|
+
"inouts": inouts,
|
|
86
|
+
"parameters": parameters
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
def remove_module_header(self, text):
|
|
90
|
+
return re.sub(r"\bmodule\b[^;]*;", "", text)
|
|
91
|
+
|
|
92
|
+
def extract_calls(self, text, known_modules, current_module):
|
|
93
|
+
calls = set()
|
|
94
|
+
text = self.remove_module_header(text)
|
|
95
|
+
|
|
96
|
+
pattern = r"\b(\w+)\s*(?:#\s*\(.*?\))?\s+\w+\s*\("
|
|
97
|
+
for m in re.finditer(pattern, text, flags=re.S):
|
|
98
|
+
mod_name = m.group(1)
|
|
99
|
+
if mod_name in known_modules:
|
|
100
|
+
calls.add(mod_name)
|
|
101
|
+
|
|
102
|
+
return sorted(list(calls))
|
|
103
|
+
|
|
104
|
+
_TB_SUFFIXES = ("_tb.v", "_tb.sv", "_bench.v", "_bench.sv", "_testbench.v", "_testbench.sv")
|
|
105
|
+
_RTL_EXTENSIONS = (".v", ".sv")
|
|
106
|
+
|
|
107
|
+
def _is_rtl_file(self, filename):
|
|
108
|
+
return (
|
|
109
|
+
any(filename.endswith(ext) for ext in self._RTL_EXTENSIONS)
|
|
110
|
+
and not any(filename.endswith(s) for s in self._TB_SUFFIXES)
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# -------------------------
|
|
114
|
+
# SCAN
|
|
115
|
+
# -------------------------
|
|
116
|
+
def scan(self):
|
|
117
|
+
file_texts = {}
|
|
118
|
+
all_files = []
|
|
119
|
+
|
|
120
|
+
def is_excluded(path):
|
|
121
|
+
for ex in self.exclude:
|
|
122
|
+
if ex in path:
|
|
123
|
+
return True
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
for p in self.paths:
|
|
127
|
+
if os.path.isfile(p):
|
|
128
|
+
if not is_excluded(p):
|
|
129
|
+
all_files.append(p)
|
|
130
|
+
else:
|
|
131
|
+
for root, dirs, files in os.walk(p):
|
|
132
|
+
dirs[:] = [d for d in dirs if not is_excluded(os.path.join(root, d))]
|
|
133
|
+
if is_excluded(root):
|
|
134
|
+
continue
|
|
135
|
+
for f in files:
|
|
136
|
+
if self._is_rtl_file(f):
|
|
137
|
+
full_path = os.path.join(root, f)
|
|
138
|
+
if not is_excluded(full_path):
|
|
139
|
+
all_files.append(full_path)
|
|
140
|
+
|
|
141
|
+
for path in all_files:
|
|
142
|
+
with open(path, "r") as fh:
|
|
143
|
+
text = self.clean(fh.read())
|
|
144
|
+
|
|
145
|
+
file_texts[path] = text
|
|
146
|
+
|
|
147
|
+
mod = self.extract_module_and_ports(text)
|
|
148
|
+
if not mod:
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
self.modules[mod["name"]] = {
|
|
152
|
+
"file": path,
|
|
153
|
+
"calls": [],
|
|
154
|
+
"inputs": mod["inputs"],
|
|
155
|
+
"outputs": mod["outputs"],
|
|
156
|
+
"inouts": mod["inouts"],
|
|
157
|
+
"parameters": mod["parameters"]
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
known_modules = set(self.modules.keys())
|
|
161
|
+
|
|
162
|
+
for path, text in file_texts.items():
|
|
163
|
+
mod = self.extract_module_and_ports(text)
|
|
164
|
+
if not mod:
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
mod_name = mod["name"]
|
|
168
|
+
self.modules[mod_name]["calls"] = self.extract_calls(
|
|
169
|
+
text, known_modules, mod_name
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
self.build_called_by()
|
|
173
|
+
|
|
174
|
+
def build_called_by(self):
|
|
175
|
+
for mod in self.modules:
|
|
176
|
+
self.called_by[mod] = []
|
|
177
|
+
|
|
178
|
+
for caller, data in self.modules.items():
|
|
179
|
+
for callee in data["calls"]:
|
|
180
|
+
if callee in self.called_by:
|
|
181
|
+
self.called_by[callee].append(caller)
|
|
182
|
+
|
|
183
|
+
# -------------------------
|
|
184
|
+
# EXISTING MD PARSE
|
|
185
|
+
# -------------------------
|
|
186
|
+
def parse_existing_sections(self, path):
|
|
187
|
+
if not os.path.exists(path):
|
|
188
|
+
return {}
|
|
189
|
+
|
|
190
|
+
with open(path) as f:
|
|
191
|
+
content = f.read()
|
|
192
|
+
|
|
193
|
+
sections = {}
|
|
194
|
+
for sec in ["Description", "Parameters", "Inputs", "Outputs", "Inouts", "Calls", "Called By"]:
|
|
195
|
+
m = re.search(rf"## {sec}\n(.*?)(\n##|\Z)", content, re.S)
|
|
196
|
+
if m:
|
|
197
|
+
sections[sec] = m.group(1).strip()
|
|
198
|
+
|
|
199
|
+
return sections
|
|
200
|
+
|
|
201
|
+
# -------------------------
|
|
202
|
+
# DIFF
|
|
203
|
+
# -------------------------
|
|
204
|
+
def diff_lists(self, old, new):
|
|
205
|
+
old_set = set(old)
|
|
206
|
+
new_set = set(new)
|
|
207
|
+
return len(new_set - old_set), len(old_set - new_set)
|
|
208
|
+
|
|
209
|
+
# -------------------------
|
|
210
|
+
# MARKDOWN
|
|
211
|
+
# -------------------------
|
|
212
|
+
def generate_markdown(self, out_dir):
|
|
213
|
+
if not self.dry_run:
|
|
214
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
215
|
+
|
|
216
|
+
managed_sections = ["Parameters", "Inputs", "Outputs", "Inouts", "Calls", "Called By"]
|
|
217
|
+
|
|
218
|
+
for mod, data in self.modules.items():
|
|
219
|
+
fname = os.path.join(out_dir, f"{mod}.md")
|
|
220
|
+
|
|
221
|
+
old_content = ""
|
|
222
|
+
if os.path.exists(fname):
|
|
223
|
+
with open(fname) as f:
|
|
224
|
+
old_content = f.read()
|
|
225
|
+
|
|
226
|
+
old_sections = self.parse_existing_sections(fname)
|
|
227
|
+
desc = old_sections.get("Description", "TODO: Add description")
|
|
228
|
+
|
|
229
|
+
if desc.startswith("TODO"):
|
|
230
|
+
self.issues.append(f"{mod}: missing description")
|
|
231
|
+
|
|
232
|
+
def format_list(lst):
|
|
233
|
+
return "\n".join([f"- {x}" for x in lst]) if lst else "- None"
|
|
234
|
+
|
|
235
|
+
new_sections = {
|
|
236
|
+
"Parameters": format_list(data["parameters"]),
|
|
237
|
+
"Inputs": format_list(data["inputs"]),
|
|
238
|
+
"Outputs": format_list(data["outputs"]),
|
|
239
|
+
"Inouts": format_list(data["inouts"]),
|
|
240
|
+
"Calls": format_list([f"[{c}]({c}.md)" for c in data["calls"]]),
|
|
241
|
+
"Called By": format_list([f"[{c}]({c}.md)" for c in self.called_by.get(mod, [])]),
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
# DIFF LOGIC
|
|
245
|
+
diffs = {}
|
|
246
|
+
for key in ["Parameters", "Inputs", "Outputs", "Inouts", "Calls"]:
|
|
247
|
+
old_list = re.findall(r"- (.+)", old_sections.get(key, ""))
|
|
248
|
+
new_list = re.findall(r"- (.+)", new_sections[key])
|
|
249
|
+
add, rem = self.diff_lists(old_list, new_list)
|
|
250
|
+
diffs[key] = (add, rem)
|
|
251
|
+
|
|
252
|
+
content = ""
|
|
253
|
+
if not old_content:
|
|
254
|
+
content = f"# {mod}\n\n## Description\n{desc}\n\n"
|
|
255
|
+
for k, v in new_sections.items():
|
|
256
|
+
content += f"## {k}\n{v}\n\n"
|
|
257
|
+
else:
|
|
258
|
+
content = old_content
|
|
259
|
+
for sec in managed_sections:
|
|
260
|
+
new_sec_text = f"## {sec}\n{new_sections[sec]}\n"
|
|
261
|
+
pattern = rf"## {sec}\n.*?(?=\n##|\Z)"
|
|
262
|
+
if re.search(pattern, content, flags=re.S):
|
|
263
|
+
content = re.sub(pattern, new_sec_text, content, flags=re.S)
|
|
264
|
+
else:
|
|
265
|
+
content += f"\n{new_sec_text}\n"
|
|
266
|
+
|
|
267
|
+
content = content.strip() + "\n"
|
|
268
|
+
|
|
269
|
+
if content != old_content:
|
|
270
|
+
if not self.dry_run:
|
|
271
|
+
with open(fname, "w") as f:
|
|
272
|
+
f.write(content)
|
|
273
|
+
|
|
274
|
+
diff_str = f"{mod}: " + ", ".join([f"{k} +{a}/-{r}" for k, (a, r) in diffs.items()])
|
|
275
|
+
self.modified_files.append((fname, diff_str))
|
|
276
|
+
|
|
277
|
+
# -------------------------
|
|
278
|
+
# LOGGING
|
|
279
|
+
# -------------------------
|
|
280
|
+
def write_log(self):
|
|
281
|
+
if self.modified_files:
|
|
282
|
+
if self.dry_run:
|
|
283
|
+
print("\n[DRY RUN] Would write:")
|
|
284
|
+
for fname, _ in self.modified_files:
|
|
285
|
+
print(f" {fname}")
|
|
286
|
+
if self.verbose >= 2:
|
|
287
|
+
print("\nDetailed section diffs:")
|
|
288
|
+
for _, diff_str in self.modified_files:
|
|
289
|
+
print(f" {diff_str}")
|
|
290
|
+
else:
|
|
291
|
+
tmp = tempfile.NamedTemporaryFile(delete=False, mode="w", suffix=".log")
|
|
292
|
+
tmp.write("Modified files:\n")
|
|
293
|
+
for fname, _ in self.modified_files:
|
|
294
|
+
tmp.write(fname + "\n")
|
|
295
|
+
tmp.write("\nDetailed section diffs:\n")
|
|
296
|
+
for _, diff_str in self.modified_files:
|
|
297
|
+
tmp.write(diff_str + "\n")
|
|
298
|
+
tmp.close()
|
|
299
|
+
|
|
300
|
+
if self.verbose >= 1:
|
|
301
|
+
print("\nModified files:")
|
|
302
|
+
for fname, _ in self.modified_files:
|
|
303
|
+
print(fname)
|
|
304
|
+
|
|
305
|
+
if self.verbose >= 2:
|
|
306
|
+
print("\nDetailed section diffs:")
|
|
307
|
+
for _, diff_str in self.modified_files:
|
|
308
|
+
print(diff_str)
|
|
309
|
+
|
|
310
|
+
print(f"\nLog file: {tmp.name}")
|
|
311
|
+
|
|
312
|
+
if not self.ci:
|
|
313
|
+
missing_docs = [mod for mod in self.modules if any(f"{mod}: missing description" in i for i in self.issues)]
|
|
314
|
+
if missing_docs:
|
|
315
|
+
print("\nMissing Docs Summary Report:")
|
|
316
|
+
for m in missing_docs:
|
|
317
|
+
print(f" - {m}")
|
|
318
|
+
|
|
319
|
+
# -------------------------
|
|
320
|
+
# JSON GRAPH
|
|
321
|
+
# -------------------------
|
|
322
|
+
def write_json(self, out_dir):
|
|
323
|
+
if not self.json_graph:
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
graph = {}
|
|
327
|
+
for m, d in self.modules.items():
|
|
328
|
+
graph[m] = {
|
|
329
|
+
"calls": d["calls"],
|
|
330
|
+
"called_by": self.called_by.get(m, [])
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
path = os.path.join(out_dir, "graph.json")
|
|
334
|
+
with open(path, "w") as f:
|
|
335
|
+
json.dump(graph, f, indent=2)
|
|
336
|
+
|
|
337
|
+
# -------------------------
|
|
338
|
+
# CI VALIDATION
|
|
339
|
+
# -------------------------
|
|
340
|
+
def run_ci_checks(self):
|
|
341
|
+
if not self.ci:
|
|
342
|
+
return
|
|
343
|
+
|
|
344
|
+
for mod, data in self.modules.items():
|
|
345
|
+
if not data["inputs"] and not data["outputs"]:
|
|
346
|
+
self.issues.append(f"{mod}: no IO")
|
|
347
|
+
|
|
348
|
+
if mod in data["calls"]:
|
|
349
|
+
self.issues.append(f"{mod}: self-instantiation")
|
|
350
|
+
|
|
351
|
+
if self.issues:
|
|
352
|
+
if not self.dry_run:
|
|
353
|
+
tmp = tempfile.NamedTemporaryFile(delete=False, mode="w", suffix="_errors.log")
|
|
354
|
+
tmp.write("CI FAIL:\n")
|
|
355
|
+
for i in self.issues:
|
|
356
|
+
tmp.write(i + "\n")
|
|
357
|
+
tmp.close()
|
|
358
|
+
print(f"\nError log file: {tmp.name}")
|
|
359
|
+
|
|
360
|
+
if self.print_errors or self.dry_run:
|
|
361
|
+
print("\nCI FAIL:")
|
|
362
|
+
for i in self.issues:
|
|
363
|
+
print(i)
|
|
364
|
+
sys.exit(1)
|
rtl_aid/lint.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
import os
|
|
5
|
+
import argparse
|
|
6
|
+
import shutil
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _get_verilator_version():
|
|
10
|
+
try:
|
|
11
|
+
out = subprocess.check_output(["verilator", "--version"], text=True)
|
|
12
|
+
return out.strip().splitlines()[0]
|
|
13
|
+
except Exception:
|
|
14
|
+
return None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _check_verilator():
|
|
18
|
+
if not shutil.which("verilator"):
|
|
19
|
+
print(
|
|
20
|
+
"Error: verilator is not installed or not in PATH.\n"
|
|
21
|
+
"\n"
|
|
22
|
+
"Install it:\n"
|
|
23
|
+
" Debian / Ubuntu: sudo apt install verilator\n"
|
|
24
|
+
" macOS: brew install verilator\n"
|
|
25
|
+
" From source: https://verilator.org/guide/latest/install.html\n"
|
|
26
|
+
"\n"
|
|
27
|
+
"Note: verilator is a system tool and cannot be installed via pip.",
|
|
28
|
+
file=sys.stderr,
|
|
29
|
+
)
|
|
30
|
+
sys.exit(1)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _run_lint(filepath, include_dirs):
|
|
34
|
+
cmd = ["verilator", "--lint-only", "-Wall"]
|
|
35
|
+
for d in include_dirs:
|
|
36
|
+
cmd.extend(["-I", d])
|
|
37
|
+
cmd.append(filepath)
|
|
38
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
39
|
+
return result.stdout + result.stderr, cmd
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_lint_output(output, filepath):
|
|
43
|
+
"""Return {line_num: message} for warnings/errors in filepath only.
|
|
44
|
+
|
|
45
|
+
Only the first issue per line is kept — multiple warnings on the same
|
|
46
|
+
line would produce unreadable inline comments. Verilator continuation
|
|
47
|
+
lines (context arrows, notes) are skipped; only lines starting with
|
|
48
|
+
'%Warning' or '%Error' are matched.
|
|
49
|
+
"""
|
|
50
|
+
issues = {}
|
|
51
|
+
target_base = os.path.basename(filepath)
|
|
52
|
+
target_abs = os.path.abspath(filepath)
|
|
53
|
+
|
|
54
|
+
# Format: %Warning-TYPE: path:line:col: message
|
|
55
|
+
# %Error: path:line:col: message
|
|
56
|
+
pattern = re.compile(r"^%(Warning[^:]*|Error[^:]*): (.+?):(\d+):\d+: (.+)$")
|
|
57
|
+
for line in output.splitlines():
|
|
58
|
+
m = pattern.match(line)
|
|
59
|
+
if not m:
|
|
60
|
+
continue
|
|
61
|
+
warn_file = m.group(2)
|
|
62
|
+
line_num = int(m.group(3))
|
|
63
|
+
message = m.group(4).strip()
|
|
64
|
+
|
|
65
|
+
if (os.path.basename(warn_file) == target_base
|
|
66
|
+
or os.path.abspath(warn_file) == target_abs):
|
|
67
|
+
if line_num not in issues:
|
|
68
|
+
issues[line_num] = message
|
|
69
|
+
|
|
70
|
+
return issues
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def tag_file(filepath, issues, lint_cmd):
|
|
74
|
+
"""Tag warned lines inline and add lint/tb-test header lines.
|
|
75
|
+
|
|
76
|
+
Idempotent: re-running replaces existing /* Check: */ tags and
|
|
77
|
+
skips header lines that are already present.
|
|
78
|
+
"""
|
|
79
|
+
with open(filepath, "r") as f:
|
|
80
|
+
lines = f.readlines()
|
|
81
|
+
|
|
82
|
+
# Tag each warned line with a trailing /* Check: ... */ comment.
|
|
83
|
+
# Verilator line numbers are 1-based; Python list is 0-based.
|
|
84
|
+
# Guard against line numbers that fall outside the file — this can
|
|
85
|
+
# happen when a warning originates inside a macro expansion.
|
|
86
|
+
for line_num, message in sorted(issues.items()):
|
|
87
|
+
idx = line_num - 1
|
|
88
|
+
if idx < 0 or idx >= len(lines):
|
|
89
|
+
continue
|
|
90
|
+
line = lines[idx].rstrip("\n")
|
|
91
|
+
line = re.sub(r"\s*/\* Check:.*?\*/", "", line).rstrip()
|
|
92
|
+
lines[idx] = f"{line} /* Check: {message} */\n"
|
|
93
|
+
|
|
94
|
+
# Insert lint/tb-test headers after the leading comment block (copyright
|
|
95
|
+
# headers, file-level comments, blank lines at the top).
|
|
96
|
+
insert_idx = 0
|
|
97
|
+
for i, line in enumerate(lines):
|
|
98
|
+
s = line.strip()
|
|
99
|
+
if s.startswith("//") or s.startswith("/*") or s.startswith("*") or s == "":
|
|
100
|
+
insert_idx = i + 1
|
|
101
|
+
else:
|
|
102
|
+
break
|
|
103
|
+
|
|
104
|
+
lint_cmd_str = " ".join(lint_cmd)
|
|
105
|
+
full_content = "".join(lines)
|
|
106
|
+
new_headers = []
|
|
107
|
+
if "// lint-test:" not in full_content:
|
|
108
|
+
new_headers.append(f"// lint-test: {lint_cmd_str}\n")
|
|
109
|
+
if "// tb-test:" not in full_content:
|
|
110
|
+
new_headers.append("// tb-test: tba\n")
|
|
111
|
+
|
|
112
|
+
if new_headers:
|
|
113
|
+
lines = lines[:insert_idx] + new_headers + lines[insert_idx:]
|
|
114
|
+
|
|
115
|
+
with open(filepath, "w") as f:
|
|
116
|
+
f.writelines(lines)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def main():
|
|
120
|
+
parser = argparse.ArgumentParser(
|
|
121
|
+
prog="rtllint",
|
|
122
|
+
description="Run verilator lint on Verilog files and tag warnings inline"
|
|
123
|
+
)
|
|
124
|
+
parser.add_argument(
|
|
125
|
+
"file",
|
|
126
|
+
nargs="+",
|
|
127
|
+
metavar="FILE",
|
|
128
|
+
help="Verilog/SystemVerilog file(s) to lint"
|
|
129
|
+
)
|
|
130
|
+
parser.add_argument(
|
|
131
|
+
"-I", "--include",
|
|
132
|
+
dest="include_dirs",
|
|
133
|
+
action="append",
|
|
134
|
+
metavar="DIR",
|
|
135
|
+
default=[],
|
|
136
|
+
help="Add include directory (passed to verilator as -I, repeatable)"
|
|
137
|
+
)
|
|
138
|
+
parser.add_argument(
|
|
139
|
+
"--dry-run",
|
|
140
|
+
action="store_true",
|
|
141
|
+
help="Show issues without modifying any files"
|
|
142
|
+
)
|
|
143
|
+
parser.add_argument(
|
|
144
|
+
"-v",
|
|
145
|
+
action="store_true",
|
|
146
|
+
help="Print full verilator output"
|
|
147
|
+
)
|
|
148
|
+
args = parser.parse_args()
|
|
149
|
+
|
|
150
|
+
_check_verilator()
|
|
151
|
+
|
|
152
|
+
if args.v:
|
|
153
|
+
print(f"Using {_get_verilator_version()}")
|
|
154
|
+
|
|
155
|
+
any_issues = False
|
|
156
|
+
for filepath in args.file:
|
|
157
|
+
if not os.path.isfile(filepath):
|
|
158
|
+
print(f"Error: {filepath}: file not found", file=sys.stderr)
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
output, cmd = _run_lint(filepath, args.include_dirs)
|
|
162
|
+
issues = parse_lint_output(output, filepath)
|
|
163
|
+
|
|
164
|
+
if args.v and output.strip():
|
|
165
|
+
print(output.strip())
|
|
166
|
+
|
|
167
|
+
if not issues:
|
|
168
|
+
print(f"{filepath}: clean")
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
any_issues = True
|
|
172
|
+
if args.dry_run:
|
|
173
|
+
print(f"\n{filepath}: {len(issues)} issue(s) would be tagged:")
|
|
174
|
+
for ln, msg in sorted(issues.items()):
|
|
175
|
+
print(f" Line {ln}: {msg}")
|
|
176
|
+
else:
|
|
177
|
+
tag_file(filepath, issues, cmd)
|
|
178
|
+
print(f"{filepath}: tagged {len(issues)} issue(s)")
|
|
179
|
+
|
|
180
|
+
sys.exit(1 if any_issues else 0)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__":
|
|
184
|
+
main()
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rtl-aid
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CI-native documentation layer for RTL projects
|
|
5
|
+
Author: vishwaksen-1
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/vishwaksen-1/rtl-aid
|
|
8
|
+
Project-URL: Issues, https://github.com/vishwaksen-1/rtl-aid/issues
|
|
9
|
+
Keywords: verilog,systemverilog,rtl,fpga,asic,eda,documentation,linting,ci,hardware,verilator,dependency-graph,code-analysis,agentic
|
|
10
|
+
Requires-Python: >=3.7
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# rtl-aid
|
|
16
|
+
|
|
17
|
+
CI-native documentation layer for RTL projects.
|
|
18
|
+
|
|
19
|
+
Parses Verilog/SystemVerilog source files, extracts module structure, and generates Markdown documentation that stays in sync with your RTL — automatically.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Tools
|
|
24
|
+
|
|
25
|
+
| Command | Purpose |
|
|
26
|
+
|---------|---------|
|
|
27
|
+
| `rtldoc` | Generate and maintain module documentation |
|
|
28
|
+
| `rtllint` | Run verilator lint and tag warnings inline in source |
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install rtl-aid
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Or from source:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
git clone https://github.com/vishwaksen-1/rtl-aid
|
|
42
|
+
cd rtl-aid
|
|
43
|
+
pip install -e .
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Requirements**
|
|
47
|
+
|
|
48
|
+
- Python 3.7+
|
|
49
|
+
- `rtllint` requires [verilator](https://verilator.org) — a system binary, not installable via pip:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# Debian / Ubuntu
|
|
53
|
+
sudo apt install verilator
|
|
54
|
+
|
|
55
|
+
# macOS
|
|
56
|
+
brew install verilator
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`rtldoc` has no external dependencies. If verilator is missing, `rtllint` will exit with a clear error and install instructions.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## rtldoc
|
|
64
|
+
|
|
65
|
+
### Quick start
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
# Document all modules in a directory
|
|
69
|
+
rtldoc -d rtl/
|
|
70
|
+
|
|
71
|
+
# Document specific files
|
|
72
|
+
rtldoc -f rtl/core/alu.v rtl/core/decoder.v
|
|
73
|
+
|
|
74
|
+
# Custom output directory
|
|
75
|
+
rtldoc -d rtl/ -o docs/modules/
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### What gets generated
|
|
79
|
+
|
|
80
|
+
For each module, a Markdown file is created or updated:
|
|
81
|
+
|
|
82
|
+
```markdown
|
|
83
|
+
# alu
|
|
84
|
+
|
|
85
|
+
## Description
|
|
86
|
+
TODO: Add description
|
|
87
|
+
|
|
88
|
+
## Parameters
|
|
89
|
+
- DATA_WIDTH = 8
|
|
90
|
+
- OP_WIDTH = 4
|
|
91
|
+
|
|
92
|
+
## Inputs
|
|
93
|
+
- clk
|
|
94
|
+
- rst
|
|
95
|
+
- op
|
|
96
|
+
- operand_a
|
|
97
|
+
- operand_b
|
|
98
|
+
|
|
99
|
+
## Outputs
|
|
100
|
+
- result
|
|
101
|
+
- overflow
|
|
102
|
+
|
|
103
|
+
## Calls
|
|
104
|
+
- [mux4](mux4.md)
|
|
105
|
+
|
|
106
|
+
## Called By
|
|
107
|
+
- [cpu_core](cpu_core.md)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The `Description` section is **never overwritten** — you write it once, rtldoc preserves it on every run. All other sections are auto-managed.
|
|
111
|
+
|
|
112
|
+
### CLI reference
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
rtldoc (-d DIR [DIR...] | -f FILE [FILE...]) [options]
|
|
116
|
+
|
|
117
|
+
Input:
|
|
118
|
+
-d, --dir DIR [DIR...] Recursively scan directories for .v / .sv files
|
|
119
|
+
-f, --file FILE [FILE...] Parse specific files (no directory traversal)
|
|
120
|
+
|
|
121
|
+
Output:
|
|
122
|
+
-o, --out OUT_DIR Output directory (default: temp/docs/modules)
|
|
123
|
+
--json-graph Write dependency graph to graph.json
|
|
124
|
+
|
|
125
|
+
Filtering:
|
|
126
|
+
--exclude PATTERN [...] Exclude paths containing any of these strings
|
|
127
|
+
|
|
128
|
+
Run modes:
|
|
129
|
+
--dry-run Show what would be written without touching files
|
|
130
|
+
--ci Fail (exit 1) on missing descriptions, no-IO modules,
|
|
131
|
+
or self-instantiations
|
|
132
|
+
--print-errors Print CI issues to stdout (in addition to the error log)
|
|
133
|
+
|
|
134
|
+
Verbosity:
|
|
135
|
+
-v Print modified file paths
|
|
136
|
+
-vv Print modified files + section-level diffs (+adds/-removals)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### CI integration
|
|
140
|
+
|
|
141
|
+
```yaml
|
|
142
|
+
# .github/workflows/docs.yml
|
|
143
|
+
- name: Check RTL docs
|
|
144
|
+
run: rtldoc -d rtl/ -o docs/modules/ --ci --print-errors
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Exit codes: `0` = clean, `1` = CI check failed.
|
|
148
|
+
|
|
149
|
+
Testbench files (`_tb.v`, `_tb.sv`, `_bench.v`, `_testbench.v`, and `.sv` variants) are automatically excluded from scanning.
|
|
150
|
+
|
|
151
|
+
### Dry run
|
|
152
|
+
|
|
153
|
+
Preview what would change before committing:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
rtldoc -d rtl/ --dry-run
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
[DRY RUN] Would write:
|
|
161
|
+
docs/modules/alu.md
|
|
162
|
+
docs/modules/decoder.md
|
|
163
|
+
|
|
164
|
+
5 module(s) processed — 2 doc(s) would be written, 3 unchanged
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Dependency graph
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
rtldoc -d rtl/ --json-graph -o docs/modules/
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Writes `docs/modules/graph.json`:
|
|
174
|
+
|
|
175
|
+
```json
|
|
176
|
+
{
|
|
177
|
+
"cpu_core": {
|
|
178
|
+
"calls": ["alu", "decoder", "register_file"],
|
|
179
|
+
"called_by": []
|
|
180
|
+
},
|
|
181
|
+
"alu": {
|
|
182
|
+
"calls": ["mux4"],
|
|
183
|
+
"called_by": ["cpu_core"]
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## rtllint
|
|
191
|
+
|
|
192
|
+
Runs `verilator --lint-only -Wall` on a file and tags each warned line with an inline comment. Useful for tracking lint debt without blocking a build.
|
|
193
|
+
|
|
194
|
+
### Usage
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
rtllint rtl/core/alu.v
|
|
198
|
+
|
|
199
|
+
# With include directories
|
|
200
|
+
rtllint -I rtl/includes -I rtl/common rtl/core/alu.v
|
|
201
|
+
|
|
202
|
+
# Multiple files
|
|
203
|
+
rtllint rtl/core/*.v
|
|
204
|
+
|
|
205
|
+
# Preview without modifying files
|
|
206
|
+
rtllint --dry-run rtl/core/alu.v
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### What gets written
|
|
210
|
+
|
|
211
|
+
Given a verilator warning on line 75:
|
|
212
|
+
|
|
213
|
+
```
|
|
214
|
+
%Warning-WIDTHEXPAND: rtl/alu.v:75:12: Operator ADD generates 9 bits ...
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
rtllint modifies `alu.v` in place:
|
|
218
|
+
|
|
219
|
+
```verilog
|
|
220
|
+
// lint-test: verilator --lint-only -Wall rtl/alu.v
|
|
221
|
+
// tb-test: tba
|
|
222
|
+
...
|
|
223
|
+
assign result = a + b; /* Check: Operator ADD generates 9 bits ... */
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Re-running rtllint replaces existing `/* Check: */` tags — it does not stack duplicates.
|
|
227
|
+
|
|
228
|
+
### CLI reference
|
|
229
|
+
|
|
230
|
+
```
|
|
231
|
+
rtllint FILE [FILE...] [options]
|
|
232
|
+
|
|
233
|
+
-I, --include DIR Add include directory (repeatable)
|
|
234
|
+
--dry-run Show issues without modifying files
|
|
235
|
+
-v Print full verilator output
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Exit codes: `0` = no issues found, `1` = one or more warnings/errors.
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## Design principles
|
|
243
|
+
|
|
244
|
+
- **No heavy dependencies** — stdlib only, no parser frameworks
|
|
245
|
+
- **CI-first** — every flag is designed for scripted use
|
|
246
|
+
- **Safe for teams** — manual descriptions are never overwritten
|
|
247
|
+
- **Diff-aware** — files are only touched when content actually changes
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## Supported syntax
|
|
252
|
+
|
|
253
|
+
| Feature | Status |
|
|
254
|
+
|---------|--------|
|
|
255
|
+
| Verilog-2001 (`.v`) | Supported |
|
|
256
|
+
| SystemVerilog (`.sv`) | Supported (port/param parsing) |
|
|
257
|
+
| ANSI port declarations | Supported |
|
|
258
|
+
| Comma-inherited port direction (`output reg a, b`) | Supported |
|
|
259
|
+
| Parameterised modules (`#(parameter ...)`) | Supported |
|
|
260
|
+
| Module instantiations with `#()` param override | Supported |
|
|
261
|
+
| Testbench auto-exclusion | Supported |
|
|
262
|
+
| Pre-2001 Verilog style | Not supported |
|
|
263
|
+
| Multi-module files | Not supported (first module only) |
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
rtl_aid/__init__.py,sha256=J3qdjHJISA6Cqd6sKZ2brPidpfxjy2a12O4Z4Wwu0TE,91
|
|
2
|
+
rtl_aid/cli.py,sha256=auXA5TCtGrpgxIf3qWL0ez9FA3BX3htst-YSuE6BY40,2480
|
|
3
|
+
rtl_aid/core.py,sha256=joZ_isBh4jFogCI-GRQ8NmICwqajKScc3QpDX4KajU8,12369
|
|
4
|
+
rtl_aid/lint.py,sha256=zlkb4GVUoDyz_mHqX62b5lMiIZMHAb4ZTnQC9WKAb9I,5707
|
|
5
|
+
rtl_aid-0.1.0.dist-info/licenses/LICENSE,sha256=ZRKXRB0OwPXwHVrgs_FKR0ccE7iVilonlm8hFTEq2II,1082
|
|
6
|
+
rtl_aid-0.1.0.dist-info/METADATA,sha256=DbTgxtm7trwoRNtmsJoVYFt8PkSq4q3IAz4fcYeUkPg,5922
|
|
7
|
+
rtl_aid-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
rtl_aid-0.1.0.dist-info/entry_points.txt,sha256=Ix76H3ujuKDefSIxfkE40k19yTwzRu1W2K0Z8aK0Ef0,72
|
|
9
|
+
rtl_aid-0.1.0.dist-info/top_level.txt,sha256=Mm_ImtgV6c0zD1ZqbVx4-P2uwxKTAqpCHiTO0qbQWM8,8
|
|
10
|
+
rtl_aid-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vishwaksen Reddy Dhareddy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rtl_aid
|