Skip to content

Commit 2cbef0a

Browse files
committed
feat(datamodels): map the data shapes a project declares
The symbol index tells you where a class lives; it doesn't tell you what that class actually holds. For someone reading an unfamiliar codebase, the shape of the data ("an Order has a total, a list of items and a status") often says more than any call graph. This adds a deterministic, no-API-key map that reads those shapes straight from the source: dataclasses, pydantic models, TypedDicts and NamedTuples, each with its declared fields, their type annotations, and whether they carry a default. It's wired into ProjectMeta, the codemap.md export and the project router alongside the existing maps. find_data_models stays pure over the file contents, so the 13 unit tests drive it with plain strings — no repo needed.
1 parent 2495a6e commit 2cbef0a

6 files changed

Lines changed: 394 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,7 @@ CodeABC/
208208

209209
### Shipped
210210

211-
The reading experience is already complete end to end: a plain-language project manual, hover annotations, a terminology dictionary, natural-language editing, and snippet Q&A, all in a bilingual UI you launch with one command (or as a native Tauri desktop app). On top of that sit a dozen deterministic, no-API-key maps — reading map, core-module ranking, a project-wide definition index with find-all-references (look up any name to jump to where it's declared and list every place it's used), test coverage, git-history hotspots and ownership, tech-debt markers, env-var surface, entry points, external integrations, silent-failure spots, under-documented files, and logic complexity. It all exports offline too: every map stitches into a single `codemap.md`, or a self-contained HTML report you can email to a non-technical stakeholder — no server, no API key, nothing fetched from the network. Analyses also persist to a small on-disk library (`GET /api/projects`), so you can list past projects and reopen one without remembering its id or re-scanning.
211+
The reading experience is already complete end to end: a plain-language project manual, hover annotations, a terminology dictionary, natural-language editing, and snippet Q&A, all in a bilingual UI you launch with one command (or as a native Tauri desktop app). On top of that sit a dozen deterministic, no-API-key maps — reading map, core-module ranking, a project-wide definition index with find-all-references (look up any name to jump to where it's declared and list every place it's used), test coverage, git-history hotspots and ownership, tech-debt markers, env-var surface, entry points, CLI commands, the data-model shapes a project declares (dataclasses, pydantic models, TypedDicts and NamedTuples, with their fields and types), external integrations, silent-failure spots, under-documented files, and logic complexity. It all exports offline too: every map stitches into a single `codemap.md`, or a self-contained HTML report you can email to a non-technical stakeholder — no server, no API key, nothing fetched from the network. Analyses also persist to a small on-disk library (`GET /api/projects`), so you can list past projects and reopen one without remembering its id or re-scanning.
212212

213213
### Planned
214214

backend/models.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,20 @@ class CliCommand(BaseModel):
218218
line: int
219219

220220

221+
class DataModelField(BaseModel):
222+
name: str
223+
type: str = "" # the annotation as written, e.g. "int" or "list[str]"
224+
has_default: bool = False
225+
226+
227+
class DataModel(BaseModel):
228+
name: str
229+
kind: str # "dataclass" | "pydantic" | "typeddict" | "namedtuple"
230+
fields: list[DataModelField] = []
231+
path: str
232+
line: int
233+
234+
221235
class ComplexFile(BaseModel):
222236
path: str
223237
complexity: int # approximate cyclomatic complexity (decision points + 1)
@@ -348,6 +362,7 @@ class ProjectMeta(BaseModel):
348362
env_vars: list[EnvVar] = []
349363
entry_points: list[EntryPoint] = []
350364
cli_commands: list[CliCommand] = []
365+
data_models: list[DataModel] = []
351366
complexity_files: list[ComplexFile] = []
352367
dependencies: list[Dependency] = []
353368
security: SecuritySummary | None = None

