linecite 0.1.1__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.
- linecite/__init__.py +3 -0
- linecite/adopt.py +195 -0
- linecite/affected.py +198 -0
- linecite/audit.py +359 -0
- linecite/cli.py +333 -0
- linecite/config.py +128 -0
- linecite/errors.py +6 -0
- linecite/history.py +144 -0
- linecite/repo.py +323 -0
- linecite/scan.py +442 -0
- linecite/spec.py +255 -0
- linecite-0.1.1.dist-info/METADATA +205 -0
- linecite-0.1.1.dist-info/RECORD +16 -0
- linecite-0.1.1.dist-info/WHEEL +4 -0
- linecite-0.1.1.dist-info/entry_points.txt +2 -0
- linecite-0.1.1.dist-info/licenses/LICENSE +21 -0
linecite/__init__.py
ADDED
linecite/adopt.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Convert number-only citations into citations that name their code, using the history `audit` reads.
|
|
2
|
+
|
|
3
|
+
Markdown gets the link form; every other document a hidden anchor right after the number:
|
|
4
|
+
|
|
5
|
+
orders.py:10 -> [orders.py:12](../app/orders.py#L12 "OrderService.create_order: row.lock()")
|
|
6
|
+
orders.py:10 -> orders.py:12<!--@ app/orders.py::OrderService.create_order `row.lock()` -->
|
|
7
|
+
|
|
8
|
+
The number written into the new citation is where the cited line is now by audit's trace — never
|
|
9
|
+
line N of today's code, which would freeze a number that is already stale. Only ok and stale
|
|
10
|
+
citations are converted; gone, unknown and unverifiable ones are left for a human. Every proposal
|
|
11
|
+
is read back through `extract` and resolved before it is offered, so an applied proposal passes `check`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from .audit import OK, STALE, Traced, Tracer, number_only, renumber
|
|
22
|
+
from .config import Config
|
|
23
|
+
from .errors import RefError
|
|
24
|
+
from .repo import Repo
|
|
25
|
+
from .scan import MARKDOWN_SUFFIXES, Syntax, code_spans, extract, line_of
|
|
26
|
+
from .spec import describe, resolve
|
|
27
|
+
|
|
28
|
+
# [bracketed text] on one line, one level of nesting: link texts (inline or reference-style) and the like
|
|
29
|
+
BRACKETS_RE = re.compile(r"\[(?:[^\[\]\n]|\[[^\[\]\n]*\])*\]")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Proposal:
|
|
34
|
+
traced: Traced
|
|
35
|
+
start: int
|
|
36
|
+
end: int
|
|
37
|
+
new: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class DocPlan:
|
|
42
|
+
"""What adopt would do to one document."""
|
|
43
|
+
|
|
44
|
+
path: Path
|
|
45
|
+
text: str
|
|
46
|
+
proposals: list[Proposal] = field(default_factory=list)
|
|
47
|
+
left: list[tuple[Traced, str]] = field(default_factory=list) # (citation, why)
|
|
48
|
+
|
|
49
|
+
def converted(self) -> str:
|
|
50
|
+
out, last = [], 0
|
|
51
|
+
for p in sorted(self.proposals, key=lambda p: p.start):
|
|
52
|
+
out += [self.text[last : p.start], p.new]
|
|
53
|
+
last = p.end
|
|
54
|
+
out.append(self.text[last:])
|
|
55
|
+
return "".join(out)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def link_url(doc: Path, repo: Repo, path: str) -> str:
|
|
59
|
+
"""How a link from DOC names PATH: relative when the doc lives in the code repo, else from the code root."""
|
|
60
|
+
doc = doc.resolve()
|
|
61
|
+
if not doc.is_relative_to(repo.root):
|
|
62
|
+
return path
|
|
63
|
+
rel = Path(os.path.relpath(repo.root / path, doc.parent)).as_posix()
|
|
64
|
+
kept = "/".join(p for p in rel.split("/") if p not in ("", ".", ".."))
|
|
65
|
+
return (
|
|
66
|
+
rel if kept == path else "/" + path
|
|
67
|
+
) # links resolve by suffix; keep the whole path
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def title_attr(title: str, in_table: bool = False) -> str:
|
|
71
|
+
title = title.replace("\\", "\\\\").replace('"', '\\"')
|
|
72
|
+
if in_table: # an unescaped | ends a table cell, even inside a link title
|
|
73
|
+
title = title.replace("|", "\\|")
|
|
74
|
+
return f'"{title}"'
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _line_at(text: str, pos: int) -> str:
|
|
78
|
+
start = text.rfind("\n", 0, pos) + 1
|
|
79
|
+
end = text.find("\n", pos)
|
|
80
|
+
return text[start : end if end >= 0 else len(text)]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class _Doc:
|
|
85
|
+
path: Path
|
|
86
|
+
text: str
|
|
87
|
+
brackets: list[tuple[int, int]] # [bracketed] spans: text of other links
|
|
88
|
+
code: list[tuple[int, int]] # markdown inline `code` spans
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def of(cls, path: Path, text: str) -> _Doc:
|
|
92
|
+
markdown = path.suffix.lower() in MARKDOWN_SUFFIXES
|
|
93
|
+
return cls(
|
|
94
|
+
path,
|
|
95
|
+
text,
|
|
96
|
+
[m.span() for m in BRACKETS_RE.finditer(text)] if markdown else [],
|
|
97
|
+
code_spans(text) if markdown else [],
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _check(
|
|
102
|
+
d: _Doc,
|
|
103
|
+
start: int,
|
|
104
|
+
end: int,
|
|
105
|
+
new: str,
|
|
106
|
+
syntax: Syntax,
|
|
107
|
+
repo: Repo,
|
|
108
|
+
want: tuple[int, int],
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Read the document back with the replacement in place, as `check` will: the replaced region must
|
|
111
|
+
hold exactly one link or anchor, resolving to WANT with nothing to sync. In place matters —
|
|
112
|
+
`!` before a link makes it an image, a surrounding code span makes it literal text."""
|
|
113
|
+
after = d.text[:start] + new + d.text[end:]
|
|
114
|
+
lo, hi = start, start + len(new)
|
|
115
|
+
cites = [
|
|
116
|
+
c for c in extract(after, d.path, syntax)[0] if c.start < hi and lo < c.end
|
|
117
|
+
]
|
|
118
|
+
if len(cites) != 1 or cites[0].kind not in ("link", "anchor") or cites[0].error:
|
|
119
|
+
raise RefError("the converted citation would not read back as a citation here")
|
|
120
|
+
_, a, b = resolve(repo, cites[0].spec)
|
|
121
|
+
if (a, b) != want or any(s.render(a, b) != s.old for s in cites[0].slots):
|
|
122
|
+
raise RefError(
|
|
123
|
+
f"the converted citation would resolve to {a}-{b}, not {want[0]}-{want[1]}"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _within(pos: int, spans: list[tuple[int, int]]) -> tuple[int, int] | None:
|
|
128
|
+
return next(((s, e) for s, e in spans if s <= pos < e), None)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def propose(d: _Doc, t: Traced, repo: Repo, syntax: Syntax) -> Proposal:
|
|
132
|
+
"""The replacement for one traced citation; RefError says why there is none."""
|
|
133
|
+
if t.status not in (OK, STALE):
|
|
134
|
+
raise RefError(t.note or t.status)
|
|
135
|
+
if len(t.spans) != 1:
|
|
136
|
+
raise RefError("a list of lines: give each line its own citation first")
|
|
137
|
+
(a, b), c, text = t.spans[0], t.citation, d.text
|
|
138
|
+
naming = describe(repo, t.path, a, b)
|
|
139
|
+
frag = f"#L{a}" if a == b else f"#L{a}-L{b}"
|
|
140
|
+
title = title_attr(naming.title(), _line_at(text, c.start).lstrip().startswith("|"))
|
|
141
|
+
start, end = c.start, c.end
|
|
142
|
+
|
|
143
|
+
if c.kind == "bare-link":
|
|
144
|
+
# the author's own text and url, renumbered; the url is replaced only if the file moved
|
|
145
|
+
body, _, url = renumber(text, c, t.spans)[1:].partition("](")
|
|
146
|
+
url = url.partition("#")[0]
|
|
147
|
+
try:
|
|
148
|
+
same_file = repo.resolve_path(c.cited.path, None) == t.path
|
|
149
|
+
except RefError:
|
|
150
|
+
same_file = False
|
|
151
|
+
if not same_file:
|
|
152
|
+
url = link_url(d.path, repo, t.path)
|
|
153
|
+
new = f"[{body}]({url}{frag} {title})"
|
|
154
|
+
elif d.path.suffix.lower() in MARKDOWN_SUFFIXES:
|
|
155
|
+
if _within(c.start, d.brackets):
|
|
156
|
+
raise RefError("inside [brackets], such as another link's text")
|
|
157
|
+
# a link inside inline code renders as literal text: the whole code span becomes the link text
|
|
158
|
+
start, end = _within(c.start, d.code) or (start, end)
|
|
159
|
+
label = text[start : c.start] + t.now + text[c.end : end]
|
|
160
|
+
new = f"[{label}]({link_url(d.path, repo, t.path)}{frag} {title})"
|
|
161
|
+
else:
|
|
162
|
+
spec = naming.spec()
|
|
163
|
+
if "-->" in spec:
|
|
164
|
+
raise RefError("the quoted code contains `-->`, which would end the anchor")
|
|
165
|
+
new = f"{t.now}<!--@ {spec} -->"
|
|
166
|
+
_check(d, start, end, new, syntax, repo, (a, b))
|
|
167
|
+
return Proposal(t, start, end, new)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def plan(files: list[Path], repo: Repo, cfg: Config) -> list[DocPlan]:
|
|
171
|
+
tracer = Tracer(repo)
|
|
172
|
+
syntax = Syntax(cfg)
|
|
173
|
+
docs = number_only(files, syntax)
|
|
174
|
+
tracer.prefetch([(h, [line_of(text, c.start) for c in cs]) for h, text, cs in docs])
|
|
175
|
+
plans = []
|
|
176
|
+
for history, text, cites in docs:
|
|
177
|
+
d = _Doc.of(history.path, text)
|
|
178
|
+
p = DocPlan(history.path, text)
|
|
179
|
+
for c in cites:
|
|
180
|
+
t = tracer.trace(history, text, c)
|
|
181
|
+
try:
|
|
182
|
+
prop = propose(d, t, repo, syntax)
|
|
183
|
+
if any(q.start < prop.end and prop.start < q.end for q in p.proposals):
|
|
184
|
+
raise RefError("shares its inline code with another citation")
|
|
185
|
+
p.proposals.append(prop)
|
|
186
|
+
except RefError as e:
|
|
187
|
+
p.left.append((t, str(e)))
|
|
188
|
+
plans.append(p)
|
|
189
|
+
return plans
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def write(p: DocPlan) -> None:
|
|
193
|
+
p.path.write_bytes(
|
|
194
|
+
p.converted().encode("utf-8")
|
|
195
|
+
) # bytes: keep the file's own line endings
|
linecite/affected.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""After a code change, list the doc lines whose citations point into the changed code.
|
|
2
|
+
|
|
3
|
+
`sync` keeps the numbers right; whether the prose around them is still true needs a human.
|
|
4
|
+
This narrows the re-read to the citations whose code actually changed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import codecs
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .config import Config
|
|
16
|
+
from .errors import RefError
|
|
17
|
+
from .repo import Repo
|
|
18
|
+
from .scan import LINK_RE, Syntax, display, extract, line_of
|
|
19
|
+
from .spec import format_quote, parse_target, resolve
|
|
20
|
+
|
|
21
|
+
HUNK_RE = re.compile(r"\+(\d+)(?:,(\d+))?")
|
|
22
|
+
# what a reader sees of a doc line: links reduced to their text, anchors dropped
|
|
23
|
+
ANCHOR_RE = re.compile(r"<!--@.*?-->")
|
|
24
|
+
EXCERPT_CHARS = 120
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def require_commit(repo: Repo, rev: str) -> None:
|
|
28
|
+
"""Fail with the fix when REV (or an end of A..B / A...B) is missing — in CI, usually a shallow clone."""
|
|
29
|
+
for end in filter(None, re.split(r"\.\.\.?", rev, maxsplit=1)):
|
|
30
|
+
try:
|
|
31
|
+
repo.git(
|
|
32
|
+
"rev-parse",
|
|
33
|
+
"--verify",
|
|
34
|
+
"--quiet",
|
|
35
|
+
"--end-of-options",
|
|
36
|
+
end + "^{commit}",
|
|
37
|
+
)
|
|
38
|
+
except RefError:
|
|
39
|
+
hint = ""
|
|
40
|
+
if repo.git("rev-parse", "--is-shallow-repository").strip() == "true":
|
|
41
|
+
name = end.removeprefix(
|
|
42
|
+
"origin/"
|
|
43
|
+
) # the refspec names the remote's branch
|
|
44
|
+
hint = (
|
|
45
|
+
f"; this is a shallow clone: fetch it (git fetch --depth=1 origin {name})"
|
|
46
|
+
" or check out full history (actions/checkout with fetch-depth: 0)"
|
|
47
|
+
)
|
|
48
|
+
raise RefError(f"{end!r} is not a commit in {repo.root}{hint}") from None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _diff_path(line: str) -> str | None:
|
|
52
|
+
"""The new-side path of a `+++ ` line; git C-quotes paths with unusual characters."""
|
|
53
|
+
path = line[4:]
|
|
54
|
+
if path.startswith('"') and path.endswith('"'):
|
|
55
|
+
path = codecs.escape_decode(path[1:-1])[0].decode("utf-8", "replace")
|
|
56
|
+
return path[2:] if path.startswith("b/") else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def changed_lines(repo: Repo, rev: str) -> dict[str, set[int]]:
|
|
60
|
+
"""Working-tree line numbers changed since REV, per file (committed, staged and unstaged edits).
|
|
61
|
+
|
|
62
|
+
REV may be a range (A..B, A...B): then git compares commits, not the working tree.
|
|
63
|
+
"""
|
|
64
|
+
require_commit(repo, rev)
|
|
65
|
+
out: dict[str, set[int]] = {}
|
|
66
|
+
current = None
|
|
67
|
+
for line in repo.git(
|
|
68
|
+
"-c",
|
|
69
|
+
"core.quotepath=false",
|
|
70
|
+
"diff",
|
|
71
|
+
"-U0",
|
|
72
|
+
"--no-color",
|
|
73
|
+
"--relative",
|
|
74
|
+
rev,
|
|
75
|
+
"--",
|
|
76
|
+
).splitlines():
|
|
77
|
+
if line.startswith("+++ "):
|
|
78
|
+
current = _diff_path(line)
|
|
79
|
+
elif line.startswith("@@") and current:
|
|
80
|
+
m = HUNK_RE.search(line)
|
|
81
|
+
start, count = int(m.group(1)), int(m.group(2) or 1)
|
|
82
|
+
if count == 0: # pure deletion between line `start` and `start + 1`
|
|
83
|
+
out.setdefault(current, set()).update((start, start + 1))
|
|
84
|
+
else:
|
|
85
|
+
out.setdefault(current, set()).update(range(start, start + count))
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class Hit:
|
|
91
|
+
doc: str
|
|
92
|
+
line: int
|
|
93
|
+
path: str
|
|
94
|
+
span: tuple[int, int]
|
|
95
|
+
touched: list[int]
|
|
96
|
+
error: str | None = None
|
|
97
|
+
source: Path | None = None # the document
|
|
98
|
+
excerpt: str = "" # its citing line, as a reader sees it
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def affected(files: list[Path], repo: Repo, cfg: Config, rev: str) -> list[Hit]:
|
|
102
|
+
changed = changed_lines(repo, rev)
|
|
103
|
+
syntax = Syntax(cfg)
|
|
104
|
+
hits: list[Hit] = []
|
|
105
|
+
for f in files:
|
|
106
|
+
text = f.read_bytes().decode("utf-8")
|
|
107
|
+
cites, _ = extract(text, f, syntax)
|
|
108
|
+
seen = set()
|
|
109
|
+
for c in cites:
|
|
110
|
+
spec = c.spec
|
|
111
|
+
if (
|
|
112
|
+
c.kind not in ("anchor", "link", "symref")
|
|
113
|
+
or c.error
|
|
114
|
+
or spec.startswith("@")
|
|
115
|
+
):
|
|
116
|
+
continue # not code, malformed (check reports it), or pinned history that cannot change
|
|
117
|
+
ln = line_of(text, c.start)
|
|
118
|
+
where = {"source": f, "excerpt": excerpt(text, c.start)}
|
|
119
|
+
try:
|
|
120
|
+
target, rest = parse_target(repo, spec)
|
|
121
|
+
# the whole enclosing symbol counts: a change next to the quoted line can change its meaning;
|
|
122
|
+
# without a symbol only the quoted lines count — a whole-file span would flag every change
|
|
123
|
+
a, b = target.span
|
|
124
|
+
if "::" not in spec and rest:
|
|
125
|
+
_, a, b = resolve(repo, spec)
|
|
126
|
+
except RefError as e:
|
|
127
|
+
hits.append(Hit(display(f), ln, "", (0, 0), [], str(e), **where))
|
|
128
|
+
continue
|
|
129
|
+
touched = sorted(n for n in changed.get(target.path, ()) if a <= n <= b)
|
|
130
|
+
key = (ln, target.path, a, b)
|
|
131
|
+
if touched and key not in seen:
|
|
132
|
+
seen.add(key)
|
|
133
|
+
hits.append(Hit(display(f), ln, target.path, (a, b), touched, **where))
|
|
134
|
+
return hits
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def excerpt(text: str, pos: int) -> str:
|
|
138
|
+
"""The document line holding POS, as a reader sees it, cut to EXCERPT_CHARS."""
|
|
139
|
+
start = text.rfind("\n", 0, pos) + 1
|
|
140
|
+
end = text.find("\n", pos)
|
|
141
|
+
line = text[start : end if end >= 0 else len(text)]
|
|
142
|
+
line = " ".join(ANCHOR_RE.sub("", LINK_RE.sub(r"\g<text>", line)).split())
|
|
143
|
+
return line if len(line) <= EXCERPT_CHARS else line[: EXCERPT_CHARS - 1] + "…"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _lines(ns: list[int] | tuple[int, int]) -> str:
|
|
147
|
+
a, b = ns[0], ns[-1]
|
|
148
|
+
return str(a) if a == b else f"{a}-{b}"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _runs(ns: list[int], most: int = 4) -> str:
|
|
152
|
+
"""Sorted line numbers as runs: `7, 11`, `10-12, 20`; more than MOST runs end in a count."""
|
|
153
|
+
runs: list[list[int]] = []
|
|
154
|
+
for n in ns:
|
|
155
|
+
if runs and n == runs[-1][-1] + 1:
|
|
156
|
+
runs[-1].append(n)
|
|
157
|
+
else:
|
|
158
|
+
runs.append([n])
|
|
159
|
+
shown = ", ".join(_lines(r) for r in runs[:most])
|
|
160
|
+
return shown if len(runs) <= most else f"{shown}, … ({len(ns)} lines)"
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def markdown(
|
|
164
|
+
hits: list[Hit], rev: str, link: Callable[[Hit], str | None] = lambda h: None
|
|
165
|
+
) -> str:
|
|
166
|
+
"""The re-read list as markdown, for a pull request comment or a CI summary.
|
|
167
|
+
|
|
168
|
+
LINK gives a URL for a hit's doc line, when there is one to give.
|
|
169
|
+
"""
|
|
170
|
+
if re.fullmatch(r"[0-9a-f]{40}", rev):
|
|
171
|
+
rev = rev[:12]
|
|
172
|
+
if not hits:
|
|
173
|
+
return f"No doc citation points into code changed since `{rev}`.\n"
|
|
174
|
+
found = [h for h in hits if not h.error]
|
|
175
|
+
out = [
|
|
176
|
+
f"**{len(found)} doc citation(s) point into code changed since `{rev}`.** "
|
|
177
|
+
"Re-read the sentences around them: `linecite sync` keeps line numbers right, "
|
|
178
|
+
"not what the prose says about the code."
|
|
179
|
+
]
|
|
180
|
+
by_doc: dict[str, list[Hit]] = {}
|
|
181
|
+
for h in found:
|
|
182
|
+
by_doc.setdefault(h.doc, []).append(h)
|
|
183
|
+
for doc, rows in by_doc.items():
|
|
184
|
+
out.append(f"\n`{doc}`\n")
|
|
185
|
+
for h in rows:
|
|
186
|
+
url = link(h)
|
|
187
|
+
where = f"[line {h.line}]({url})" if url else f"line {h.line}"
|
|
188
|
+
out.append(
|
|
189
|
+
f"- {where}: {format_quote(h.excerpt)}"
|
|
190
|
+
f" — cites `{h.path}:{_lines(h.span)}`, changed {_runs(h.touched)}"
|
|
191
|
+
)
|
|
192
|
+
broken = [h for h in hits if h.error]
|
|
193
|
+
if broken:
|
|
194
|
+
out.append(
|
|
195
|
+
"\nCitations that no longer resolve (`linecite check` reports them):\n"
|
|
196
|
+
)
|
|
197
|
+
out += [f"- `{h.doc}:{h.line}`: {format_quote(h.error)}" for h in broken]
|
|
198
|
+
return "\n".join(out) + "\n"
|