mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 02:54:26 +00:00
A person credited with a link in one place and a bare name in another reads as two different people and drops the reader's path to them. sys-svc-engine showed it in two adjacent lines: the same name once bare, once linked. The new credits-attribution lint requires a name linked in any Credits section to carry that link everywhere, and rejects one name pointing at two URLs. It found 13 offenders, including a second contributor whose attribution had drifted in test-e2e-cli. The generator produced the defect on every run: its context carried application_author but never application_author_url, so the template always took the unlinked branch. Existing READMEs kept their correct Credits because the generator only fills missing sections, which is why 239 roles were right and 12 wrong. The name to URL map is derived rather than declared: galaxy_info.company names the project owner, not the role author (web-app-mattermost is authored by Alejandro Roman Ibanez but lists Kevin Veen-Birkenbach as company), so only the Credits lines themselves carry the mapping. Generator and lint share that derivation and cannot drift apart, and a new contributor needs one linked credit rather than a code change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
119 lines
4.2 KiB
Python
119 lines
4.2 KiB
Python
"""Attribution recorded in markdown ``## Credits`` sections.
|
|
|
|
The link that belongs to a credited person lives nowhere but the Credits
|
|
lines themselves: ``galaxy_info.author`` carries the name without a URL, and
|
|
``galaxy_info.company`` names the project owner rather than the role's
|
|
author, so neither can supply it. This module derives the name -> URL map
|
|
from the linked mentions already present in the repository, which keeps the
|
|
README generator and the ``credits-attribution`` lint reading the same
|
|
source instead of a hand-maintained list.
|
|
|
|
Adding a contributor therefore needs no code change: one linked credit
|
|
anywhere makes the generator emit that link for every role they author.
|
|
|
|
Patterns exported here:
|
|
|
|
* ``LINKED_RE`` ``[Name](https://url)`` plus the bold markers a Credits
|
|
line wraps it in.
|
|
* ``BARE_BOLD_RE`` ``**Name**`` that is not itself a link's text.
|
|
* ``PERSON_RE`` a multi-word proper name whose parts carry no dot. The
|
|
dot is what separates people from the products and
|
|
projects that also appear in Credits lines
|
|
("Infinito.Nexus Project"). A person written with an
|
|
initial ("J. Smith") therefore falls out of enforcement
|
|
rather than into a false positive, which is the safe
|
|
direction for a lint.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from functools import lru_cache
|
|
from typing import TYPE_CHECKING
|
|
|
|
from utils.cache.files import iter_non_ignored_files, read_text
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
|
|
CREDITS_HEADING_RE = re.compile(r"^##+\s+Credits\s*$", re.IGNORECASE)
|
|
ANY_HEADING_RE = re.compile(r"^##+\s+")
|
|
|
|
LINKED_RE = re.compile(r"\*{0,2}\[([^\]\[]+)\]\((https?://[^)\s]+)\)\*{0,2}")
|
|
|
|
BARE_BOLD_RE = re.compile(r"(?<!\])\*\*([^*\]]+)\*\*(?!\()")
|
|
|
|
PERSON_RE = re.compile(r"^[A-Z][\w'-]*(?:\s+[A-Z][\w'-]*)+$")
|
|
|
|
|
|
def is_person(name: str) -> bool:
|
|
return bool(PERSON_RE.match(name.strip()))
|
|
|
|
|
|
def credits_lines(lines: list[str]) -> list[tuple[int, str]]:
|
|
"""``(1-based line number, text)`` for every line inside a ``## Credits``
|
|
section, ending at the next heading of any level.
|
|
|
|
Args:
|
|
lines: the file's lines, without trailing newlines.
|
|
"""
|
|
out: list[tuple[int, str]] = []
|
|
inside = False
|
|
for idx, raw in enumerate(lines, start=1):
|
|
if CREDITS_HEADING_RE.match(raw):
|
|
inside = True
|
|
continue
|
|
if inside and ANY_HEADING_RE.match(raw):
|
|
inside = False
|
|
continue
|
|
if inside:
|
|
out.append((idx, raw))
|
|
return out
|
|
|
|
|
|
def collect_author_urls(documents: list[list[str]]) -> dict[str, set[str]]:
|
|
"""Map every credited person to the set of URLs they are linked with.
|
|
|
|
A name with more than one URL is a conflict the caller decides about;
|
|
this function reports rather than resolves it.
|
|
|
|
Args:
|
|
documents: one entry per file, each the file's lines.
|
|
"""
|
|
urls: dict[str, set[str]] = {}
|
|
for lines in documents:
|
|
for _line_no, text in credits_lines(lines):
|
|
for name, url in LINKED_RE.findall(text):
|
|
if is_person(name):
|
|
urls.setdefault(name.strip(), set()).add(url)
|
|
return urls
|
|
|
|
|
|
def iter_markdown_documents() -> Iterator[tuple[str, list[str]]]:
|
|
"""Yield ``(path, lines)`` for every tracked markdown file.
|
|
|
|
Files the process cannot read are skipped rather than raised: this walk
|
|
backs the README build, and an unreadable agent scratch file such as
|
|
``.claude/loop.md`` must not abort generating documentation.
|
|
"""
|
|
for path in iter_non_ignored_files(extensions=(".md",)):
|
|
try:
|
|
yield path, read_text(path).splitlines()
|
|
except OSError:
|
|
continue
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def author_urls() -> dict[str, str]:
|
|
"""Name -> URL for every person credited with exactly one URL repo-wide.
|
|
|
|
Names linked to conflicting URLs are omitted, so an ambiguous mapping
|
|
never silently picks a winner.
|
|
"""
|
|
documents = [lines for _path, lines in iter_markdown_documents()]
|
|
return {
|
|
name: next(iter(seen))
|
|
for name, seen in collect_author_urls(documents).items()
|
|
if len(seen) == 1
|
|
}
|