Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions .github/scripts/type_completeness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
"""
Builds a Markdown summary comparing pyright `--verifytypes` reports for a
PR's base and head commits, for posting as a PR comment.

"Project" completeness is read directly from the head report's
typeCompleteness.completenessScore. "Patch" completeness is computed by
matching exported symbols between the base and head reports by their
dotted name: a symbol counts toward the patch if it is new in head, or if
its known/ambiguous/unknown status changed between base and head. This
avoids needing to map symbols to source line ranges, since pyright only
reports a file/line for symbols that already have a type problem.
"""
from __future__ import annotations

import argparse
import json
from pathlib import Path
from typing import Any

STATUS_ICON = {"known": "✅", "ambiguous": "⚠️", "unknown": "❌"}


def status_of(symbol: dict[str, Any]) -> str:
if symbol["isTypeKnown"]:
return "known"
if symbol["isTypeAmbiguous"]:
return "ambiguous"
return "unknown"


def load_type_completeness(report_path: Path) -> dict[str, Any]:
with report_path.open() as f:
data = json.load(f)
return data["typeCompleteness"]


def exported_symbols(type_completeness: dict[str, Any]) -> dict[str, dict[str, Any]]:
return {s["name"]: s for s in type_completeness["symbols"] if s["isExported"]}


def other_symbol_counts(type_completeness: dict[str, Any]) -> dict[str, int]:
other = type_completeness["otherSymbolCounts"]
return {
"known": other["withKnownType"],
"ambiguous": other["withAmbiguousType"],
"unknown": other["withUnknownType"],
}


def counts_by_status(symbols: list[dict[str, Any]]) -> dict[str, int]:
counts = {"known": 0, "ambiguous": 0, "unknown": 0}
for s in symbols:
counts[status_of(s)] += 1
return counts


def completeness_pct(counts: dict[str, int]) -> float:
total = sum(counts.values())
return 100.0 * counts["known"] / total if total else 0.0


def render_counts_table(rows: list[tuple[str, dict[str, int]]]) -> str:
lines = ["| | Known | Ambiguous | Unknown | Total |", "|---|---|---|---|---|"]
for label, counts in rows:
total = sum(counts.values())
lines.append(
f"| {label} | {counts['known']} | {counts['ambiguous']} | "
f"{counts['unknown']} | {total} |"
)
return "\n".join(lines)


def render_patch_detail(
patch_names: list[str],
base: dict[str, dict[str, Any]],
head: dict[str, dict[str, Any]],
) -> str:
lines = ["| Symbol | Status | Change |", "|---|---|---|"]
for name in sorted(patch_names):
head_status = status_of(head[name])
icon = STATUS_ICON[head_status]
if name not in base:
change = "new"
else:
base_status = status_of(base[name])
change = f"changed (was {STATUS_ICON[base_status]} {base_status})"
lines.append(f"| `{name}` | {icon} {head_status} | {change} |")
return "\n".join(lines)


def build_summary(base_path: Path, head_path: Path, run_url: str | None = None) -> str:
base_tc = load_type_completeness(base_path)
head_tc = load_type_completeness(head_path)
base = exported_symbols(base_tc)
head = exported_symbols(head_tc)

project_counts = counts_by_status(list(head.values()))
project_pct = completeness_pct(project_counts)
other_counts = other_symbol_counts(head_tc)

patch_names = [
name
for name, symbol in head.items()
if name not in base or status_of(base[name]) != status_of(symbol)
]

sections = [
"## Pyright Type Completeness",
"",
]
if run_url:
sections += [
f"[View the full `pyright --verifytypes` output for this commit]({run_url})",
"",
]
sections += [
f"**Project (full `chainladder` package, at this PR's head):** "
f"{project_pct:.1f}% of exported symbols fully typed "
f"({project_counts['known']} / {sum(project_counts.values())})",
"",
render_counts_table([("Project (head)", project_counts)]),
"",
f"Other symbols referenced but not exported by `chainladder`: "
f"{sum(other_counts.values())}",
"",
render_counts_table([("Other (head)", other_counts)]),
"",
"Symbols without documentation:",
f"- Functions without docstring: {head_tc['missingFunctionDocStringCount']}",
f"- Functions without default param: {head_tc['missingDefaultParamCount']}",
f"- Classes without docstring: {head_tc['missingClassDocStringCount']}",
"",
]

if patch_names:
patch_counts = counts_by_status([head[n] for n in patch_names])
patch_pct = completeness_pct(patch_counts)
sections += [
f"**Patch (exported symbols added or changed by this PR):** "
f"{patch_pct:.1f}% fully typed "
f"({patch_counts['known']} / {sum(patch_counts.values())})",
"",
render_counts_table([("Patch", patch_counts)]),
"",
"<details>",
"<summary>Patch symbol details</summary>",
"",
render_patch_detail(patch_names, base, head),
"",
"</details>",
]
else:
sections += [
"**Patch (exported symbols added or changed by this PR):** "
"no exported symbol type-completeness changes detected.",
]
Comment thread
cursor[bot] marked this conversation as resolved.