backend/routers/project.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
CodeWalkStep,
2525
ComplexFile,
2626
CouplingHotspot,
27+
DataModel,
28+
DataModelField,
2729
Definition,
2830
DefinitionMatches,
2931
Dependency,
@@ -72,6 +74,7 @@
7274
commands,
7375
complexity,
7476
coverage,
77+
datamodels,
7578
dependencies,
7679
docs,
7780
entrypoints,
@@ -198,6 +201,7 @@ def _content_analyses(
198201
env = envscan.scan_env_vars(file_contents)
199202
entries = entrypoints.find_entry_points(file_contents)
200203
cli_commands = commands.find_cli_commands(file_contents)
204+
data_models = datamodels.find_data_models(file_contents)
201205
complex_files = complexity.scan_complexity(file_contents)
202206
deps = dependencies.scan_dependencies(file_contents)
203207
sec = security.scan_security(file_contents)
@@ -238,6 +242,19 @@ def _content_analyses(
238242
)
239243
for c in cli_commands["commands"]
240244
],
245+
"data_models": [
246+
DataModel(
247+
name=m["name"],
248+
kind=m["kind"],
249+
fields=[
250+
DataModelField(name=f["name"], type=f["type"], has_default=f["has_default"])
251+
for f in m["fields"]
252+
],
253+
path=m["path"],
254+
line=m["line"],
255+
)
256+
for m in data_models["models"]
257+
],
241258
"complexity_files": [
242259
ComplexFile(
243260
path=f["path"],

backend/services/codemap_export.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
commands,
2020
complexity,
2121
coverage,
22+
datamodels,
2223
dependencies,
2324
docs,
2425
entrypoints,
@@ -64,6 +65,7 @@ def _ordered_sections(proj: dict) -> list[str]:
6465
envscan.render_env_markdown(name, envscan.scan_env_vars(contents)),
6566
entrypoints.render_entrypoints_markdown(name, entrypoints.find_entry_points(contents)),
6667
commands.render_commands_markdown(name, commands.find_cli_commands(contents)),
68+
datamodels.render_data_models_markdown(name, datamodels.find_data_models(contents)),
6769
complexity.render_complexity_markdown(name, complexity.scan_complexity(contents)),
6870
dependencies.render_dependencies_markdown(name, dependencies.scan_dependencies(contents)),
6971
security.render_security_markdown(name, security.scan_security(contents)),

backend/services/datamodels.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
"""Data-model map — what shape the data this project moves around actually has.
2+
3+
The symbol index tells you *where* a class named ``User`` is declared. It does
4+
not tell you what a ``User`` *is*: which fields it carries, and of what type.
5+
For someone trying to understand an unfamiliar codebase without reading every
6+
file, that second question is often the important one — "an Order has a total, a
7+
list of items and a status" says more about the program than any call graph.
8+
9+
This map reads those shapes straight from the source CodeABC already loaded — no
10+
LLM, nothing to install or run — by recognising the four ways Python code
11+
declares a data record explicitly:
12+
13+
dataclass ``@dataclass class User: id: int; name: str = ""``
14+
pydantic ``class Order(BaseModel): total: float``
15+
TypedDict ``class Movie(TypedDict): title: str``
16+
NamedTuple ``class Coord(NamedTuple): lat: float``
17+
18+
Each model carries its name, which of the four kinds it is, the fields it
19+
declares (name, type annotation, and whether it has a default), and the file and
20+
line where it is defined.
21+
22+
:func:`find_data_models` is pure over the file contents, so it is unit-testable
23+
with plain strings and needs no repository.
24+
25+
Limitations (kept honest on purpose):
26+
27+
* Only the four class-based declaration styles above. The functional forms
28+
(``TypedDict("Movie", {...})``, ``namedtuple("Coord", [...])``) and ORM
29+
bases (SQLAlchemy ``Base``, Django ``models.Model``) are out of scope — the
30+
latter are too easy to confuse with ordinary classes to flag safely.
31+
* Only statically-declared, annotated fields. Attributes assigned in
32+
``__init__`` or built dynamically are not counted.
33+
* Inherited fields are not flattened in; each model lists only what it
34+
declares itself.
35+
* A file that does not parse as Python is skipped, not guessed at.
36+
"""
37+
38+
from __future__ import annotations
39+
40+
import ast
41+
42+
# Base-class names that mark a class as a typed data record, mapped to the kind
43+
# label we report for them.
44+
_BASE_KINDS = {
45+
"BaseModel": "pydantic",
46+
"TypedDict": "typeddict",
47+
"NamedTuple": "namedtuple",
48+
}
49+
50+
51+
def _base_name(node: ast.expr) -> str | None:
52+
"""Return the right-most attribute name of a base/decorator expression.
53+
54+
``BaseModel`` -> ``BaseModel``; ``pydantic.BaseModel`` -> ``BaseModel``.
55+
"""
56+
if isinstance(node, ast.Name):
57+
return node.id
58+
if isinstance(node, ast.Attribute):
59+
return node.attr
60+
return None
61+
62+
63+
def _is_dataclass(cls: ast.ClassDef) -> bool:
64+
"""True when the class carries a ``@dataclass`` decorator in any form."""
65+
for dec in cls.decorator_list:
66+
target = dec.func if isinstance(dec, ast.Call) else dec
67+
if _base_name(target) == "dataclass":
68+
return True
69+
return False
70+
71+
72+
def _classify(cls: ast.ClassDef) -> str | None:
73+
"""Resolve which data-model kind a class is, or ``None`` if it is not one."""
74+
if _is_dataclass(cls):
75+
return "dataclass"
76+
for base in cls.bases:
77+
name = _base_name(base)
78+
if name and name in _BASE_KINDS:
79+
return _BASE_KINDS[name]
80+
return None
81+
82+
83+
def _annotation_str(node: ast.expr | None) -> str:
84+
if node is None:
85+
return ""
86+
try:
87+
return ast.unparse(node)
88+
except Exception:
89+
return ""
90+
91+
92+
def _fields(cls: ast.ClassDef) -> list[dict]:
93+
"""Collect the annotated fields declared directly in the class body."""
94+
fields: list[dict] = []
95+
for stmt in cls.body:
96+
if not isinstance(stmt, ast.AnnAssign) or not isinstance(stmt.target, ast.Name):
97+
continue
98+
name = stmt.target.id
99+
# Skip private (_x) and dunder (__x__) names: ORM table hints like
100+
# __tablename__ and internal bookkeeping are not part of the data shape.
101+
if name.startswith("_"):
102+
continue
103+
fields.append(
104+
{
105+
"name": name,
106+
"type": _annotation_str(stmt.annotation),
107+
"has_default": stmt.value is not None,
108+
}
109+
)
110+
return fields
111+
112+
113+
def find_data_models(file_contents: dict[str, str], *, limit: int = 60) -> dict:
114+
"""Collect the explicitly-declared data models a project defines.
115+
116+
Args:
117+
file_contents: mapping of path to file text (as CodeABC already read it).
118+
limit: how many models to return in the sorted list.
119+
120+
Returns ``{"total", "kinds", "models"}`` where each model is
121+
``{"name", "kind", "fields", "path", "line"}``, ``kind`` is one of
122+
``dataclass`` / ``pydantic`` / ``typeddict`` / ``namedtuple``, and each field
123+
is ``{"name", "type", "has_default"}``.
124+
"""
125+
seen: set[tuple[str, str, int]] = set()
126+
models: list[dict] = []
127+
128+
for path, content in file_contents.items():
129+
if not content or not path.endswith(".py"):
130+
continue
131+
try:
132+
tree = ast.parse(content)
133+
except (SyntaxError, ValueError):
134+
continue
135+
136+
for node in ast.walk(tree):
137+
if not isinstance(node, ast.ClassDef):
138+
continue
139+
kind = _classify(node)
140+
if kind is None:
141+
continue
142+
key = (path, node.name, node.lineno)
143+
if key in seen:
144+
continue
145+
seen.add(key)
146+
models.append(
147+
{
148+
"name": node.name,
149+
"kind": kind,
150+
"fields": _fields(node),
151+
"path": path,
152+
"line": node.lineno,
153+
}
154+
)
155+
156+
models.sort(key=lambda m: (m["path"], m["line"]))
157+
kinds = sorted({m["kind"] for m in models})
158+
return {"total": len(models), "kinds": kinds, "models": models[:limit]}
159+
160+
161+
def render_data_models_markdown(project_name: str, data: dict | None) -> str:
162+
"""Render the data-model map as Markdown, or ``""`` if none were found."""
163+
models = (data or {}).get("models") or []
164+
if not models:
165+
return ""
166+
167+
lines = [
168+
f"# {project_name} — 数据长什么样(数据模型)",
169+
"",
170+
"> 符号索引告诉你一个类“在哪声明”,这里告诉你它“装了什么”——"
171+
"项目用 dataclass / pydantic / TypedDict / NamedTuple 显式声明的数据记录,"
172+
"以及每条记录有哪些字段、各是什么类型。",
173+
"",
174+
]
175+
for model in models:
176+
lines.append(f"## `{model['name']}` ({model['kind']}{model['path']}:{model['line']})")
177+
if not model["fields"]:
178+
lines.append("- (未声明带类型标注的字段)")
179+
lines.append("")
180+
continue
181+
for field in model["fields"]:
182+
entry = f"- `{field['name']}`"
183+
if field["type"]:
184+
entry += f":{field['type']}"
185+
if field["has_default"]:
186+
entry += "(有默认值)"
187+
lines.append(entry)
188+
lines.append("")
189+
190+
return "\n".join(lines).rstrip() + "\n"

0 commit comments

Comments
 (0)