contribos 0.2.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.
- contribos/__init__.py +3 -0
- contribos/__main__.py +5 -0
- contribos/agent_rules.py +148 -0
- contribos/bench.py +80 -0
- contribos/brief.py +299 -0
- contribos/check.py +395 -0
- contribos/claim.py +92 -0
- contribos/cli.py +371 -0
- contribos/find.py +295 -0
- contribos/github.py +96 -0
- contribos/llm.py +93 -0
- contribos/mcp_server.py +192 -0
- contribos/policy.py +341 -0
- contribos/precedent.py +115 -0
- contribos/proof.py +105 -0
- contribos/record.py +135 -0
- contribos/repo.py +105 -0
- contribos/review.py +162 -0
- contribos/setup_doctor.py +368 -0
- contribos/tone.py +66 -0
- contribos-0.2.0.dist-info/METADATA +93 -0
- contribos-0.2.0.dist-info/RECORD +25 -0
- contribos-0.2.0.dist-info/WHEEL +5 -0
- contribos-0.2.0.dist-info/entry_points.txt +2 -0
- contribos-0.2.0.dist-info/top_level.txt +1 -0
contribos/__init__.py
ADDED
contribos/__main__.py
ADDED
contribos/agent_rules.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Agent guardrails: make your coding agent follow this repo's rules, where slop starts.
|
|
2
|
+
|
|
3
|
+
Writes, into your local checkout:
|
|
4
|
+
- a skill (`.claude/skills/contribos/SKILL.md`, mirrored to `.agents/skills/`)
|
|
5
|
+
that Claude Code, Codex, Copilot and Cursor load, with this repo's rules;
|
|
6
|
+
- a Claude Code hook that blocks the agent from opening PRs or posting
|
|
7
|
+
comments on its own;
|
|
8
|
+
- a git `commit-msg` hook that enforces sign-off and the AI-disclosure line
|
|
9
|
+
this repo asks for.
|
|
10
|
+
|
|
11
|
+
None of these files are part of your contribution: they're added to
|
|
12
|
+
`.git/info/exclude` so they never end up in your PR.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import stat
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from .policy import Policy, structured
|
|
22
|
+
from .setup_doctor import SetupPlan
|
|
23
|
+
|
|
24
|
+
GUARD = '''#!/usr/bin/env python3
|
|
25
|
+
"""ContribOS guard: stop the coding agent from acting publicly on its own."""
|
|
26
|
+
import json, re, sys
|
|
27
|
+
|
|
28
|
+
data = json.load(sys.stdin)
|
|
29
|
+
cmd = (data.get("tool_input") or {}).get("command", "")
|
|
30
|
+
if re.search(r"\\bgh\\s+(pr\\s+(create|comment|review|merge|edit|ready|close|reopen)|"
|
|
31
|
+
r"issue\\s+(create|comment|edit|close|reopen)|"
|
|
32
|
+
r"api\\b.*(-X|--method)\\s*(POST|PATCH|PUT|DELETE))", cmd, re.I):
|
|
33
|
+
print("ContribOS: opening PRs, posting comments or claiming issues must be done by the human "
|
|
34
|
+
"contributor, not the agent. Prepare the text and let the human post it.", file=sys.stderr)
|
|
35
|
+
sys.exit(2)
|
|
36
|
+
sys.exit(0)
|
|
37
|
+
'''
|
|
38
|
+
|
|
39
|
+
COMMIT_MSG = '''#!/bin/sh
|
|
40
|
+
# ContribOS commit-msg hook: enforce this repo's sign-off and AI-disclosure rules.
|
|
41
|
+
msg="$1"
|
|
42
|
+
{dco}
|
|
43
|
+
if [ -f "$(git rev-parse --git-dir)/contribos-ai-used" ] && [ -n "{trailer}" ]; then
|
|
44
|
+
if ! grep -qi "^{trailer}:" "$msg"; then
|
|
45
|
+
echo "ContribOS: this repo asks for a '{trailer}' line when AI helped. Add, for example:" >&2
|
|
46
|
+
echo " {trailer}: $(cat "$(git rev-parse --git-dir)/contribos-ai-used")" >&2
|
|
47
|
+
exit 1
|
|
48
|
+
fi
|
|
49
|
+
fi
|
|
50
|
+
exit 0
|
|
51
|
+
'''
|
|
52
|
+
|
|
53
|
+
DCO_CHECK = '''if ! grep -q "^Signed-off-by:" "$msg"; then
|
|
54
|
+
echo "ContribOS: this repo requires sign-off. Commit with: git commit -s" >&2
|
|
55
|
+
exit 1
|
|
56
|
+
fi'''
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def skill_text(policy: Policy, setup: SetupPlan | None) -> str:
|
|
60
|
+
s = structured(policy)
|
|
61
|
+
ai = s["ai"]
|
|
62
|
+
rules = ["Never open pull requests, post comments, or claim issues yourself. Draft text; the human posts it.",
|
|
63
|
+
"Keep the change minimal and focused on the issue. Do not refactor unrelated code.",
|
|
64
|
+
"The human must understand every line. Explain your changes plainly and point out anything uncertain.",
|
|
65
|
+
"Never write the human's PR explanation, issue comments or review replies as final text; offer a "
|
|
66
|
+
"short draft they will rewrite."]
|
|
67
|
+
if ai["stance"] == "restricted":
|
|
68
|
+
rules.append("This repo restricts AI use. Read the AI policy evidence below and stop if the task is not allowed.")
|
|
69
|
+
if s["trust_gate"]:
|
|
70
|
+
rules.append("This repo only accepts PRs from vouched contributors. If the human isn't vouched yet, "
|
|
71
|
+
"help them write a short introduction instead of code.")
|
|
72
|
+
if ai["no_ai_on_good_first_issues"]:
|
|
73
|
+
rules.append("Do not work on issues labelled good-first-issue: this repo reserves them for humans learning.")
|
|
74
|
+
if ai["disclosure_trailer"]:
|
|
75
|
+
rules.append(f"Every commit you help with must include a `{ai['disclosure_trailer']}` line naming the tool.")
|
|
76
|
+
elif ai["disclose_in_pr"]:
|
|
77
|
+
rules.append("Remind the human to disclose in the PR description which tool was used and for what.")
|
|
78
|
+
if s["dco"]:
|
|
79
|
+
rules.append("Commits must be signed off by the human (`git commit -s`). Never add Signed-off-by yourself.")
|
|
80
|
+
if s["tests_expected"]:
|
|
81
|
+
rules.append("Add or update a test that fails without the fix.")
|
|
82
|
+
if s["changelog"]:
|
|
83
|
+
rules.append("Add a changelog entry in the format the repo uses.")
|
|
84
|
+
if s["issue_first"] or s["claim_first"]:
|
|
85
|
+
rules.append("Only work on an issue a maintainer has agreed to. If unsure, stop and ask the human.")
|
|
86
|
+
steps = []
|
|
87
|
+
if setup:
|
|
88
|
+
for st in setup.steps:
|
|
89
|
+
if st.commands and ("test" in st.title.lower() or "lint" in st.title.lower() or "pre-commit" in st.title.lower()):
|
|
90
|
+
steps += [f"`{c}`" for c in st.commands[:2]]
|
|
91
|
+
L = ["---", "name: contribos", "description: Rules for contributing to this open-source repository. Use before "
|
|
92
|
+
"writing or committing any change here.", "---", "",
|
|
93
|
+
f"# Contributing to {policy.repo}", "",
|
|
94
|
+
"You are helping a human contributor. Maintainers here review human work and reject unexplained AI output.",
|
|
95
|
+
"", "## Rules", ""] + [f"- {r}" for r in rules]
|
|
96
|
+
if steps:
|
|
97
|
+
L += ["", "## Run before saying you're done", ""] + [f"- {c}" for c in steps]
|
|
98
|
+
L += ["", "## Tools", "",
|
|
99
|
+
"If the ContribOS MCP server is available, use `contribos_brief` to find relevant files and past PRs, "
|
|
100
|
+
"and `contribos_check` before the human submits.", "", "## Evidence for these rules", ""]
|
|
101
|
+
for f in policy.findings[:8]:
|
|
102
|
+
src = f" ({f.evidence[0].source})" if f.evidence else ""
|
|
103
|
+
L.append(f"- {f.topic}: {f.verdict}{src}")
|
|
104
|
+
return "\n".join(L) + "\n"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def install(root: Path, policy: Policy, setup: SetupPlan | None, ai_note: str | None = None) -> list[str]:
|
|
108
|
+
written = []
|
|
109
|
+
text = skill_text(policy, setup)
|
|
110
|
+
for d in (root / ".claude/skills/contribos", root / ".agents/skills/contribos"):
|
|
111
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
(d / "SKILL.md").write_text(text)
|
|
113
|
+
written.append((d / "SKILL.md").relative_to(root).as_posix())
|
|
114
|
+
|
|
115
|
+
guard = root / ".contribos/guard.py"
|
|
116
|
+
guard.parent.mkdir(exist_ok=True)
|
|
117
|
+
guard.write_text(GUARD)
|
|
118
|
+
guard.chmod(guard.stat().st_mode | stat.S_IEXEC)
|
|
119
|
+
written.append(".contribos/guard.py")
|
|
120
|
+
settings_path = root / ".claude/settings.local.json"
|
|
121
|
+
settings = json.loads(settings_path.read_text()) if settings_path.exists() else {}
|
|
122
|
+
hooks = settings.setdefault("hooks", {}).setdefault("PreToolUse", [])
|
|
123
|
+
entry = {"matcher": "Bash", "hooks": [{"type": "command", "command": "python3 .contribos/guard.py"}]}
|
|
124
|
+
if entry not in hooks:
|
|
125
|
+
hooks.append(entry)
|
|
126
|
+
settings_path.write_text(json.dumps(settings, indent=2) + "\n")
|
|
127
|
+
written.append(".claude/settings.local.json")
|
|
128
|
+
|
|
129
|
+
s = structured(policy)
|
|
130
|
+
git_dir = root / ".git"
|
|
131
|
+
if git_dir.is_dir():
|
|
132
|
+
hook = git_dir / "hooks" / "commit-msg"
|
|
133
|
+
target = hook if not hook.exists() or "ContribOS" in hook.read_text() else hook.with_name("commit-msg.contribos")
|
|
134
|
+
target.write_text(COMMIT_MSG.format(dco=DCO_CHECK if s["dco"] else "", trailer=s["ai"]["disclosure_trailer"] or ""))
|
|
135
|
+
target.chmod(target.stat().st_mode | stat.S_IEXEC)
|
|
136
|
+
written.append(target.relative_to(root).as_posix())
|
|
137
|
+
marker = git_dir / "contribos-ai-used"
|
|
138
|
+
if ai_note:
|
|
139
|
+
marker.write_text(ai_note + "\n")
|
|
140
|
+
exclude = git_dir / "info" / "exclude"
|
|
141
|
+
exclude.parent.mkdir(exist_ok=True)
|
|
142
|
+
current = exclude.read_text() if exclude.exists() else ""
|
|
143
|
+
add = [p for p in (".claude/skills/contribos/", ".agents/skills/contribos/", ".contribos/",
|
|
144
|
+
".claude/settings.local.json") if p not in current]
|
|
145
|
+
if add:
|
|
146
|
+
exclude.write_text(current.rstrip("\n") + ("\n" if current else "") + "# ContribOS (local only)\n"
|
|
147
|
+
+ "\n".join(add) + "\n")
|
|
148
|
+
return written
|
contribos/bench.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""History benchmark: replay past merged changes and check whether the brief finds their files.
|
|
2
|
+
|
|
3
|
+
For each sampled change we rebuild the brief at its parent commit, using only
|
|
4
|
+
history older than the change, and the PR title as a stand-in for the issue.
|
|
5
|
+
Titles are shorter and more precise than real issues, so treat scores as an
|
|
6
|
+
upper-bound smoke test; the real benchmark uses linked issue text (needs the
|
|
7
|
+
GitHub API).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import random
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from . import brief as brief_mod
|
|
16
|
+
from .policy import is_code
|
|
17
|
+
from .precedent import Change
|
|
18
|
+
from .repo import Repo
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Case:
|
|
23
|
+
change: Change
|
|
24
|
+
code_files: list[str]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Result:
|
|
29
|
+
case: Case
|
|
30
|
+
full_hit: bool
|
|
31
|
+
full_recall: float
|
|
32
|
+
grep_hit: bool
|
|
33
|
+
grep_recall: float
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def sample(history: list[Change], n: int, seed: int = 7) -> list[Case]:
|
|
37
|
+
pool = []
|
|
38
|
+
for i, c in enumerate(history[:-50]): # keep older history as precedent
|
|
39
|
+
code = [f for f in c.files if is_code(f)]
|
|
40
|
+
if c.pr and 1 <= len(code) <= 4 and len(c.title.split()) >= 4 \
|
|
41
|
+
and not c.title.lower().startswith(("bump", "release", "merge", "revert", "update dependencies")):
|
|
42
|
+
pool.append(Case(c, code))
|
|
43
|
+
random.Random(seed).shuffle(pool)
|
|
44
|
+
return pool[:n]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _score(top: list[str], truth: list[str]) -> tuple[bool, float]:
|
|
48
|
+
found = len(set(top) & set(truth))
|
|
49
|
+
return found > 0, found / len(truth)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run(repo: Repo, history: list[Change], n: int = 20, k: int = 5, log=print) -> list[Result]:
|
|
53
|
+
results = []
|
|
54
|
+
order = {c.sha: i for i, c in enumerate(history)}
|
|
55
|
+
for case in sample(history, n):
|
|
56
|
+
c = case.change
|
|
57
|
+
parent = f"{c.sha}^1"
|
|
58
|
+
older = history[order[c.sha] + 1:]
|
|
59
|
+
full = brief_mod.build(repo, c.title, "", older, None, rev=parent, top=k)
|
|
60
|
+
grep = brief_mod.build(repo, c.title, "", [], None, rev=parent, top=k)
|
|
61
|
+
fh, fr = _score([h.path for h in full.files], case.code_files)
|
|
62
|
+
gh, gr = _score([h.path for h in grep.files], case.code_files)
|
|
63
|
+
results.append(Result(case, fh, fr, gh, gr))
|
|
64
|
+
log(f" #{c.pr} {'✓' if fh else '·'} {'✓' if gh else '·'} {_short(c.title, 70)}")
|
|
65
|
+
return results
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def summary(results: list[Result]) -> dict:
|
|
69
|
+
n = len(results) or 1
|
|
70
|
+
return {
|
|
71
|
+
"cases": len(results),
|
|
72
|
+
"brief_hit@5": sum(r.full_hit for r in results) / n,
|
|
73
|
+
"brief_recall@5": sum(r.full_recall for r in results) / n,
|
|
74
|
+
"grep_only_hit@5": sum(r.grep_hit for r in results) / n,
|
|
75
|
+
"grep_only_recall@5": sum(r.grep_recall for r in results) / n,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _short(text: str, n: int) -> str:
|
|
80
|
+
return text if len(text) <= n else text[:n - 1].rstrip() + "…"
|
contribos/brief.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Contribution brief: where to look, how this repo did it before, and what to watch for.
|
|
2
|
+
|
|
3
|
+
Ranking is deterministic and every file comes with the reasons it was picked:
|
|
4
|
+
term matches in the code, term matches in its path, and similar past changes
|
|
5
|
+
that touched it. No LLM is needed for the ranking itself.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
import re
|
|
12
|
+
from collections import Counter, defaultdict
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
from .precedent import Change
|
|
16
|
+
from .repo import Repo
|
|
17
|
+
from .policy import is_code, is_test, Policy
|
|
18
|
+
|
|
19
|
+
STOP = set("""
|
|
20
|
+
a an and are as at be been but by can could did do does doesn't don't for from get gets
|
|
21
|
+
got has have having how i if in into is isn't it it's its just like make makes may me might
|
|
22
|
+
more most my no not now of on one or our out should so some such than that the their them
|
|
23
|
+
then there these they this those to too up us use used using via was we were what when where
|
|
24
|
+
which while who why will with without would you your also only any all each other same very
|
|
25
|
+
issue bug fix fixed fixes problem error errors expected actual behavior behaviour steps
|
|
26
|
+
reproduce version works working work please thanks thank hi hello example code case cases
|
|
27
|
+
new need needs want run running returns return value true false none null
|
|
28
|
+
""".split())
|
|
29
|
+
SKIP_DIRS = ("node_modules/", "vendor/", "third_party/", "dist/", "build/", ".git/")
|
|
30
|
+
LOOKALIKE_DIRS = ("legacy", "deprecated", "compat", "examples", "example", "docs", "doc",
|
|
31
|
+
"migrations", "benchmarks", "vendor", "_vendor", "contrib")
|
|
32
|
+
IDENT = re.compile(r"`([^`\n]{2,80})`|([A-Za-z_][\w]*(?:\.[A-Za-z_]\w*)+)|"
|
|
33
|
+
r"\b([a-z]+_[a-z_0-9]+)\b|\b([A-Z][a-z0-9]+(?:[A-Z][a-z0-9]*)+)\b|"
|
|
34
|
+
r"\b(\w+(?:Error|Exception|Warning))\b")
|
|
35
|
+
PRECEDENT_WEIGHT = float(__import__("os").environ.get("CONTRIBOS_PW", "2"))
|
|
36
|
+
WORD = re.compile(r"[A-Za-z][A-Za-z0-9]{3,}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Term:
|
|
41
|
+
text: str
|
|
42
|
+
weight: float # identifiers count more than plain words
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class FileHit:
|
|
47
|
+
path: str
|
|
48
|
+
score: float = 0.0
|
|
49
|
+
reasons: list[str] = field(default_factory=list)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Brief:
|
|
54
|
+
repo: str
|
|
55
|
+
title: str
|
|
56
|
+
terms: list[Term]
|
|
57
|
+
files: list[FileHit]
|
|
58
|
+
tests: list[FileHit]
|
|
59
|
+
precedents: list[tuple[float, Change]]
|
|
60
|
+
lookalikes: list[tuple[str, str]]
|
|
61
|
+
questions: list[str]
|
|
62
|
+
scope_hint: str | None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def extract_terms(text: str, limit: int = 20) -> list[Term]:
|
|
66
|
+
idents: Counter[str] = Counter()
|
|
67
|
+
for m in IDENT.finditer(text):
|
|
68
|
+
tok = next(g for g in m.groups() if g)
|
|
69
|
+
tok = tok.strip().strip("()")
|
|
70
|
+
if len(tok) < 3 or " " in tok and len(tok.split()) > 3:
|
|
71
|
+
continue
|
|
72
|
+
idents[tok] += 1
|
|
73
|
+
# Also index the last part of dotted names (module.func -> func).
|
|
74
|
+
if "." in tok and not tok.endswith((".py", ".js", ".ts", ".md")):
|
|
75
|
+
idents[tok.rsplit(".", 1)[-1]] += 1
|
|
76
|
+
words = Counter(w.lower() for w in WORD.findall(text) if w.lower() not in STOP)
|
|
77
|
+
terms: dict[str, Term] = {}
|
|
78
|
+
for tok, n in idents.most_common():
|
|
79
|
+
if tok.lower() in STOP:
|
|
80
|
+
continue
|
|
81
|
+
terms.setdefault(tok.lower(), Term(tok, 2.0 + min(n, 3) * 0.3))
|
|
82
|
+
for w, n in words.most_common():
|
|
83
|
+
if w not in terms:
|
|
84
|
+
terms[w] = Term(w, 1.0 + min(n, 3) * 0.2)
|
|
85
|
+
return sorted(terms.values(), key=lambda t: -t.weight)[:limit]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _tokens(text: str) -> set[str]:
|
|
89
|
+
return {w.lower() for w in re.findall(r"[A-Za-z][A-Za-z0-9]{2,}", text)} - STOP
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _path_tokens(path: str) -> set[str]:
|
|
93
|
+
parts = re.split(r"[/_.\-]", path.lower())
|
|
94
|
+
return {p for p in parts if len(p) > 2}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def rank_precedents(terms: list[Term], history: list[Change], k: int = 8) -> list[tuple[float, Change]]:
|
|
98
|
+
"""BM25-style match of issue terms against past change titles and bodies."""
|
|
99
|
+
docs = [(c, _tokens(f"{c.title} {c.title} {c.body[:600]}")) for c in history]
|
|
100
|
+
n = len(docs) or 1
|
|
101
|
+
df: Counter[str] = Counter()
|
|
102
|
+
for _, toks in docs:
|
|
103
|
+
df.update(toks)
|
|
104
|
+
qterms = {}
|
|
105
|
+
for t in terms:
|
|
106
|
+
for tok in _tokens(t.text) | ({t.text.lower()} if "_" in t.text else set()):
|
|
107
|
+
qterms[tok] = max(qterms.get(tok, 0), t.weight)
|
|
108
|
+
scored = []
|
|
109
|
+
for c, toks in docs:
|
|
110
|
+
s = sum(w * math.log(1 + n / (1 + df[q])) for q, w in qterms.items() if q in toks)
|
|
111
|
+
if s > 0 and c.files:
|
|
112
|
+
scored.append((s / math.sqrt(1 + len(toks) / 20), c))
|
|
113
|
+
scored.sort(key=lambda x: -x[0])
|
|
114
|
+
return scored[:k]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def build(repo: Repo, title: str, body: str, history: list[Change], policy: Policy | None,
|
|
118
|
+
rev: str = "HEAD", top: int = 5) -> Brief:
|
|
119
|
+
terms = extract_terms(f"{title}\n{title}\n{body}")
|
|
120
|
+
all_files = [p for p in repo.files(rev) if not p.startswith(SKIP_DIRS)]
|
|
121
|
+
candidates = [p for p in all_files if is_code(p) or is_test(p)]
|
|
122
|
+
cand_set = set(candidates)
|
|
123
|
+
n_files = len(candidates) or 1
|
|
124
|
+
hits: dict[str, FileHit] = {}
|
|
125
|
+
|
|
126
|
+
def hit(path: str) -> FileHit:
|
|
127
|
+
return hits.setdefault(path, FileHit(path))
|
|
128
|
+
|
|
129
|
+
# 1. Content matches, weighted by how rare the term is in the repo.
|
|
130
|
+
for t in terms[:15]:
|
|
131
|
+
counts = {p: c for p, c in repo.grep(t.text, rev).items() if p in cand_set}
|
|
132
|
+
if not counts or len(counts) > n_files * 0.3:
|
|
133
|
+
continue # absent, or too common to say anything
|
|
134
|
+
idf = math.log(n_files / len(counts))
|
|
135
|
+
for p, c in counts.items():
|
|
136
|
+
h = hit(p)
|
|
137
|
+
h.score += t.weight * idf * math.log(1 + c)
|
|
138
|
+
if len(h.reasons) < 6:
|
|
139
|
+
h.reasons.append(f"mentions `{t.text}` ({c}×)")
|
|
140
|
+
|
|
141
|
+
# 2. Path matches.
|
|
142
|
+
for p in candidates:
|
|
143
|
+
ptoks = _path_tokens(p)
|
|
144
|
+
matched = [t.text for t in terms if t.text.lower() in ptoks]
|
|
145
|
+
if matched:
|
|
146
|
+
h = hit(p)
|
|
147
|
+
h.score += 2.0 * sum(1 for _ in matched)
|
|
148
|
+
h.reasons.append("path matches " + ", ".join(f"'{m}'" for m in matched[:3]))
|
|
149
|
+
|
|
150
|
+
# 3. Files touched by similar past changes.
|
|
151
|
+
precedents = rank_precedents(terms, history)
|
|
152
|
+
if precedents:
|
|
153
|
+
best = precedents[0][0]
|
|
154
|
+
for s, c in precedents:
|
|
155
|
+
for p in c.files:
|
|
156
|
+
if p in cand_set:
|
|
157
|
+
h = hit(p)
|
|
158
|
+
h.score += PRECEDENT_WEIGHT * s / best
|
|
159
|
+
label = f"PR #{c.pr}" if c.pr else c.sha[:8]
|
|
160
|
+
h.reasons.append(f"changed by similar {label}: “{_short(c.title, 70)}”")
|
|
161
|
+
|
|
162
|
+
ranked = sorted(hits.values(), key=lambda h: -h.score)
|
|
163
|
+
code = [h for h in ranked if is_code(h.path)][:top]
|
|
164
|
+
tests = _related_tests(code, ranked, history)
|
|
165
|
+
|
|
166
|
+
lookalikes = []
|
|
167
|
+
top_paths = {h.path for h in code}
|
|
168
|
+
for h in ranked[: top * 4]:
|
|
169
|
+
if h.path in top_paths or not is_code(h.path):
|
|
170
|
+
continue
|
|
171
|
+
dirs = set(h.path.lower().split("/")[:-1])
|
|
172
|
+
if dirs & set(LOOKALIKE_DIRS):
|
|
173
|
+
lookalikes.append((h.path, "matches the issue's words but lives in "
|
|
174
|
+
f"'{'/'.join(sorted(dirs & set(LOOKALIKE_DIRS)))}', which is rarely the place for a fix"))
|
|
175
|
+
elif all(r.startswith("path matches") for r in h.reasons):
|
|
176
|
+
lookalikes.append((h.path, "name looks related, but no code or past change links it to this issue"))
|
|
177
|
+
if len(lookalikes) >= 3:
|
|
178
|
+
break
|
|
179
|
+
|
|
180
|
+
scope_hint = None
|
|
181
|
+
sizes = sorted(len([f for f in c.files if is_code(f)]) for _, c in precedents[:5] if c.files)
|
|
182
|
+
if sizes:
|
|
183
|
+
median = sizes[len(sizes) // 2]
|
|
184
|
+
scope_hint = (f"Similar past changes touched a median of {median} code file"
|
|
185
|
+
f"{'s' if median != 1 else ''}. If yours grows well past that, expect questions about scope.")
|
|
186
|
+
|
|
187
|
+
return Brief(repo.slug, title, terms, code, tests, precedents[:5], lookalikes,
|
|
188
|
+
_questions(policy, precedents, scope_hint), scope_hint)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _related_tests(code: list[FileHit], ranked: list[FileHit], history: list[Change]) -> list[FileHit]:
|
|
192
|
+
"""Tests that co-change with the top files, or share their names."""
|
|
193
|
+
top = {h.path for h in code}
|
|
194
|
+
co: dict[str, Counter[str]] = defaultdict(Counter)
|
|
195
|
+
for c in history:
|
|
196
|
+
tfiles = [f for f in c.files if is_test(f)]
|
|
197
|
+
for f in c.files:
|
|
198
|
+
if f in top:
|
|
199
|
+
co[f].update(tfiles)
|
|
200
|
+
out: dict[str, FileHit] = {}
|
|
201
|
+
for rank, src in enumerate(h.path for h in code):
|
|
202
|
+
stem = src.rsplit("/", 1)[-1].rsplit(".", 1)[0].lower()
|
|
203
|
+
total = sum(co[src].values()) or 1
|
|
204
|
+
weight = 1 / (1 + rank) # tests of the top file matter most
|
|
205
|
+
for t, n in co[src].most_common(2):
|
|
206
|
+
h = out.setdefault(t, FileHit(t))
|
|
207
|
+
h.score += weight * n / total
|
|
208
|
+
h.reasons.append(f"changed together with `{src}` in {n} past change{'s' if n > 1 else ''}")
|
|
209
|
+
for h2 in ranked:
|
|
210
|
+
if is_test(h2.path) and stem in h2.path.lower() and len(stem) > 3:
|
|
211
|
+
h = out.setdefault(h2.path, FileHit(h2.path))
|
|
212
|
+
h.score += 1.5 * weight
|
|
213
|
+
h.reasons.append(f"named after `{src}`")
|
|
214
|
+
for h2 in ranked:
|
|
215
|
+
if is_test(h2.path) and h2.path not in out and len(out) < 4:
|
|
216
|
+
h = out.setdefault(h2.path, FileHit(h2.path, h2.score * 0.1))
|
|
217
|
+
h.reasons.extend(h2.reasons[:2])
|
|
218
|
+
return sorted(out.values(), key=lambda h: -h.score)[:4]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _questions(policy: Policy | None, precedents, scope_hint) -> list[str]:
|
|
222
|
+
q = ["What exactly was broken, and how does your test show it is fixed now?"]
|
|
223
|
+
if policy and (policy.get("Tests") or (policy.get("Tests in practice")
|
|
224
|
+
and "%" in policy.get("Tests in practice").verdict)):
|
|
225
|
+
q.append("Where is the regression test, and does it fail without your change?")
|
|
226
|
+
if scope_hint:
|
|
227
|
+
q.append("Why does this change need every file it touches?")
|
|
228
|
+
if precedents:
|
|
229
|
+
c = precedents[0][1]
|
|
230
|
+
ref = f"#{c.pr}" if c.pr else c.sha[:8]
|
|
231
|
+
q.append(f"How does this relate to {ref} (“{_short(c.title, 60)}”), and does it follow the same approach?")
|
|
232
|
+
if policy and policy.get("Changelog"):
|
|
233
|
+
q.append("Did you add a changelog entry?")
|
|
234
|
+
if policy and policy.get("AI policy") and "No written" not in policy.get("AI policy").verdict:
|
|
235
|
+
q.append("Did you use AI, and did you disclose it as the project asks?")
|
|
236
|
+
return q
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def to_markdown(b: Brief, policy: Policy | None, issue_url: str | None = None,
|
|
240
|
+
review_quotes: dict[int, list[str]] | None = None) -> str:
|
|
241
|
+
L = [f"# Contribution brief: {b.title}", ""]
|
|
242
|
+
L.append(f"Repository: `{b.repo}`" + (f" · Issue: {issue_url}" if issue_url else ""))
|
|
243
|
+
L.append("")
|
|
244
|
+
L.append("Search terms taken from the issue: " + ", ".join(f"`{t.text}`" for t in b.terms[:10]))
|
|
245
|
+
L += ["", "## Where to look", ""]
|
|
246
|
+
if not b.files:
|
|
247
|
+
L.append("No strong matches. The issue may need clarifying before you start; ask the maintainer.")
|
|
248
|
+
for i, h in enumerate(b.files, 1):
|
|
249
|
+
L.append(f"{i}. **`{h.path}`**")
|
|
250
|
+
for r in _dedupe(h.reasons)[:4]:
|
|
251
|
+
L.append(f" - {r}")
|
|
252
|
+
L += ["", "## How this repo handled similar changes", ""]
|
|
253
|
+
if not b.precedents:
|
|
254
|
+
L.append("No similar past changes found in recent history.")
|
|
255
|
+
for _, c in b.precedents:
|
|
256
|
+
ref = f"PR #{c.pr}" if c.pr else f"commit {c.sha[:8]}"
|
|
257
|
+
link = f"https://github.com/{b.repo}/pull/{c.pr}" if c.pr else f"https://github.com/{b.repo}/commit/{c.sha}"
|
|
258
|
+
code = [f for f in c.files if is_code(f)]
|
|
259
|
+
tests = [f for f in c.files if is_test(f)]
|
|
260
|
+
other = [f for f in c.files if f not in code and f not in tests]
|
|
261
|
+
L.append(f"- [{ref}]({link}): “{c.title}”. Touched {len(code)} code, {len(tests)} test and "
|
|
262
|
+
f"{len(other)} other file(s): "
|
|
263
|
+
+ ", ".join(f"`{f}`" for f in (code + tests + other)[:4]) + ("…" if len(c.files) > 4 else ""))
|
|
264
|
+
for quote in (review_quotes or {}).get(c.pr or -1, [])[:2]:
|
|
265
|
+
L.append(f" - Reviewer said: “{quote}”")
|
|
266
|
+
if b.scope_hint:
|
|
267
|
+
L += ["", f"**Scope:** {b.scope_hint}"]
|
|
268
|
+
L += ["", "## Tests to read and run", ""]
|
|
269
|
+
if not b.tests:
|
|
270
|
+
L.append("No related tests found. Ask where a test for this should live.")
|
|
271
|
+
for h in b.tests:
|
|
272
|
+
L.append(f"- `{h.path}`: " + "; ".join(_dedupe(h.reasons)[:2]))
|
|
273
|
+
if b.lookalikes:
|
|
274
|
+
L += ["", "## Probably don't touch", ""]
|
|
275
|
+
for p, why in b.lookalikes:
|
|
276
|
+
L.append(f"- `{p}`: {why}")
|
|
277
|
+
if policy:
|
|
278
|
+
L += ["", "## House rules that apply", ""]
|
|
279
|
+
for f in policy.findings:
|
|
280
|
+
src = f" ({f.evidence[0].source})" if f.evidence else ""
|
|
281
|
+
L.append(f"- **{f.topic}:** {f.verdict}{src}")
|
|
282
|
+
L += ["", "## Questions a reviewer will likely ask", ""]
|
|
283
|
+
L += [f"- {q}" for q in b.questions]
|
|
284
|
+
L += ["", "---", "_Every file above is listed with the evidence that selected it. "
|
|
285
|
+
"Check it yourself; ContribOS points, you decide._"]
|
|
286
|
+
return "\n".join(L)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _dedupe(items: list[str]) -> list[str]:
|
|
290
|
+
seen, out = set(), []
|
|
291
|
+
for i in items:
|
|
292
|
+
if i not in seen:
|
|
293
|
+
seen.add(i)
|
|
294
|
+
out.append(i)
|
|
295
|
+
return out
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _short(text: str, n: int) -> str:
|
|
299
|
+
return text if len(text) <= n else text[:n - 1].rstrip() + "…"
|