return "\n".join(sections)


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base", required=True, type=Path)
parser.add_argument("--head", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument(
"--run-url",
default=None,
help="Link to the workflow run, e.g. for its step summary.",
)
args = parser.parse_args()

summary = build_summary(args.base, args.head, run_url=args.run_url)
args.output.write_text(summary)
print(summary)


if __name__ == "__main__":
main()
26 changes: 26 additions & 0 deletions .github/scripts/type_completeness_output.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Appends a pyright --verifytypes text report to $GITHUB_STEP_SUMMARY,
# wrapped in a collapsible <details> block. GitHub caps each step's summary
# at 1MiB and silently drops the whole upload if exceeded, so the report is
# truncated to a safe byte budget with a note when that happens, rather than
# risking the entire block vanishing.
set -euo pipefail

report_file="$1"
label="$2"
max_bytes=900000

total_bytes="$(wc -c < "$report_file")"

{
echo "<details><summary>Full pyright --verifytypes output (${label})</summary>"
echo ""
echo '```text'
head -c "$max_bytes" "$report_file"
if [ "$total_bytes" -gt "$max_bytes" ]; then
echo ""
echo "... (truncated: ${total_bytes} bytes total, GITHUB_STEP_SUMMARY caps each step at 1MiB)"
fi
echo '```'
echo "</details>"
} >> "$GITHUB_STEP_SUMMARY"
Comment thread
cursor[bot] marked this conversation as resolved.
72 changes: 72 additions & 0 deletions .github/workflows/type_completeness.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
name: Pyright Type Completeness

on:
pull_request:

permissions:
contents: read
Comment thread
cursor[bot] marked this conversation as resolved.

jobs:
typecompleteness:
name: pyright --verifytypes
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0

- name: Install uv
uses: astral-sh/setup-uv@v7
with:
version: "latest"
python-version: "3.14"

- name: Install dependencies
run: uv sync --extra dev

- name: Run pyright --verifytypes on PR head
run: |
uv run pyright --outputjson --verifytypes chainladder --ignoreexternal > head.json || true
uv run pyright --verifytypes chainladder --ignoreexternal > head.txt || true
bash .github/scripts/type_completeness_output.sh head.txt "PR head"

- name: Run pyright --verifytypes on PR base
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
git fetch origin "$BASE_SHA"
MERGE_BASE="$(git merge-base "$BASE_SHA" "$HEAD_SHA")"
git checkout "$MERGE_BASE" -- chainladder
uv run pyright --outputjson --verifytypes chainladder --ignoreexternal > base.json || true
uv run pyright --verifytypes chainladder --ignoreexternal > base.txt || true
Comment thread
cursor[bot] marked this conversation as resolved.
git checkout "$HEAD_SHA" -- chainladder
bash .github/scripts/type_completeness_output.sh base.txt "PR base"

- name: Build summary
env:
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
uv run python .github/scripts/type_completeness.py \
--base base.json \
--head head.json \
--run-url "$RUN_URL" \
--output summary.md

- name: Save PR number
run: echo "${{ github.event.pull_request.number }}" > pr_number.txt

# This workflow runs on pull_request, so on PRs from forks it only ever
# gets a read-only GITHUB_TOKEN and can't post a comment directly. The
# artifact is picked up by type_completeness_comment.yml, which runs on
# workflow_run (always with the base repo's write-capable token, without
# ever checking out or executing the fork's code) and posts the comment.
- name: Upload summary artifact
uses: actions/upload-artifact@v4
with:
name: type-completeness-summary
path: |
summary.md
pr_number.txt
retention-days: 1
38 changes: 38 additions & 0 deletions .github/workflows/type_completeness_comment.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Pyright Type Completeness Comment

# Split out from type_completeness.yml so the comment can be posted with a
# write-capable token even for PRs from forks. workflow_run always executes
# using the workflow file from the default branch, with the base repo's own
# token, regardless of whether the triggering run came from a fork - and
# this workflow never checks out or executes the fork's code, only the
# already-computed summary.md/pr_number.txt artifact it produced.
on:
workflow_run:
workflows: ["Pyright Type Completeness"]
types: [completed]

permissions:
pull-requests: write

jobs:
comment:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
steps:
- name: Download summary artifact
uses: actions/download-artifact@v4
with:
name: type-completeness-summary
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}

- name: Read PR number
id: pr
run: echo "number=$(cat pr_number.txt)" >> "$GITHUB_OUTPUT"

- name: Comment on PR
uses: marocchino/sticky-pull-request-comment@v3
with:
header: pyright-typecompleteness
number_force: ${{ steps.pr.outputs.number }}
path: summary.md
Loading