tesserakit-docs 0.4.0__tar.gz

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.
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ out/
8
+ .DS_Store
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: tesserakit-docs
3
+ Version: 0.4.0
4
+ Summary: Docs job pack for Tessera: measure Python docstring coverage for public symbols.
5
+ Project-URL: Homepage, https://github.com/ShaileshRawat1403/tessera
6
+ Project-URL: Repository, https://github.com/ShaileshRawat1403/tessera
7
+ Project-URL: Issues, https://github.com/ShaileshRawat1403/tessera/issues
8
+ Author: Shailesh Rawat
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Requires-Python: >=3.10
14
+ Requires-Dist: pydantic>=2.7
15
+ Requires-Dist: rich>=13.7
16
+ Requires-Dist: tesserakit-core>=0.1.0
17
+ Requires-Dist: typer>=0.12
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=8.0; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # tesserakit-docs
23
+
24
+ Measure Python docstring coverage for public symbols.
25
+
26
+ `tessera-docs` parses Python source with the standard-library `ast` module (it never imports or runs the code), inventories every documentable symbol (modules, classes, functions, methods), and reports which public ones lack docstrings.
27
+
28
+ ## Coverage check
29
+
30
+ ```bash
31
+ tessera docs coverage --input . --output ./out/docs_pack
32
+ tessera docs coverage --input . --include-tests # also scan test files
33
+ ```
34
+
35
+ Test files are excluded by default; pass `--include-tests` to include them.
36
+
37
+ Artifacts written:
38
+
39
+ ```text
40
+ symbols.jsonl one DocSymbol per symbol (kind, public, has_docstring, line)
41
+ index.md coverage headline
42
+ validation_report.md missing-docstring findings + low-coverage warning
43
+ coverage_report.md coverage by kind and lowest-coverage files
44
+ undocumented.md every undocumented public symbol with file:line
45
+ ```
46
+
47
+ ## What counts
48
+
49
+ - **Public** = name does not start with `_` (so `_private` and `__dunder__` are excluded).
50
+ - Symbol kinds: `module`, `class`, `function`, `method`.
51
+ - A symbol is documented if `ast.get_docstring` returns a value.
52
+
53
+ ## Findings
54
+
55
+ - `missing_module_docstring` (info)
56
+ - `missing_class_docstring`, `missing_function_docstring`, `missing_method_docstring` (warning)
57
+ - `low_doc_coverage` — overall public coverage below 80%
58
+ - `parse_error` — a file could not be parsed
59
+ - `no_public_symbols` — nothing public found
@@ -0,0 +1,38 @@
1
+ # tesserakit-docs
2
+
3
+ Measure Python docstring coverage for public symbols.
4
+
5
+ `tessera-docs` parses Python source with the standard-library `ast` module (it never imports or runs the code), inventories every documentable symbol (modules, classes, functions, methods), and reports which public ones lack docstrings.
6
+
7
+ ## Coverage check
8
+
9
+ ```bash
10
+ tessera docs coverage --input . --output ./out/docs_pack
11
+ tessera docs coverage --input . --include-tests # also scan test files
12
+ ```
13
+
14
+ Test files are excluded by default; pass `--include-tests` to include them.
15
+
16
+ Artifacts written:
17
+
18
+ ```text
19
+ symbols.jsonl one DocSymbol per symbol (kind, public, has_docstring, line)
20
+ index.md coverage headline
21
+ validation_report.md missing-docstring findings + low-coverage warning
22
+ coverage_report.md coverage by kind and lowest-coverage files
23
+ undocumented.md every undocumented public symbol with file:line
24
+ ```
25
+
26
+ ## What counts
27
+
28
+ - **Public** = name does not start with `_` (so `_private` and `__dunder__` are excluded).
29
+ - Symbol kinds: `module`, `class`, `function`, `method`.
30
+ - A symbol is documented if `ast.get_docstring` returns a value.
31
+
32
+ ## Findings
33
+
34
+ - `missing_module_docstring` (info)
35
+ - `missing_class_docstring`, `missing_function_docstring`, `missing_method_docstring` (warning)
36
+ - `low_doc_coverage` — overall public coverage below 80%
37
+ - `parse_error` — a file could not be parsed
38
+ - `no_public_symbols` — nothing public found
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "tesserakit-docs"
7
+ version = "0.4.0"
8
+ description = "Docs job pack for Tessera: measure Python docstring coverage for public symbols."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ authors = [{ name = "Shailesh Rawat" }]
12
+ dependencies = [
13
+ "tesserakit-core>=0.1.0",
14
+ "typer>=0.12",
15
+ "rich>=13.7",
16
+ "pydantic>=2.7",
17
+ ]
18
+ classifiers = [
19
+ "Development Status :: 3 - Alpha",
20
+ "Environment :: Console",
21
+ "Intended Audience :: Developers",
22
+ "Programming Language :: Python :: 3",
23
+ ]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/ShaileshRawat1403/tessera"
27
+ Repository = "https://github.com/ShaileshRawat1403/tessera"
28
+ Issues = "https://github.com/ShaileshRawat1403/tessera/issues"
29
+
30
+ [project.optional-dependencies]
31
+ dev = ["pytest>=8.0"]
32
+
33
+ [project.entry-points."tessera.commands"]
34
+ docs = "tessera_docs.cli:register"
35
+
36
+ [project.entry-points."tessera.jobpacks"]
37
+ docs = "tessera_docs.pack:create_pack"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/tessera_docs"]
@@ -0,0 +1,3 @@
1
+ """Tessera docs pack."""
2
+
3
+ __version__ = "0.3.1"
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from tessera_core.models import RunContext
10
+
11
+ from tessera_docs.pack import DocsPack
12
+
13
+ console = Console()
14
+ docs_app = typer.Typer(help="Measure Python docstring coverage for public symbols.")
15
+
16
+
17
+ @docs_app.command("coverage")
18
+ def coverage_cmd(
19
+ input: Path = typer.Option(Path("."), "--input", "-i", exists=True, readable=True, help="Project directory."),
20
+ output: Path = typer.Option(Path("docs_pack"), "--output", "-o", help="Output directory."),
21
+ include_tests: bool = typer.Option(False, "--include-tests", help="Include test files in the scan."),
22
+ ) -> None:
23
+ """Scan Python source and report docstring coverage for public symbols."""
24
+ ctx = RunContext(job_name="docs", output_dir=output)
25
+ pack = DocsPack()
26
+ artifacts = pack.run(input_path=input, ctx=ctx, options={"include_tests": include_tests})
27
+
28
+ table = Table(title="Docs Pack Created")
29
+ table.add_column("Artifact")
30
+ table.add_column("Path")
31
+ table.add_column("Kind")
32
+ for art in artifacts:
33
+ table.add_row(art.name, str(art.path), art.kind)
34
+ console.print(table)
35
+
36
+ summary = Table(title="Run Summary")
37
+ summary.add_column("Metric")
38
+ summary.add_column("Value")
39
+ summary.add_row("run_id", ctx.run_id)
40
+ summary.add_row("symbols", str(ctx.metadata.get("record_count", 0)))
41
+ summary.add_row("findings", str(ctx.metadata.get("finding_count", 0)))
42
+ console.print(summary)
43
+
44
+
45
+ def register(root_app: typer.Typer) -> None:
46
+ root_app.add_typer(docs_app, name="docs")
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter, defaultdict
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from tessera_core.artifacts import write_jsonl, write_markdown
8
+ from tessera_core.models import Artifact, RunContext, ValidationFinding
9
+
10
+ from tessera_docs.loader import load_docs_records
11
+ from tessera_docs.schema import DocSymbol
12
+ from tessera_docs.validator import validate_docs_records
13
+
14
+
15
+ def load_records(input_path: Path, options: dict[str, Any]) -> list[DocSymbol]:
16
+ return load_docs_records(input_path, options)
17
+
18
+
19
+ def validate_records(symbols: list[DocSymbol], options: dict[str, Any]) -> list[ValidationFinding]:
20
+ return validate_docs_records(symbols, options)
21
+
22
+
23
+ def write_artifacts(symbols: list[DocSymbol], ctx: RunContext, options: dict[str, Any]) -> list[Artifact]:
24
+ ctx.output_dir.mkdir(parents=True, exist_ok=True)
25
+ findings: list[ValidationFinding] = ctx.metadata.get("findings") or validate_records(symbols, options)
26
+
27
+ symbols_jsonl = ctx.output_dir / "symbols.jsonl"
28
+ index_md = ctx.output_dir / "index.md"
29
+ validation_md = ctx.output_dir / "validation_report.md"
30
+ coverage_md = ctx.output_dir / "coverage_report.md"
31
+ undocumented_md = ctx.output_dir / "undocumented.md"
32
+
33
+ write_jsonl(symbols_jsonl, [s.model_dump() for s in symbols])
34
+ write_markdown(index_md, _render_index(symbols, options))
35
+ write_markdown(validation_md, _render_validation(symbols, findings))
36
+ write_markdown(coverage_md, _render_coverage(symbols))
37
+ write_markdown(undocumented_md, _render_undocumented(symbols))
38
+
39
+ return [
40
+ Artifact(name="symbols.jsonl", path=symbols_jsonl, kind="jsonl"),
41
+ Artifact(name="index.md", path=index_md, kind="markdown"),
42
+ Artifact(name="validation_report.md", path=validation_md, kind="markdown"),
43
+ Artifact(name="coverage_report.md", path=coverage_md, kind="markdown"),
44
+ Artifact(name="undocumented.md", path=undocumented_md, kind="markdown"),
45
+ ]
46
+
47
+
48
+ def _coverage(symbols: list[DocSymbol]) -> tuple[int, int, float]:
49
+ public = [s for s in symbols if s.is_public]
50
+ documented = sum(1 for s in public if s.has_docstring)
51
+ pct = (documented / len(public)) if public else 1.0
52
+ return documented, len(public), pct
53
+
54
+
55
+ def _render_index(symbols: list[DocSymbol], options: dict[str, Any]) -> str:
56
+ documented, total, pct = _coverage(symbols)
57
+ lines = ["# Documentation Coverage", ""]
58
+ lines.append(f"- Files scanned: {options.get('_file_count', 0)}")
59
+ lines.append(f"- Public symbols: {total}")
60
+ lines.append(f"- Documented: {documented}")
61
+ lines.append(f"- Coverage: {pct*100:.0f}%")
62
+ return "\n".join(lines) + "\n"
63
+
64
+
65
+ def _render_validation(symbols: list[DocSymbol], findings: list[ValidationFinding]) -> str:
66
+ lines = ["# Validation Report", ""]
67
+ lines.append(f"- Symbols: {len(symbols)}")
68
+ lines.append(f"- Findings: {len(findings)}")
69
+ lines.append("")
70
+ by_sev = Counter(f.severity for f in findings)
71
+ lines.append("## Severity Breakdown")
72
+ lines.append("")
73
+ for sev in ("error", "warning", "info"):
74
+ lines.append(f"- {sev}: {by_sev.get(sev, 0)}")
75
+ lines.append("")
76
+ if findings:
77
+ lines.append("## Findings")
78
+ lines.append("")
79
+ for f in findings[:300]:
80
+ lines.append(f"- **{f.severity.upper()}** `{f.code}`: {f.message}")
81
+ if len(findings) > 300:
82
+ lines.append(f"- ... {len(findings) - 300} more findings omitted")
83
+ return "\n".join(lines)
84
+
85
+
86
+ def _render_coverage(symbols: list[DocSymbol]) -> str:
87
+ lines = ["# Coverage Report", ""]
88
+ documented, total, pct = _coverage(symbols)
89
+ lines.append(f"- Public symbols: {total}")
90
+ lines.append(f"- Documented: {documented} ({pct*100:.0f}%)")
91
+ lines.append("")
92
+
93
+ # by kind
94
+ lines.append("## By kind")
95
+ lines.append("")
96
+ lines.append("| Kind | Public | Documented | Coverage |")
97
+ lines.append("|---|---:|---:|---:|")
98
+ by_kind: dict[str, list[DocSymbol]] = defaultdict(list)
99
+ for s in symbols:
100
+ if s.is_public:
101
+ by_kind[s.kind].append(s)
102
+ for kind in ("module", "class", "function", "method"):
103
+ items = by_kind.get(kind, [])
104
+ if not items:
105
+ continue
106
+ doc = sum(1 for s in items if s.has_docstring)
107
+ lines.append(f"| {kind} | {len(items)} | {doc} | {100*doc/len(items):.0f}% |")
108
+ lines.append("")
109
+
110
+ # worst files
111
+ by_file: dict[str, list[DocSymbol]] = defaultdict(list)
112
+ for s in symbols:
113
+ if s.is_public:
114
+ by_file[s.path].append(s)
115
+ rows = []
116
+ for path, items in by_file.items():
117
+ doc = sum(1 for s in items if s.has_docstring)
118
+ rows.append((doc / len(items), path, doc, len(items)))
119
+ rows.sort()
120
+ lines.append("## Lowest-coverage files")
121
+ lines.append("")
122
+ lines.append("| File | Documented | Public | Coverage |")
123
+ lines.append("|---|---:|---:|---:|")
124
+ for cov, path, doc, tot in rows[:15]:
125
+ lines.append(f"| `{path}` | {doc} | {tot} | {cov*100:.0f}% |")
126
+ return "\n".join(lines) + "\n"
127
+
128
+
129
+ def _render_undocumented(symbols: list[DocSymbol]) -> str:
130
+ missing = [s for s in symbols if s.is_public and not s.has_docstring]
131
+ lines = ["# Undocumented Public Symbols", ""]
132
+ lines.append(f"- Count: {len(missing)}")
133
+ lines.append("")
134
+ if not missing:
135
+ lines.append("_Everything public is documented._")
136
+ return "\n".join(lines) + "\n"
137
+ lines.append("| File | Symbol | Kind | Line |")
138
+ lines.append("|---|---|---|---:|")
139
+ for s in sorted(missing, key=lambda x: (x.path, x.lineno)):
140
+ lines.append(f"| `{s.path}` | `{s.qualname or s.name}` | {s.kind} | {s.lineno} |")
141
+ return "\n".join(lines) + "\n"
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from tessera_docs.scan import discover_py_files, extract_symbols
7
+ from tessera_docs.schema import DocSymbol
8
+
9
+
10
+ def load_docs_records(input_path: Path, options: dict[str, Any]) -> list[DocSymbol]:
11
+ """Extract documentable symbols from every (non-test) Python file."""
12
+ root = input_path if input_path.is_dir() else input_path.parent
13
+ include_tests = bool(options.get("include_tests", False))
14
+
15
+ symbols: list[DocSymbol] = []
16
+ parse_errors: list[str] = []
17
+ files = discover_py_files(root, include_tests=include_tests)
18
+ for f in files:
19
+ syms, err = extract_symbols(root, f)
20
+ symbols.extend(syms)
21
+ if err:
22
+ parse_errors.append(err)
23
+
24
+ options["_parse_errors"] = parse_errors
25
+ options["_file_count"] = len(files)
26
+ options["_root"] = str(root)
27
+ return symbols
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from tessera_core.jobpack import JobPack
7
+ from tessera_core.models import Artifact, RunContext, ValidationFinding
8
+
9
+ from tessera_docs.compiler import load_records, validate_records, write_artifacts
10
+
11
+
12
+ class DocsPack(JobPack):
13
+ name = "docs"
14
+ version = "0.3.1"
15
+
16
+ def normalize(self, input_path: Path, options: dict[str, Any]) -> list[Any]:
17
+ return load_records(input_path, options)
18
+
19
+ def validate(
20
+ self,
21
+ records: list[Any],
22
+ options: dict[str, Any],
23
+ ) -> list[ValidationFinding]:
24
+ return validate_records(records, options)
25
+
26
+ def generate(
27
+ self,
28
+ records: list[Any],
29
+ ctx: RunContext,
30
+ options: dict[str, Any],
31
+ ) -> list[Artifact]:
32
+ return write_artifacts(records, ctx, options)
33
+
34
+
35
+ def create_pack() -> DocsPack:
36
+ return DocsPack()
@@ -0,0 +1,83 @@
1
+ """Extract documentable symbols from Python source via the ast module.
2
+
3
+ Source is parsed, never imported or executed.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import ast
9
+ from pathlib import Path
10
+
11
+ from tessera_docs.schema import DocSymbol
12
+
13
+ IGNORE_DIRS = {
14
+ ".git", ".venv", "venv", "node_modules", "__pycache__", ".pytest_cache",
15
+ "dist", "build", ".tox", "target", ".mypy_cache", ".ruff_cache",
16
+ }
17
+
18
+
19
+ def is_public(name: str) -> bool:
20
+ return not name.startswith("_")
21
+
22
+
23
+ def _is_test_path(rel: Path) -> bool:
24
+ parts = [p.lower() for p in rel.parts]
25
+ if any(p in ("test", "tests", "__tests__") for p in parts[:-1]):
26
+ return True
27
+ n = rel.name.lower()
28
+ return n.startswith("test_") or n.endswith("_test.py")
29
+
30
+
31
+ def discover_py_files(root: Path, include_tests: bool = False) -> list[Path]:
32
+ out: list[Path] = []
33
+ for p in sorted(root.rglob("*.py")):
34
+ rel = p.relative_to(root)
35
+ if any(part in IGNORE_DIRS for part in rel.parts):
36
+ continue
37
+ if not include_tests and _is_test_path(rel):
38
+ continue
39
+ out.append(p)
40
+ return out
41
+
42
+
43
+ def extract_symbols(root: Path, path: Path) -> tuple[list[DocSymbol], str | None]:
44
+ """Return (symbols, parse_error). Parse error is a string or None."""
45
+ rel = path.relative_to(root).as_posix()
46
+ try:
47
+ source = path.read_text(encoding="utf-8")
48
+ tree = ast.parse(source)
49
+ except (OSError, UnicodeDecodeError, SyntaxError) as exc:
50
+ return [], f"{rel}: {exc}"
51
+
52
+ symbols: list[DocSymbol] = []
53
+
54
+ # module-level symbol
55
+ mod_doc = ast.get_docstring(tree)
56
+ symbols.append(DocSymbol(
57
+ path=rel, qualname="", kind="module", name=Path(rel).stem, lineno=1,
58
+ is_public=True, has_docstring=mod_doc is not None,
59
+ docstring_len=len(mod_doc or ""),
60
+ ))
61
+
62
+ def walk(nodes, prefix: str, inside_class: bool) -> None:
63
+ for node in nodes:
64
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
65
+ kind = "method" if inside_class else "function"
66
+ _add(node, kind, prefix)
67
+ # nested functions/classes
68
+ walk(node.body, f"{prefix}{node.name}.", inside_class=False)
69
+ elif isinstance(node, ast.ClassDef):
70
+ _add(node, "class", prefix)
71
+ walk(node.body, f"{prefix}{node.name}.", inside_class=True)
72
+
73
+ def _add(node, kind: str, prefix: str) -> None:
74
+ doc = ast.get_docstring(node)
75
+ qual = f"{prefix}{node.name}"
76
+ symbols.append(DocSymbol(
77
+ path=rel, qualname=qual, kind=kind, name=node.name, lineno=node.lineno,
78
+ is_public=is_public(node.name), has_docstring=doc is not None,
79
+ docstring_len=len(doc or ""),
80
+ ))
81
+
82
+ walk(tree.body, prefix="", inside_class=False)
83
+ return symbols, None
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class DocSymbol(BaseModel):
9
+ """A documentable Python symbol. Serialized to ``symbols.jsonl``."""
10
+
11
+ path: str # repo-relative source file, POSIX
12
+ qualname: str # dotted name within the module (module-level = "")
13
+ kind: str # module / class / function / method
14
+ name: str
15
+ lineno: int = 0
16
+ is_public: bool = True
17
+ has_docstring: bool = False
18
+ docstring_len: int = 0
19
+ metadata: dict[str, Any] = Field(default_factory=dict)
@@ -0,0 +1,64 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from tessera_core.models import ValidationFinding
6
+
7
+ from tessera_docs.schema import DocSymbol
8
+
9
+ LOW_COVERAGE_THRESHOLD = 0.80
10
+
11
+
12
+ def validate_docs_records(symbols: list[DocSymbol], options: dict[str, Any]) -> list[ValidationFinding]:
13
+ findings: list[ValidationFinding] = []
14
+
15
+ for err in options.get("_parse_errors", []):
16
+ findings.append(
17
+ ValidationFinding(severity="error", code="parse_error",
18
+ message=f"could not parse: {err}", field=None)
19
+ )
20
+
21
+ public = [s for s in symbols if s.is_public]
22
+ if not public:
23
+ if not options.get("_parse_errors"):
24
+ findings.append(ValidationFinding(severity="info", code="no_public_symbols",
25
+ message="no public symbols found", field=None))
26
+ return findings
27
+
28
+ code_for_kind = {
29
+ "module": "missing_module_docstring",
30
+ "class": "missing_class_docstring",
31
+ "function": "missing_function_docstring",
32
+ "method": "missing_method_docstring",
33
+ }
34
+ severity_for_kind = {
35
+ "module": "info",
36
+ "class": "warning",
37
+ "function": "warning",
38
+ "method": "warning",
39
+ }
40
+
41
+ for s in public:
42
+ if not s.has_docstring:
43
+ findings.append(
44
+ ValidationFinding(
45
+ severity=severity_for_kind.get(s.kind, "warning"),
46
+ code=code_for_kind.get(s.kind, "missing_docstring"),
47
+ message=f"{s.kind} `{s.qualname or s.name}` ({s.path}:{s.lineno}) has no docstring",
48
+ field="docs",
49
+ metadata={"path": s.path, "qualname": s.qualname or s.name, "kind": s.kind, "lineno": s.lineno},
50
+ )
51
+ )
52
+
53
+ documented = sum(1 for s in public if s.has_docstring)
54
+ coverage = documented / len(public)
55
+ if coverage < LOW_COVERAGE_THRESHOLD:
56
+ findings.append(
57
+ ValidationFinding(
58
+ severity="warning", code="low_doc_coverage",
59
+ message=f"public docstring coverage is {coverage*100:.0f}% (below {int(LOW_COVERAGE_THRESHOLD*100)}%)",
60
+ field="docs", metadata={"coverage": round(coverage, 4)},
61
+ )
62
+ )
63
+
64
+ return findings
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from tessera_core.models import RunContext
7
+
8
+ from tessera_docs.pack import DocsPack
9
+ from tessera_docs.scan import extract_symbols, is_public
10
+ from tessera_docs.schema import DocSymbol
11
+
12
+ REPO_ROOT = Path(__file__).resolve().parents[3]
13
+ SAMPLE = REPO_ROOT / "examples" / "docs" / "sample"
14
+
15
+
16
+ def _run(tmp_path: Path):
17
+ out = tmp_path / "docs_pack"
18
+ ctx = RunContext(job_name="docs", output_dir=out)
19
+ DocsPack().run(input_path=SAMPLE, ctx=ctx, options={})
20
+ return out, ctx
21
+
22
+
23
+ # ---------- primitives ----------
24
+
25
+
26
+ def test_is_public():
27
+ assert is_public("add")
28
+ assert not is_public("_helper")
29
+ assert not is_public("__init__")
30
+
31
+
32
+ def test_extract_symbols_documented(tmp_path: Path):
33
+ syms, err = extract_symbols(SAMPLE, SAMPLE / "documented.py")
34
+ assert err is None
35
+ by_qual = {s.qualname: s for s in syms}
36
+ # module symbol has empty qualname
37
+ assert by_qual[""].kind == "module" and by_qual[""].has_docstring
38
+ assert by_qual["add"].has_docstring
39
+ assert by_qual["Calculator"].has_docstring
40
+ assert by_qual["Calculator.total"].kind == "method" and by_qual["Calculator.total"].has_docstring
41
+
42
+
43
+ def test_extract_symbols_undocumented(tmp_path: Path):
44
+ syms, _ = extract_symbols(SAMPLE, SAMPLE / "undocumented.py")
45
+ by_qual = {s.qualname: s for s in syms}
46
+ assert not by_qual[""].has_docstring # no module docstring
47
+ assert not by_qual["subtract"].has_docstring
48
+ assert not by_qual["Widget.render"].has_docstring
49
+ assert by_qual["Widget._private_helper"].is_public is False
50
+
51
+
52
+ # ---------- end-to-end ----------
53
+
54
+
55
+ def test_pack_artifacts_and_coverage(tmp_path: Path):
56
+ out, ctx = _run(tmp_path)
57
+ names = {p.name for p in out.iterdir()}
58
+ assert {
59
+ "symbols.jsonl", "index.md", "validation_report.md",
60
+ "coverage_report.md", "undocumented.md",
61
+ } <= names
62
+
63
+ index = (out / "index.md").read_text()
64
+ assert "Coverage:" in index
65
+
66
+ # undocumented.md should list the undocumented public symbols
67
+ undoc = (out / "undocumented.md").read_text()
68
+ assert "subtract" in undoc
69
+ assert "Widget.render" in undoc
70
+ # private helper must NOT be listed
71
+ assert "_private_helper" not in undoc
72
+
73
+
74
+ def test_findings(tmp_path: Path):
75
+ _, ctx = _run(tmp_path)
76
+ codes = {f.code for f in ctx.metadata["findings"]}
77
+ assert "missing_function_docstring" in codes # subtract
78
+ assert "missing_method_docstring" in codes # Widget.render
79
+ assert "missing_module_docstring" in codes # undocumented.py
80
+ assert "low_doc_coverage" in codes # half the sample is undocumented
81
+
82
+
83
+ def test_symbols_round_trip(tmp_path: Path):
84
+ out, _ = _run(tmp_path)
85
+ for line in (out / "symbols.jsonl").read_text().splitlines():
86
+ s = DocSymbol.model_validate_json(line)
87
+ assert s.kind in ("module", "class", "function", "method")