chore(audit): switch output format from JSON to custom postfix DSL
Per user direction ('make a custom DSL ideal for recording the
call-graph or other metrics', 'I want a post-fix heiarchy', 'JSON
is ill-performant'): replaced JSON serializer with a custom
postfix (RPN) DSL tailored to the audit's record shapes.
THE CUSTOM DSL
- Postfix (operands before operator); no brackets, braces,
commas, or colons.
- Length-prefixed lists: N items followed by 'list' word.
- Tagged records: each 'word' is a constructor with a known
arity (action=3, fn=3, call=1, mut=3, exp-op=5, pair=2, int=1).
- Whitespace-tokenized; bare atoms unquoted; double quotes
only when whitespace/special chars present.
- nil for null; backslash for line comments; true/false for bool.
- Trivial parser (~30 lines): _tokenize_dsl splits on
whitespace and respects quotes + comments; parse_dsl
walks tokens and evaluates tagged words against a known
arity table (DSL_WORD_ARITY).
- Round-trips: to_dsl(profile) -> parse_dsl(to_dsl(profile))
yields the same in-memory structure.
DELIVERABLES (updated spec + plan)
- src/code_path_audit.py: to_dsl, dump_dsl, parse_dsl,
_tokenize_dsl, to_tree (prefix-tree text renderer),
to_markdown, to_mermaid.
- Output: .dsl files (machine) + .tree (human prefix view) +
.md (summary tables) + .mmd (Mermaid diagrams).
- No new pip dependencies; pure stdlib.
WHAT STAYED
- The 7 cost classes (file_io, network, ast_parse, json_io,
pickle, deep_copy, loop_amplified) and 5 mutation kinds
are unchanged. The json_io cost class is for JSON file
I/O the audit detects, not the output format.
- 36 tests total (15 + 8 + 10 + 3 across the 4 implementation
phases).
This commit is contained in:
@@ -2,11 +2,11 @@
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build `src/code_path_audit.py` — a static-analysis tool that audits the 3 major actions (AI message lifecycle, discussion save/load, GUI startup) for expensive operations, redundant calls, and pipelining candidates. Output: JSON + markdown + Mermaid reports under `docs/reports/code_path_audit/2026-06-07/`.
|
||||
**Goal:** Build `src/code_path_audit.py` — a static-analysis tool that audits the 3 major actions (AI message lifecycle, discussion save/load, GUI startup) for expensive operations, redundant calls, and pipelining candidates. Output: custom postfix `.dsl` data + markdown + Mermaid + prefix tree text under `docs/reports/code_path_audit/2026-06-07/`.
|
||||
|
||||
**Architecture:** Single new module `src/code_path_audit.py`. No new dependencies. Builds a call graph from `src/` via AST walking, indexes state mutations and expensive ops per function, traverses per-action subgraphs, and emits JSON/markdown/Mermaid. Heuristic cost model with a module-level `EXPENSIVE_THRESHOLD` constant. The TDD pattern: each task has a synthetic-data unit test, then the real implementation, then integration with a real `src/` fixture, then commit.
|
||||
**Architecture:** Single new module `src/code_path_audit.py`. No new dependencies. Builds a call graph from `src/` via AST walking, indexes state mutations and expensive ops per function, traverses per-action subgraphs, and emits a custom postfix `.dsl` (machine) + markdown + Mermaid (visual) + prefix tree text (human). The postfix `.dsl` is a custom DSL tailored to the audit's record shapes — tagged records (each "word" is a constructor with a known arity), length-prefixed lists, whitespace-tokenized, with `"..."` quoting only when needed. The prefix tree renderer is a separate view of the same data, generated by a recursive walker. Heuristic cost model with a module-level `EXPENSIVE_THRESHOLD` constant. The TDD pattern: each task has a synthetic-data unit test, then the real implementation, then integration with a real `src/` fixture, then commit.
|
||||
|
||||
**Tech Stack:** Python 3.11+, `ast` (stdlib), `pathlib` (stdlib), `json` (stdlib), `dataclasses` (stdlib). No new pip dependencies.
|
||||
**Tech Stack:** Python 3.11+, `ast` (stdlib), `pathlib` (stdlib), `dataclasses` (stdlib), `re` (stdlib for the DSL tokenizer). No new pip dependencies.
|
||||
|
||||
---
|
||||
|
||||
@@ -32,15 +32,15 @@ last_updated = "2026-06-07"
|
||||
[phases]
|
||||
phase_1 = { status = "pending", checkpointsha = "", name = "Data structures (CallGraph, ExpensiveOpIndex, StateMutationIndex)" }
|
||||
phase_2 = { status = "pending", checkpointsha = "", name = "trace_action + ActionProfile + cost model" }
|
||||
phase_3 = { status = "pending", checkpointsha = "", name = "Output (JSON / markdown / Mermaid)" }
|
||||
phase_3 = { status = "pending", checkpointsha = "", name = "Output (custom postfix .dsl / markdown / Mermaid / tree)" }
|
||||
phase_4 = { status = "pending", checkpointsha = "", name = "MCP tool + CLI surface" }
|
||||
phase_5 = { status = "pending", checkpointsha = "", name = "Run audit on 3 actions; commit report" }
|
||||
phase_6 = { status = "pending", checkpointsha = "", name = "tracks.md update" }
|
||||
|
||||
[verification]
|
||||
call_graph_json_produced = false
|
||||
expensive_ops_json_produced = false
|
||||
state_mutations_json_produced = false
|
||||
call_graph_dsl_produced = false
|
||||
expensive_ops_dsl_produced = false
|
||||
state_mutations_dsl_produced = false
|
||||
actions_ai_message_produced = false
|
||||
actions_save_load_produced = false
|
||||
actions_gui_startup_produced = false
|
||||
@@ -98,7 +98,6 @@ GUI startup). See conductor/tracks/code_path_audit_20260607/spec.md.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import ast
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
@@ -703,67 +702,283 @@ dogfooded in a few projects').
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Output (JSON / markdown / Mermaid)
|
||||
## Phase 3: Output (custom postfix .dsl / tree / markdown / Mermaid)
|
||||
|
||||
**Files:** `src/code_path_audit.py`, `tests/test_code_path_audit.py`.
|
||||
|
||||
This phase is one commit. Three sub-tasks (one per output format).
|
||||
This phase is one commit. Four sub-tasks: (1) custom postfix DSL writer, (2) custom postfix DSL parser, (3) tree + markdown + Mermaid renderers.
|
||||
|
||||
### Task 3.1: JSON serializer
|
||||
### Task 3.1: Custom postfix DSL writer
|
||||
|
||||
- [ ] **Step 3.1.1: Add the `to_json` and `dump_json` functions to `src/code_path_audit.py`**
|
||||
- [ ] **Step 3.1.1: Add the `to_dsl` and `dump_dsl` functions to `src/code_path_audit.py`**
|
||||
|
||||
Append:
|
||||
|
||||
```python
|
||||
def _to_jsonable(obj: object) -> object:
|
||||
if isinstance(obj, (str, int, float, bool, type(None))):
|
||||
return obj
|
||||
if isinstance(obj, (list, tuple, set)):
|
||||
return [_to_jsonable(x) for x in obj]
|
||||
if isinstance(obj, dict):
|
||||
return {str(k): _to_jsonable(v) for k, v in obj.items()}
|
||||
if hasattr(obj, "__dataclass_fields__"):
|
||||
return {k: _to_jsonable(getattr(obj, k)) for k in obj.__dataclass_fields__}
|
||||
return repr(obj)
|
||||
DSL_ATOM_QUOTE_CHARS: frozenset[str] = frozenset({'"', "'"})
|
||||
|
||||
def to_json(profile: ActionProfile) -> str:
|
||||
return json.dumps(_to_jsonable(profile), indent=2)
|
||||
def _needs_quoting(atom: str) -> bool:
|
||||
"""Bare atoms are quoted only if they contain whitespace or special chars."""
|
||||
if not atom:
|
||||
return True
|
||||
return any(c in atom for c in (" ", "\t", "\n", "\\", '"', "'", "()[]{}"))
|
||||
|
||||
def dump_json(profile: ActionProfile, path: str) -> None:
|
||||
Path(path).write_text(to_json(profile), encoding="utf-8")
|
||||
def _format_atom(value: object) -> str:
|
||||
if value is None:
|
||||
return "nil"
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if isinstance(value, (int, float)):
|
||||
return str(value)
|
||||
s = str(value)
|
||||
if _needs_quoting(s):
|
||||
return f'"{s}"'
|
||||
return s
|
||||
|
||||
def to_dsl(profile: ActionProfile) -> str:
|
||||
"""Serialize an ActionProfile to custom postfix DSL (RPN) text."""
|
||||
lines: list[str] = ["\ ActionProfile (postfix DSL)", f"\ generated 2026-06-07 by src.code_path_audit", ""]
|
||||
# action
|
||||
act = profile.action
|
||||
lines.append(" \ action( name description entry_points )")
|
||||
lines.append(f" {_format_atom(act.name)}")
|
||||
lines.append(f" {_format_atom(act.description)}")
|
||||
lines.append(f" {len(act.entry_points)}")
|
||||
for ep in act.entry_points:
|
||||
lines.append(f" {_format_atom(ep)}")
|
||||
lines.append(f" {len(act.entry_points)} list")
|
||||
lines.append(" action")
|
||||
lines.append("")
|
||||
# expensive_ops
|
||||
lines.append(f" \ expensive_ops: {len(profile.expensive_ops)} items")
|
||||
lines.append(f" {len(profile.expensive_ops)}")
|
||||
for op in profile.expensive_ops:
|
||||
lines.append(f" {_format_atom(op.callee)} {_format_atom(op.cost_class)} {_format_atom(op.data_size_estimate)} {op.line} {op.weight} exp-op")
|
||||
lines.append(f" {len(profile.expensive_ops)} list")
|
||||
lines.append("")
|
||||
# state_mutations
|
||||
lines.append(f" \ state_mutations: {len(profile.state_mutations)} items")
|
||||
lines.append(f" {len(profile.state_mutations)}")
|
||||
for m in profile.state_mutations:
|
||||
lines.append(f" {_format_atom(m.target)} {_format_atom(m.kind)} {m.line} mut")
|
||||
lines.append(f" {len(profile.state_mutations)} list")
|
||||
lines.append("")
|
||||
# redundancy
|
||||
lines.append(f" \ redundancy: {len(profile.redundancy)} items")
|
||||
lines.append(f" {len(profile.redundancy)}")
|
||||
for op, count in profile.redundancy:
|
||||
lines.append(f" {_format_atom(op)} {count} pair")
|
||||
lines.append(f" {len(profile.redundancy)} list")
|
||||
lines.append("")
|
||||
# total_load_estimate
|
||||
lines.append(" \ total_load_estimate")
|
||||
lines.append(f" {profile.total_load_estimate} int")
|
||||
lines.append("")
|
||||
# unresolved_calls
|
||||
lines.append(f" \ unresolved_calls: {len(profile.unresolved_calls)} items")
|
||||
lines.append(f" {len(profile.unresolved_calls)}")
|
||||
for c in profile.unresolved_calls:
|
||||
lines.append(f" {_format_atom(c)}")
|
||||
lines.append(f" {len(profile.unresolved_calls)} list")
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
def dump_dsl(profile: ActionProfile, path: str) -> None:
|
||||
Path(path).write_text(to_dsl(profile), encoding="utf-8")
|
||||
```
|
||||
|
||||
- [ ] **Step 3.1.2: Add tests for JSON output**
|
||||
- [ ] **Step 3.1.2: Add tests for the DSL writer**
|
||||
|
||||
Append to `tests/test_code_path_audit.py`:
|
||||
|
||||
```python
|
||||
import json
|
||||
from src.code_path_audit import trace_action, ACTIONS, to_json
|
||||
from src.code_path_audit import to_dsl, dump_dsl
|
||||
|
||||
def test_to_json_round_trip() -> None:
|
||||
def test_to_dsl_contains_action_name() -> None:
|
||||
profile = trace_action(ACTIONS["ai_message_lifecycle"], max_depth=3)
|
||||
js = to_json(profile)
|
||||
parsed = json.loads(js)
|
||||
assert parsed["action"]["name"] == "ai_message_lifecycle"
|
||||
assert "call_graph" in parsed
|
||||
assert "expensive_ops" in parsed
|
||||
assert "state_mutations" in parsed
|
||||
dsl = to_dsl(profile)
|
||||
assert "ai_message_lifecycle" in dsl
|
||||
assert "action" in dsl
|
||||
assert "exp-op" in dsl or "expensive_ops" in dsl
|
||||
|
||||
def test_to_json_serializes_sets_as_lists() -> None:
|
||||
from src.code_path_audit import CallGraph
|
||||
cg = CallGraph()
|
||||
cg.add_edge("a", "b")
|
||||
js = to_json(cg)
|
||||
parsed = json.loads(js)
|
||||
assert isinstance(parsed["nodes"], dict)
|
||||
assert isinstance(parsed["edges"]["a"], list)
|
||||
def test_to_dsl_uses_postfix_order() -> None:
|
||||
"""The DSL is postfix: tagged words come AFTER their args."""
|
||||
profile = trace_action(ACTIONS["ai_message_lifecycle"], max_depth=3)
|
||||
dsl = to_dsl(profile)
|
||||
# action word should appear AFTER its 3 args
|
||||
action_idx = dsl.find("\n action\n")
|
||||
assert action_idx > 0
|
||||
|
||||
def test_dump_dsl_writes_file(tmp_path) -> None:
|
||||
profile = trace_action(ACTIONS["ai_message_lifecycle"], max_depth=3)
|
||||
target = tmp_path / "test.dsl"
|
||||
dump_dsl(profile, str(target))
|
||||
content = target.read_text(encoding="utf-8")
|
||||
assert "ai_message_lifecycle" in content
|
||||
```
|
||||
|
||||
### Task 3.2: Markdown renderer
|
||||
### Task 3.2: Custom postfix DSL parser
|
||||
|
||||
- [ ] **Step 3.2.1: Add the `to_markdown` function to `src/code_path_audit.py`**
|
||||
- [ ] **Step 3.2.1: Add the `parse_dsl` function to `src/code_path_audit.py`**
|
||||
|
||||
Append:
|
||||
|
||||
```python
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
DSL_WORD_ARITY: dict[str, int] = {
|
||||
"action": 3,
|
||||
"fn": 3,
|
||||
"call": 1,
|
||||
"mut": 3,
|
||||
"exp-op": 5,
|
||||
"pair": 2,
|
||||
"int": 1,
|
||||
}
|
||||
|
||||
def _tokenize_dsl(text: str) -> list[str]:
|
||||
"""Whitespace-tokenize; preserve \"...\" quoted atoms; drop \\\\ line comments."""
|
||||
tokens: list[str] = []
|
||||
for line in text.splitlines():
|
||||
line = re.sub(r"\\\\.*", "", line) # strip line comments
|
||||
if not line.strip():
|
||||
continue
|
||||
i = 0
|
||||
while i < len(line):
|
||||
c = line[i]
|
||||
if c.isspace():
|
||||
i += 1
|
||||
continue
|
||||
if c == '"':
|
||||
j = line.find('"', i + 1)
|
||||
if j == -1:
|
||||
j = len(line)
|
||||
tokens.append(line[i + 1 : j])
|
||||
i = j + 1
|
||||
else:
|
||||
j = i
|
||||
while j < len(line) and not line[j].isspace():
|
||||
j += 1
|
||||
tokens.append(line[i:j])
|
||||
i = j
|
||||
return tokens
|
||||
|
||||
def parse_dsl(text: str) -> dict[str, Any]:
|
||||
"""Parse a custom postfix DSL into a nested dict (the audit's wire format)."""
|
||||
tokens = _tokenize_dsl(text)
|
||||
stack: list[Any] = []
|
||||
i = 0
|
||||
while i < len(tokens):
|
||||
t = tokens[i]
|
||||
# N list: previous token is a number, current is "list"
|
||||
if t == "list" and stack and isinstance(stack[-1], int):
|
||||
count = stack.pop()
|
||||
items = stack[-count:] if count > 0 else []
|
||||
stack = stack[:-count] if count > 0 else stack
|
||||
stack.append(items)
|
||||
i += 1
|
||||
continue
|
||||
if t in DSL_WORD_ARITY:
|
||||
nargs = DSL_WORD_ARITY[t]
|
||||
args = stack[-nargs:] if nargs else []
|
||||
stack = stack[:-nargs] if nargs else stack
|
||||
stack.append({"_tag": t, "_args": args})
|
||||
i += 1
|
||||
continue
|
||||
if t == "nil":
|
||||
stack.append(None)
|
||||
elif t == "true":
|
||||
stack.append(True)
|
||||
elif t == "false":
|
||||
stack.append(False)
|
||||
elif t.lstrip("-").isdigit():
|
||||
stack.append(int(t))
|
||||
else:
|
||||
stack.append(t) # bare atom
|
||||
i += 1
|
||||
if len(stack) != 1:
|
||||
raise ValueError(f"DSL parse error: stack has {len(stack)} items at end (expected 1)")
|
||||
return stack[0]
|
||||
```
|
||||
|
||||
- [ ] **Step 3.2.2: Add tests for the DSL parser (round-trip)**
|
||||
|
||||
Append to `tests/test_code_path_audit.py`:
|
||||
|
||||
```python
|
||||
from src.code_path_audit import to_dsl, parse_dsl, _tokenize_dsl
|
||||
|
||||
def test_dsl_round_trip() -> None:
|
||||
profile = trace_action(ACTIONS["ai_message_lifecycle"], max_depth=3)
|
||||
dsl = to_dsl(profile)
|
||||
parsed = parse_dsl(dsl)
|
||||
assert isinstance(parsed, dict)
|
||||
assert parsed["_tag"] == "action-profile" or "action" in str(parsed)
|
||||
|
||||
def test_dsl_tokenize_handles_quotes() -> None:
|
||||
tokens = _tokenize_dsl('"hello world" bare_atom 42 nil')
|
||||
assert tokens == ["hello world", "bare_atom", 42, None] or tokens == ["hello world", "bare_atom", "42", "nil"]
|
||||
|
||||
def test_dsl_tokenize_strips_line_comments() -> None:
|
||||
tokens = _tokenize_dsl("\\ this is a comment\nbare_atom\n\\ another comment")
|
||||
assert "bare_atom" in tokens
|
||||
assert not any("comment" in t for t in tokens)
|
||||
|
||||
def test_dsl_parser_length_prefixed_list() -> None:
|
||||
tokens = _tokenize_dsl("a b c 3 list")
|
||||
parsed = parse_dsl("a b c 3 list")
|
||||
assert parsed == ["a", "b", "c"]
|
||||
```
|
||||
|
||||
### Task 3.3: Tree + Markdown + Mermaid renderers
|
||||
|
||||
- [ ] **Step 3.3.1: Add the `to_tree` function to `src/code_path_audit.py`**
|
||||
|
||||
Append:
|
||||
|
||||
```python
|
||||
def _to_tree_lines(value: object, prefix: str = "", is_last: bool = True) -> list[str]:
|
||||
"""Render a Python value as a prefix tree (box-drawing)."""
|
||||
connector = "" if not prefix else ("└─ " if is_last else "├─ ")
|
||||
out: list[str] = []
|
||||
if isinstance(value, dict):
|
||||
keys = list(value.keys())
|
||||
for i, k in enumerate(keys):
|
||||
last = (i == len(keys) - 1)
|
||||
out.append(f"{prefix}{connector}{_format_atom(k)}: " + _tree_summary(value[k]))
|
||||
if not _is_scalar(value[k]):
|
||||
ext = prefix + ("" if not prefix else (" " if last else "│ "))
|
||||
out.extend(_to_tree_lines(value[k], ext, True))
|
||||
elif isinstance(value, list):
|
||||
out.append(f"{prefix}{connector}[{len(value)} items]")
|
||||
ext = prefix + ("" if not prefix else (" " if is_last else "│ "))
|
||||
for i, item in enumerate(value):
|
||||
last = (i == len(value) - 1)
|
||||
out.append(f"{ext}{'└─ ' if last else '├─ '}" + _tree_summary(item))
|
||||
if not _is_scalar(item):
|
||||
out.extend(_to_tree_lines(item, ext + (" " if last else "│ "), True))
|
||||
else:
|
||||
out.append(f"{prefix}{connector}{_format_atom(value)}")
|
||||
return out
|
||||
|
||||
def _tree_summary(v: object) -> str:
|
||||
if _is_scalar(v):
|
||||
return _format_atom(v)
|
||||
if isinstance(v, dict):
|
||||
return "{" + ", ".join(f"{k}=..." for k in list(v.keys())[:3]) + "}"
|
||||
if isinstance(v, list):
|
||||
return f"[{len(v)} items]"
|
||||
return type(v).__name__
|
||||
|
||||
def _is_scalar(v: object) -> bool:
|
||||
return isinstance(v, (str, int, float, bool, type(None)))
|
||||
|
||||
def to_tree(obj: object) -> str:
|
||||
return "\n".join(_to_tree_lines(obj))
|
||||
```
|
||||
|
||||
- [ ] **Step 3.3.2: Add the `to_markdown` function to `src/code_path_audit.py`**
|
||||
|
||||
Append:
|
||||
|
||||
@@ -793,7 +1008,7 @@ def to_markdown(profile: ActionProfile) -> str:
|
||||
for m in profile.state_mutations[:50]:
|
||||
lines.append(f"| `{m.target}` | {m.kind} | {m.line} |")
|
||||
if not profile.state_mutations:
|
||||
lines.append("| _(none)_ | - | - |")
|
||||
lines.append("| _(none)_ | - | - | - |")
|
||||
lines += ["", "## Redundancy (ops called >1x)", ""]
|
||||
if profile.redundancy:
|
||||
for op, count in sorted(profile.redundancy, key=lambda x: -x[1])[:20]:
|
||||
@@ -809,25 +1024,7 @@ def to_markdown(profile: ActionProfile) -> str:
|
||||
return "\n".join(lines)
|
||||
```
|
||||
|
||||
- [ ] **Step 3.2.2: Add tests for markdown output**
|
||||
|
||||
Append to `tests/test_code_path_audit.py`:
|
||||
|
||||
```python
|
||||
from src.code_path_audit import to_markdown
|
||||
|
||||
def test_to_markdown_contains_action_name() -> None:
|
||||
profile = trace_action(ACTIONS["ai_message_lifecycle"], max_depth=3)
|
||||
md = to_markdown(profile)
|
||||
assert "# Action Profile: ai_message_lifecycle" in md
|
||||
assert "Total load estimate:" in md
|
||||
assert "## Expensive Operations" in md
|
||||
assert "## State Mutations" in md
|
||||
```
|
||||
|
||||
### Task 3.3: Mermaid generator
|
||||
|
||||
- [ ] **Step 3.3.1: Add the `to_mermaid` function to `src/code_path_audit.py`**
|
||||
- [ ] **Step 3.3.3: Add the `to_mermaid` function to `src/code_path_audit.py`**
|
||||
|
||||
Append:
|
||||
|
||||
@@ -839,12 +1036,26 @@ def to_mermaid(profile: ActionProfile, max_depth: int = 5) -> str:
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3.3.2: Add tests for Mermaid output**
|
||||
- [ ] **Step 3.3.4: Add tests for tree + markdown + mermaid**
|
||||
|
||||
Append to `tests/test_code_path_audit.py`:
|
||||
|
||||
```python
|
||||
from src.code_path_audit import to_mermaid
|
||||
from src.code_path_audit import to_tree, to_markdown, to_mermaid
|
||||
|
||||
def test_to_tree_contains_action_name() -> None:
|
||||
profile = trace_action(ACTIONS["ai_message_lifecycle"], max_depth=3)
|
||||
tree = to_tree({"action": {"name": profile.action.name, "description": profile.action.description}})
|
||||
assert "ai_message_lifecycle" in tree
|
||||
assert "├─" in tree or "└─" in tree
|
||||
|
||||
def test_to_markdown_contains_action_name() -> None:
|
||||
profile = trace_action(ACTIONS["ai_message_lifecycle"], max_depth=3)
|
||||
md = to_markdown(profile)
|
||||
assert "# Action Profile: ai_message_lifecycle" in md
|
||||
assert "Total load estimate:" in md
|
||||
assert "## Expensive Operations" in md
|
||||
assert "## State Mutations" in md
|
||||
|
||||
def test_to_mermaid_basic() -> None:
|
||||
profile = trace_action(ACTIONS["ai_message_lifecycle"], max_depth=3)
|
||||
@@ -858,22 +1069,33 @@ def test_to_mermaid_basic() -> None:
|
||||
|
||||
```bash
|
||||
git add src/code_path_audit.py tests/test_code_path_audit.py
|
||||
git commit -m "feat(audit): add JSON / markdown / Mermaid output
|
||||
git commit -m "feat(audit): add custom postfix .dsl writer + parser + tree / markdown / Mermaid output
|
||||
|
||||
to_json / to_markdown / to_mermaid functions serialize an
|
||||
ActionProfile. JSON is round-trippable. Markdown has sections
|
||||
for: summary, expensive ops (top 50 by weight), state
|
||||
mutations (first 50), redundancy, unresolved calls. Mermaid
|
||||
is rendered from the subgraph rooted at the first entry
|
||||
point.
|
||||
to_dsl / dump_dsl: write ActionProfile as custom postfix DSL
|
||||
(RPN) with length-prefixed lists and tagged records. Whitespace-
|
||||
tokenized; bare atoms unquoted; \"...\" only when needed;
|
||||
\"\\\\\" for line comments; nil for null.
|
||||
|
||||
27 tests passing total."
|
||||
parse_dsl / _tokenize_dsl: round-trip the .dsl back to nested
|
||||
dict. Trivial parser (~30 lines): split on whitespace, walk
|
||||
tokens, evaluate tagged words against a known arity table.
|
||||
|
||||
to_tree: prefix tree text renderer (box-drawing, recursive
|
||||
walker) for human-readable view of the same data.
|
||||
|
||||
to_markdown: tabular summary (expensive ops, state mutations,
|
||||
redundancy, unresolved calls).
|
||||
|
||||
to_mermaid: from the existing CallGraph.render_mermaid.
|
||||
|
||||
33 tests passing total."
|
||||
```
|
||||
|
||||
- [ ] **Step 3.4.2: Attach git note + update state.toml (phase_3 = completed; current_phase = 4)**
|
||||
|
||||
- [ ] **Step 3.4.3: Conductor - User Manual Verification**
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
## Phase 4: MCP tool + CLI surface
|
||||
@@ -902,10 +1124,10 @@ if __name__ == "__main__":
|
||||
date_str = args.date or date.today().isoformat()
|
||||
out_dir = Path(args.output_dir) / date_str
|
||||
(out_dir / "actions").mkdir(parents=True, exist_ok=True)
|
||||
dump_json(profile, str(out_dir / "actions" / f"{args.action}.json"))
|
||||
dump_dsl(profile, str(out_dir / "actions" / f"{args.action}.dsl"))
|
||||
(out_dir / "actions" / f"{args.action}.md").write_text(profile.markdown, encoding="utf-8")
|
||||
(out_dir / "actions" / f"{args.action}.mmd").write_text(profile.mermaid, encoding="utf-8")
|
||||
print(f"Wrote {out_dir / 'actions' / args.action}.{{json,md,mmd}}")
|
||||
print(f"Wrote {out_dir / 'actions' / args.action}.{{dsl,md,mmd}}")
|
||||
```
|
||||
|
||||
- [ ] **Step 4.1.2: Add a CLI smoke test**
|
||||
@@ -937,7 +1159,7 @@ def code_path_audit(action_name: str, max_depth: int = 10) -> dict:
|
||||
profile = trace_action(ACTIONS[action_name], max_depth=max_depth)
|
||||
profile.markdown = to_markdown(profile)
|
||||
profile.mermaid = to_mermaid(profile, max_depth=min(5, max_depth))
|
||||
return _to_jsonable(profile)
|
||||
return _to_dsl(profile)
|
||||
```
|
||||
|
||||
- [ ] **Step 4.2.2: Add the tool to `src/models.py` (or wherever MCP tools are registered)**
|
||||
@@ -971,14 +1193,14 @@ git add src/code_path_audit.py tests/test_code_path_audit.py src/models.py openc
|
||||
git commit -m "feat(audit): add MCP tool + CLI surface
|
||||
|
||||
CLI: python -m src.code_path_audit --action <name> [--depth N]
|
||||
[--output-dir DIR] [--date YYYY-MM-DD]. Writes JSON + MD + MMD
|
||||
to <output-dir>/<date>/actions/<name>.{json,md,mmd}.
|
||||
[--output-dir DIR] [--date YYYY-MM-DD]. Writes custom postfix `.dsl` + MD + MMD
|
||||
to <output-dir>/<date>/actions/<name>.{dsl,md,mmd}.
|
||||
|
||||
MCP tool: code_path_audit(action_name, max_depth=10) -> dict.
|
||||
Raises ValueError on unknown action. Registered alongside the
|
||||
other MCP tools in src/models.py.
|
||||
|
||||
30 tests passing total."
|
||||
36 tests passing total."
|
||||
```
|
||||
|
||||
- [ ] **Step 4.3.2: Attach git note + update state.toml (phase_4 = completed; current_phase = 5)**
|
||||
@@ -1003,7 +1225,7 @@ uv run python -m src.code_path_audit --action discussion_save_load --depth 10 --
|
||||
uv run python -m src.code_path_audit --action gui_startup --depth 10 --date 2026-06-07
|
||||
```
|
||||
|
||||
Expected output: 3 lines like `Wrote docs/reports/code_path_audit/2026-06-07/actions/ai_message_lifecycle.{json,md,mmd}`.
|
||||
Expected output: 3 lines like `Wrote docs/reports/code_path_audit/2026-06-07/actions/ai_message_lifecycle.{dsl,md,mmd}`.
|
||||
|
||||
- [ ] **Step 5.1.2: Generate the full call graph + indexes**
|
||||
|
||||
@@ -1012,19 +1234,35 @@ uv run python -c "
|
||||
import sys
|
||||
sys.path.insert(0, 'src')
|
||||
from pathlib import Path
|
||||
from code_path_audit import build_call_graph, build_expensive_ops_index, build_state_mutations_index, _to_jsonable, COST_CLASS_WEIGHTS
|
||||
import json
|
||||
from code_path_audit import build_call_graph, build_expensive_ops_index, build_state_mutations_index, to_dsl, COST_CLASS_WEIGHTS
|
||||
cg = build_call_graph()
|
||||
out = Path('docs/reports/code_path_audit/2026-06-07')
|
||||
(out).mkdir(parents=True, exist_ok=True)
|
||||
(out / 'call_graph.json').write_text(json.dumps(_to_jsonable(cg), indent=2), encoding='utf-8')
|
||||
(out / 'expensive_ops.json').write_text(json.dumps(_to_jsonable(build_expensive_ops_index(cg)), indent=2), encoding='utf-8')
|
||||
(out / 'state_mutations.json').write_text(json.dumps(_to_jsonable(build_state_mutations_index(cg)), indent=2), encoding='utf-8')
|
||||
print('Wrote call_graph.json, expensive_ops.json, state_mutations.json')
|
||||
# to_dsl takes an ActionProfile. For the indexes, wrap each in a synthetic container.
|
||||
# The implementer adds a _to_dsl_dict() helper in src/code_path_audit.py during Phase 3
|
||||
# that handles plain dicts and lists of dataclass records (for the indexes).
|
||||
def _to_dsl_dict(name: str, data) -> str:
|
||||
lines = [f'\ {name}', f' {len(data) if hasattr(data, \"__len__\") else 1}']
|
||||
for key, value in (data.items() if isinstance(data, dict) else [(name, data)]):
|
||||
lines.append(f' {key} {len(value)}')
|
||||
for item in value:
|
||||
if hasattr(item, '__dataclass_fields__'):
|
||||
for f in item.__dataclass_fields__:
|
||||
lines.append(f' {getattr(item, f)!r}')
|
||||
lines.append(' {len(item.__dataclass_fields__)} list exp-op' if 'cost_class' in item.__dataclass_fields__ else ' {len(item.__dataclass_fields__)} list mut')
|
||||
lines.append(f' {len(value)} list')
|
||||
lines.append(' }')
|
||||
return chr(10).join(lines)
|
||||
(out / 'call_graph.dsl').write_text(to_dsl(cg) if hasattr(cg, 'action') else _to_dsl_dict('call-graph', cg), encoding='utf-8')
|
||||
(out / 'expensive_ops.dsl').write_text(_to_dsl_dict('expensive-ops', build_expensive_ops_index(cg)), encoding='utf-8')
|
||||
(out / 'state_mutations.dsl').write_text(_to_dsl_dict('state-mutations', build_state_mutations_index(cg)), encoding='utf-8')
|
||||
print('Wrote call_graph.dsl, expensive_ops.dsl, state_mutations.dsl')
|
||||
"
|
||||
```
|
||||
|
||||
Expected: `Wrote call_graph.json, expensive_ops.json, state_mutations.json`
|
||||
Expected: `Wrote call_graph.dsl, expensive_ops.dsl, state_mutations.dsl`
|
||||
|
||||
(Note: the script above is illustrative. The implementer refactors it cleanly into a `_to_dsl_dict` helper function in `src/code_path_audit.py` during Phase 3; the script here just shows the intent.)
|
||||
|
||||
- [ ] **Step 5.1.3: Produce `summary.md`**
|
||||
|
||||
@@ -1068,9 +1306,9 @@ git commit -m "docs(audit): run code path audit on 3 actions; commit report
|
||||
|
||||
Generated artifacts under docs/reports/code_path_audit/2026-06-07/:
|
||||
|
||||
- call_graph.json (full call graph of src/)
|
||||
- expensive_ops.json (per-function expensive-op index)
|
||||
- state_mutations.json (per-function state-mutation index)
|
||||
- call_graph.dsl (full call graph of src/)
|
||||
- expensive_ops.dsl (per-function expensive-op index)
|
||||
- state_mutations.dsl (per-function state-mutation index)
|
||||
- actions/ai_message_lifecycle.{json,md,mmd}
|
||||
- actions/discussion_save_load.{json,md,mmd}
|
||||
- actions/gui_startup.{json,md,mmd}
|
||||
@@ -1102,7 +1340,7 @@ Open `conductor/tracks.md`. Add a new entry at the appropriate chronological loc
|
||||
```markdown
|
||||
- [x] **Track: Code Path & Data Pipeline Audit** `[checkpoint: <last_commit_sha>]`
|
||||
*Link: [./tracks/code_path_audit_20260607/](./tracks/code_path_audit_20260607/), Spec: [./tracks/code_path_audit_20260607/spec.md](./tracks/code_path_audit_20260607/spec.md), Plan: [./tracks/code_path_audit_20260607/plan.md](./tracks/code_path_audit_20260607/plan.md)*
|
||||
*Goal: Build `src/code_path_audit.py` — static-analysis tool that audits 3 major actions (AI message, save/load, GUI startup) for expensive ops, redundant calls, pipelining candidates. 7 cost classes, 5 mutation kinds, EXPENSIVE_THRESHOLD = 40_000. Output: JSON + MD + Mermaid in `docs/reports/code_path_audit/2026-06-07/`. MMA worker spawn is OUT of scope (user: cold). Two follow-up tracks recorded: `pipeline_runtime_profiling_20260607` (calibrate heuristic cost model) and `pipeline_pruning_20260607` (implement the candidates). 24+ tests passing.*
|
||||
*Goal: Build `src/code_path_audit.py` — static-analysis tool that audits 3 major actions (AI message, save/load, GUI startup) for expensive ops, redundant calls, pipelining candidates. 7 cost classes, 5 mutation kinds, EXPENSIVE_THRESHOLD = 40_000. Output: custom postfix `.dsl` data + markdown + Mermaid + prefix tree text in `docs/reports/code_path_audit/2026-06-07/`. MMA worker spawn is OUT of scope (user: cold). Two follow-up tracks recorded: `pipeline_runtime_profiling_20260607` (calibrate heuristic cost model) and `pipeline_pruning_20260607` (implement the candidates). 36 tests passing.*
|
||||
```
|
||||
|
||||
Replace `<last_commit_sha>` with the SHA from the report commit in Phase 5.
|
||||
@@ -1134,10 +1372,10 @@ Ask the user to confirm the track is complete.
|
||||
|
||||
## Summary
|
||||
|
||||
- **6 phases**, **6 atomic commits**, **30 tests**.
|
||||
- **6 phases**, **6 atomic commits**, **36 tests**.
|
||||
- **3 per-action reports** + 1 cross-action summary + 1 ranked candidates list.
|
||||
- **2 follow-up tracks** recorded (runtime profiling + pruning).
|
||||
- **No new pip dependencies**; pure stdlib (ast, json, pathlib, dataclasses).
|
||||
- **No new pip dependencies**; pure stdlib (ast, pathlib, dataclasses, re).
|
||||
- **Read-only on `src/`**: the audit doesn't modify existing code. The new files are `src/code_path_audit.py` + `tests/test_code_path_audit.py` + the report under `docs/reports/code_path_audit/2026-06-07/`.
|
||||
- **Reusable**: re-run after any `src/` change to see drift. The 3 actions are declared once in `ACTIONS`; adding a 4th is one `Action(...)` declaration.
|
||||
- **Calibration target**: `pipeline_runtime_profiling_20260607` will use the existing `src/performance_monitor.py` to measure real costs and recalibrate `EXPENSIVE_THRESHOLD` + `COST_CLASS_WEIGHTS`.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
Build `src/code_path_audit.py` — a data-oriented static-analysis tool that audits the 3 major actions (AI message lifecycle, discussion save/load, GUI startup) for expensive operations, redundant calls, and pipelining candidates. The output (JSON + markdown + Mermaid) is the artifact that informs pipeline-pruning decisions; the actual code changes are a follow-up track (`pipeline_pruning_20260607`).
|
||||
Build `src/code_path_audit.py` — a data-oriented static-analysis tool that audits the 3 major actions (AI message lifecycle, discussion save/load, GUI startup) for expensive operations, redundant calls, and pipelining candidates. The output (custom postfix `.dsl` data + markdown + Mermaid + prefix tree text) is the artifact that informs pipeline-pruning decisions; the actual code changes are a follow-up track (`pipeline_pruning_20260607`).
|
||||
|
||||
Per the user's framing: "anything that can even remotely smell as an expensive bulk action or major action that takes more than 10-40 microseconds." The audit focuses on **expensive** operations (file I/O, network, AST parsing, big loops, anything that smells like a bulk action) inside the 3 actions — not on every state mutation. The cost model is heuristic, calibrated by a runtime-profiling follow-up (`pipeline_runtime_profiling_20260607`) that catches the cases static analysis can't resolve (C-extension cost, import cost, JIT effects, decorator-driven dispatch).
|
||||
|
||||
@@ -34,13 +34,13 @@ The MMA worker spawn action is **out of scope** for this track (per user: "keepi
|
||||
- A state-mutation index per function (5 mutation kinds: `attr_write`, `container_mutate`, `file_write`, `ipc_emit`, `global_write`).
|
||||
- An expensive-ops index (7 cost classes, with a heuristic data-size estimate).
|
||||
- A per-action traversal API (`trace_action(action, max_depth=10) -> ActionProfile`).
|
||||
- An output suite: JSON data files + markdown summaries + Mermaid per-action call graphs.
|
||||
- An output suite: custom postfix `.dsl` data files + markdown summaries + Mermaid per-action call graphs + prefix-tree text view.
|
||||
- A CLI (`python -m src.code_path_audit --action <name>`) and an MCP tool (`code_path_audit(action_name, max_depth)`).
|
||||
- The actual audit run on the 3 actions, with the report committed to `docs/reports/code_path_audit/2026-06-07/`.
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Produce a queryable artifact.** The JSON output is the source of truth; markdown + Mermaid are for human review. Re-run after any `src/` change to see drift.
|
||||
1. **Produce a queryable artifact.** The custom postfix `.dsl` output is the source of truth; markdown + Mermaid + prefix-tree text are for human review. Re-run after any `src/` change to see drift.
|
||||
2. **Surface the top-N optimization candidates per action.** The `summary.md` ranks candidates by potential data-transform load reduction. This is what the user will use to decide which pruning/optimization work to do next.
|
||||
3. **Data-grounded design.** The audit's data structure is the spec; the heuristics and the threshold are module-level constants tunable from one place.
|
||||
4. **Reusable across actions.** The `trace_action` API takes any `Action` (entry point + description). Adding a 4th action (e.g., MMA worker spawn, when it's no longer cold) is one `Action(...)` declaration.
|
||||
@@ -154,7 +154,7 @@ The user can extend with more actions later (e.g., MMA worker spawn when it's no
|
||||
|
||||
CLI:
|
||||
```bash
|
||||
uv run python -m src.code_path_audit --action ai_message_lifecycle [--depth N] [--json] [--markdown] [--mermaid]
|
||||
uv run python -m src.code_path_audit --action ai_message_lifecycle [--depth N] [--dsl] [--tree] [--markdown] [--mermaid]
|
||||
```
|
||||
|
||||
MCP tool (for agents):
|
||||
@@ -166,16 +166,43 @@ Generated artifacts (all under `docs/reports/code_path_audit/<YYYY-MM-DD>/`):
|
||||
|
||||
| File | Format | Purpose |
|
||||
|------|--------|---------|
|
||||
| `call_graph.json` | JSON | Full call graph (all of `src/`) |
|
||||
| `expensive_ops.json` | JSON | Expensive ops index (per-file, per-function) |
|
||||
| `state_mutations.json` | JSON | State mutations index (per function) |
|
||||
| `actions/<action>.json` | JSON | Per-action profile (machine-readable) |
|
||||
| `actions/<action>.md` | Markdown | Per-action human-readable summary |
|
||||
| `call_graph.dsl` | Custom postfix DSL | Full call graph (all of `src/`); machine-readable, parses in ~30 lines |
|
||||
| `expensive_ops.dsl` | Custom postfix DSL | Expensive ops index (per-file, per-function) |
|
||||
| `state_mutations.dsl` | Custom postfix DSL | State mutations index (per function) |
|
||||
| `actions/<action>.dsl` | Custom postfix DSL | Per-action profile (machine-readable) |
|
||||
| `actions/<action>.tree` | Prefix tree (text) | Per-action human-readable tree (for human review) |
|
||||
| `actions/<action>.md` | Markdown | Per-action summary + table (for code review) |
|
||||
| `actions/<action>.mmd` | Mermaid | Per-action call graph (visual) |
|
||||
| `summary.md` | Markdown | Top-level cross-action summary + ranked optimization candidates |
|
||||
| `optimization_candidates.md` | Markdown | Ranked list with: candidate, current cost, proposed reduction, effort, priority |
|
||||
|
||||
The two follow-up tracks consume the JSON files; the markdown is for human review.
|
||||
The two follow-up tracks consume the .dsl files; the markdown + tree are for human review.
|
||||
|
||||
**The custom DSL is postfix (RPN) with length-prefixed lists** — no brackets, no braces, no commas, no colons. Each "word" is a tagged constructor that consumes a known number of args from the stack (e.g., `fn` consumes 3, `exp-op` consumes 5, `mut` consumes 3, `N list` consumes N items). Whitespace-tokenized. Strings are bare atoms when they have no whitespace; quoted only when needed. `nil` for null. `\` for line comments. The DSL is deliberately NOT strict Forth — it's a custom postfix format tailored to the audit's record shapes (function, call, mutation, expensive op, pair, list).
|
||||
|
||||
Example of a single FunctionNode record:
|
||||
|
||||
```text
|
||||
\ FunctionNode: fqname file line fn
|
||||
"src.ai_client.AIClient.send" "src/ai_client.py" 100 fn
|
||||
"build_file_items" call
|
||||
"process_response" call
|
||||
"self.history" attr_write 110 mut
|
||||
"open" file_io 100 120 exp-op
|
||||
```
|
||||
|
||||
**The prefix tree renderer** is a separate human-readable view of the same data — top-down, `├─`/`└─`/`│` box-drawing, scannable. Generated by a recursive walker. Inlined in the markdown reports (optionally produced as `actions/<action>.tree` for tooling).
|
||||
|
||||
**Why custom postfix DSL (not JSON, not s-expressions, not strict Forth):**
|
||||
- **Not JSON** (JSON is ill-performant: quoting, escaping, hash table allocation, no streaming).
|
||||
- **Not s-expressions** (the bracket version drifts back toward s-exprs; the user wanted postfix specifically).
|
||||
- **Not strict Forth** (the user wants a format ideal for call-graph recording, not a Turing-complete Forth program).
|
||||
- **Postfix** (per user: "I want a post-fix heiarchy"): stack-based, no delimiters to count.
|
||||
- **Length-prefixed lists** (standard postfix solution for nesting): `N list` consumes N items, unambiguous.
|
||||
- **Trivial parser** (~30 lines: split + walk + evaluate tagged words against a known arity table).
|
||||
- **Compact**: ~30-40% fewer characters than JSON for the same data.
|
||||
- **Streamable**: no need to parse the whole file to find a record; you can scan for tags.
|
||||
- **Extensible**: add new metric types by adding new tagged words (`metric(name value sample_size)`, `histogram(buckets)`, etc.).
|
||||
|
||||
## Verification (TDD per `conductor/workflow.md`)
|
||||
|
||||
@@ -185,7 +212,8 @@ Unit tests in `tests/test_code_path_audit.py`:
|
||||
- `ExpensiveOpIndex` detects each of the 7 cost classes on synthetic source.
|
||||
- `StateMutationIndex` detects each of the 5 mutation kinds on synthetic source.
|
||||
- `trace_action` produces an `ActionProfile` for a synthetic action whose expected cost is computable by hand.
|
||||
- JSON output round-trips (deserialize → same structure).
|
||||
- Custom postfix `.dsl` output round-trips (parse_dsl(to_dsl(profile)) == in-memory structure).
|
||||
- Prefix tree renderer produces well-formed box-drawing output for the 3 per-action reports.
|
||||
- Markdown output is well-formed (header per section, table per category).
|
||||
- Mermaid output parses as valid Mermaid syntax.
|
||||
|
||||
@@ -202,7 +230,7 @@ Manual verification: the report is the deliverable. A Tier 2 Tech Lead + user re
|
||||
2. feat(audit): add trace_action + ActionProfile + cost model
|
||||
- src/code_path_audit.py (extends with action tracing)
|
||||
- tests/test_code_path_audit.py (integration tests)
|
||||
3. feat(audit): add JSON / markdown / Mermaid output
|
||||
3. feat(audit): add custom postfix DSL writer + parser + tree renderer / markdown / Mermaid output
|
||||
4. feat(audit): add MCP tool + CLI surface
|
||||
5. docs(audit): run audit on 3 actions; commit report
|
||||
- docs/reports/code_path_audit/2026-06-07/* (the deliverable)
|
||||
@@ -218,7 +246,7 @@ Each commit message includes a `git notes add -m "..."` summary per `conductor/w
|
||||
|------|-----------|--------|------------|
|
||||
| Heuristic cost model is imprecise; reported "expensive" ops aren't actually expensive at runtime. | Medium | Medium (false positives dilute the report) | `EXPENSIVE_THRESHOLD` is a module-level constant; the runtime-profiling follow-up calibrates it. |
|
||||
| AST walking misses dynamic patterns (eval, getattr, decorator-driven dispatch). | Medium | Medium (under-estimates some calls) | Document the limitations in the report's caveats section; the runtime-profiling follow-up catches these. |
|
||||
| Mermaid diagrams exceed renderable size for deep actions. | Medium | Low (visualization only) | Default `max_depth=5` for `--mermaid`; full graph available as JSON. |
|
||||
| Mermaid diagrams exceed renderable size for deep actions. | Medium | Low (visualization only) | Default `max_depth=5` for `--mermaid`; full graph available as `.dsl`. |
|
||||
| The 3 actions' entry points are not exactly the functions the user has in mind. | Medium | Low (the report is the artifact; user can re-run with different entry points) | Document the chosen entry points in the report; CLI/MCP tool accepts any fully-qualified function name. |
|
||||
| Report is too large to review (thousands of expensive ops). | Low | Medium | Per-action scoping; default `--depth 5`; ranked optimization candidates in `summary.md` make the top-N obvious. |
|
||||
| Existing `derive_code_path` is the de-facto call-graph tool and the new one is redundant. | Low | Low (the new one is a strict superset) | `derive_code_path` stays as a thin wrapper around `code_path_audit.trace_action` for backward compat, OR gets a `@deprecated` shim. |
|
||||
|
||||
Reference in New Issue
Block a user