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 ADDED
@@ -0,0 +1,3 @@
1
+ """linecite — code citations in docs that re-derive their line numbers from the source."""
2
+
3
+ __version__ = "0.1.1"
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"