openkos 0.1.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.
Files changed (59) hide show
  1. openkos/__init__.py +1 -0
  2. openkos/bundle/__init__.py +4 -0
  3. openkos/bundle/bundle.py +28 -0
  4. openkos/bundle/index.py +229 -0
  5. openkos/bundle/links.py +274 -0
  6. openkos/bundle/log.py +162 -0
  7. openkos/bundle/merge.py +195 -0
  8. openkos/bundle/provenance.py +98 -0
  9. openkos/bundle/references.py +170 -0
  10. openkos/bundle/relations.py +229 -0
  11. openkos/cli/__init__.py +1 -0
  12. openkos/cli/main.py +4853 -0
  13. openkos/cli/observability.py +66 -0
  14. openkos/config.py +410 -0
  15. openkos/extraction/__init__.py +8 -0
  16. openkos/extraction/concept.py +231 -0
  17. openkos/fsio.py +97 -0
  18. openkos/graph/__init__.py +13 -0
  19. openkos/graph/analysis.py +39 -0
  20. openkos/graph/base.py +57 -0
  21. openkos/graph/sqlite_graph.py +439 -0
  22. openkos/lifecycle.py +98 -0
  23. openkos/lint.py +451 -0
  24. openkos/llm/__init__.py +9 -0
  25. openkos/llm/base.py +42 -0
  26. openkos/llm/ollama.py +336 -0
  27. openkos/llm/parsing.py +95 -0
  28. openkos/model/__init__.py +4 -0
  29. openkos/model/okf.py +1039 -0
  30. openkos/model/relations.py +68 -0
  31. openkos/model/types.py +88 -0
  32. openkos/py.typed +0 -0
  33. openkos/resolution/__init__.py +22 -0
  34. openkos/resolution/adjudication.py +269 -0
  35. openkos/resolution/candidates.py +195 -0
  36. openkos/resolution/contradiction.py +438 -0
  37. openkos/resolution/edge_typing.py +291 -0
  38. openkos/resolution/normalize.py +29 -0
  39. openkos/resolution/similarity.py +85 -0
  40. openkos/resolution/volatility_typing.py +281 -0
  41. openkos/retrieval/__init__.py +7 -0
  42. openkos/retrieval/answer.py +485 -0
  43. openkos/retrieval/fusion.py +81 -0
  44. openkos/retrieval/graph_retrieve.py +77 -0
  45. openkos/retrieval/pool.py +20 -0
  46. openkos/sensitivity.py +123 -0
  47. openkos/state/__init__.py +4 -0
  48. openkos/state/derived.py +227 -0
  49. openkos/state/fts.py +351 -0
  50. openkos/state/reindex.py +323 -0
  51. openkos/state/vectorstore.py +465 -0
  52. openkos/templates/agents.md.template +50 -0
  53. openkos/templates/openkos.yaml.template +15 -0
  54. openkos/vcs/__init__.py +6 -0
  55. openkos/vcs/git.py +512 -0
  56. openkos-0.1.0.dist-info/METADATA +176 -0
  57. openkos-0.1.0.dist-info/RECORD +59 -0
  58. openkos-0.1.0.dist-info/WHEEL +4 -0
  59. openkos-0.1.0.dist-info/entry_points.txt +3 -0
openkos/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """openkos: local-first engine that compiles text into a portable knowledge base."""
@@ -0,0 +1,4 @@
1
+ """Canonical OKF bundle: the rendered and written shape of `bundle/`.
2
+
3
+ Never imports from the derived layer (`retrieval`, `graph`, `memory`).
4
+ """
@@ -0,0 +1,28 @@
1
+ """Writes the OKF bundle root: `index.md` and `log.md`.
2
+
3
+ The only module in `bundle/` that touches the filesystem — `index.py` and
4
+ `log.py` stay pure renderers.
5
+ """
6
+
7
+ from datetime import date
8
+ from pathlib import Path
9
+
10
+ from openkos import fsio
11
+ from openkos.bundle.index import render_index
12
+ from openkos.bundle.log import render_log
13
+
14
+
15
+ def create(bundle_dir: Path, today: date) -> None:
16
+ """Create a fresh bundle at `bundle_dir`: `index.md` then `log.md`.
17
+
18
+ Both files are written with exclusive-create mode ("x"): a colliding
19
+ file raises `FileExistsError` instead of being overwritten (D2). This
20
+ closes the Phase-A -> B TOCTOU window only for these two leaf files —
21
+ NOT for `bundle_dir.mkdir(parents=True, exist_ok=True)` above, where a
22
+ racing writer adding a non-colliding file is silently absorbed (known,
23
+ accepted limitation, D2). No cleanup path on a mid-write failure (D3): a
24
+ partially written bundle is left as-is for manual recovery.
25
+ """
26
+ bundle_dir.mkdir(parents=True, exist_ok=True)
27
+ fsio.write_exclusive(bundle_dir / "index.md", render_index())
28
+ fsio.write_exclusive(bundle_dir / "log.md", render_log(today))
@@ -0,0 +1,229 @@
1
+ """Renders the bytes of a fresh bundle's root `index.md`, and appends to it."""
2
+
3
+ import re
4
+ from pathlib import PurePosixPath
5
+
6
+ from openkos.model import okf
7
+ from openkos.model.types import CANONICAL_SECTION_ORDER as _CANONICAL_SECTION_ORDER
8
+
9
+
10
+ def render_index() -> str:
11
+ """Render a fresh root `index.md`: OKF version frontmatter, empty body."""
12
+ return okf.dump_frontmatter({"okf_version": okf.OKF_VERSION})
13
+
14
+
15
+ _FRONTMATTER_RE = re.compile(r"\A---\n.*?\n---\n", re.DOTALL)
16
+ _SECTION_SPLIT_RE = re.compile(r"\n(?=# )")
17
+ _SECTION_HEADER_RE = re.compile(r"\A# (.+)\n")
18
+
19
+
20
+ def _split_frontmatter_verbatim(text: str) -> tuple[str, str]:
21
+ """Split `text` into its frontmatter block (kept byte-for-byte) and body.
22
+
23
+ Never re-parses and re-dumps the frontmatter block through
24
+ `dump_frontmatter`/`frontmatter.Post` -- doing so risks reformatting a
25
+ quoting choice like `okf_version: '0.1'` (D2). Raises `ValueError` if
26
+ `text` does not start with a `---`-delimited block.
27
+ """
28
+ match = _FRONTMATTER_RE.match(text)
29
+ if match is None:
30
+ raise ValueError("index.md: missing or malformed frontmatter block")
31
+ return match.group(0), text[match.end() :]
32
+
33
+
34
+ def _section_header(chunk: str) -> str:
35
+ """Return the header text of a `# `-headed section chunk."""
36
+ match = _SECTION_HEADER_RE.match(chunk)
37
+ if match is None:
38
+ raise ValueError(f"index.md: malformed section chunk {chunk!r}")
39
+ return match.group(1)
40
+
41
+
42
+ def _reject_newline(field: str, value: str) -> None:
43
+ """Raise `ValueError` if `value` contains a newline (RISK-1).
44
+
45
+ `title`/`slug`/`description` are interpolated verbatim into the
46
+ rendered bullet with no escaping. A value containing a newline followed
47
+ by `# ` or `## ` could forge a section header the next time the file is
48
+ re-parsed. Every one of these fields is inherently single-line for a
49
+ single Source concept, so rejecting is simpler and safer than escaping.
50
+ """
51
+ if "\n" in value or "\r" in value:
52
+ raise ValueError(f"index.md: {field!r} must not contain a newline")
53
+
54
+
55
+ # `_CANONICAL_SECTION_ORDER` is now derived from
56
+ # `openkos.model.types.REGISTRY` -- see that module for the single source of
57
+ # truth.
58
+
59
+
60
+ def insert_index_entry(
61
+ index_text: str,
62
+ *,
63
+ section: str,
64
+ link_dir: str,
65
+ title: str,
66
+ slug: str,
67
+ description: str,
68
+ ) -> str:
69
+ """Insert a new bullet into `index_text`'s `# {section}` section (D2, #4).
70
+
71
+ Generalizes `insert_source_entry` to any of the canonical catalog
72
+ sections. Pure body-only edit: the frontmatter block is split off and
73
+ kept byte-for-byte verbatim, and every existing section round-trips
74
+ byte-for-byte except for the inserted bullet. `# {section}` is located
75
+ if present, and the bullet is appended to it. If absent, a fresh
76
+ `# {section}` chunk is created and inserted at its CANONICAL rank --
77
+ `_CANONICAL_SECTION_ORDER = (Concepts, Entities, Places, Events,
78
+ Procedures, Decisions, People, Organizations, Sources)` -- i.e.
79
+ immediately before the first EXISTING section whose rank is greater, or
80
+ at the end of the body if no such section exists. `Sources` is always
81
+ last in that order, so a fresh `# Sources` section is always appended
82
+ after every other existing section, regardless of which of the other
83
+ eight currently exist -- preserving the historical Sources-last behavior
84
+ byte-identically.
85
+ `title`/`slug`/`description` are each rejected (`ValueError`) if they
86
+ contain a newline (RISK-1) -- see `_reject_newline`. This guard applies
87
+ to every section, including untrusted, LLM-derived object fields.
88
+ `section` MUST be one of the canonical sections, else `ValueError` --
89
+ there is no defined rank for an unknown section.
90
+ """
91
+ if section not in _CANONICAL_SECTION_ORDER:
92
+ raise ValueError(
93
+ f"section must be one of {list(_CANONICAL_SECTION_ORDER)}, got {section!r}"
94
+ )
95
+ _reject_newline("title", title)
96
+ _reject_newline("slug", slug)
97
+ _reject_newline("description", description)
98
+ frontmatter_block, body = _split_frontmatter_verbatim(index_text)
99
+ chunks = _SECTION_SPLIT_RE.split(body)
100
+ preamble, section_chunks = chunks[0], chunks[1:]
101
+
102
+ bullet = f"* [{title}](/{link_dir}/{slug}.md) - {description}\n"
103
+ headers = [_section_header(chunk) for chunk in section_chunks]
104
+
105
+ if section in headers:
106
+ section_index = headers.index(section)
107
+ section_chunks[section_index] = section_chunks[section_index] + bullet
108
+ else:
109
+ target_rank = _CANONICAL_SECTION_ORDER.index(section)
110
+ insert_at = len(section_chunks)
111
+ for i, header in enumerate(headers):
112
+ if (
113
+ header in _CANONICAL_SECTION_ORDER
114
+ and _CANONICAL_SECTION_ORDER.index(header) > target_rank
115
+ ):
116
+ insert_at = i
117
+ break
118
+ section_chunks.insert(insert_at, f"# {section}\n\n{bullet}")
119
+
120
+ return (
121
+ frontmatter_block + preamble + "".join(f"\n{chunk}" for chunk in section_chunks)
122
+ )
123
+
124
+
125
+ def insert_source_entry(
126
+ index_text: str, *, title: str, slug: str, description: str
127
+ ) -> str:
128
+ """Insert a new Source bullet into `index_text`'s `# Sources` section (D2).
129
+
130
+ Thin wrapper around `insert_index_entry(section="Sources",
131
+ link_dir="sources", ...)`, kept as a distinct public function so
132
+ `cli/main.py`'s existing call site (`bundle_index.insert_source_entry`)
133
+ keeps working unmodified.
134
+ """
135
+ return insert_index_entry(
136
+ index_text,
137
+ section="Sources",
138
+ link_dir="sources",
139
+ title=title,
140
+ slug=slug,
141
+ description=description,
142
+ )
143
+
144
+
145
+ _LINK_RE = re.compile(r"\[[^\]]*\]\(([^)]+)\)")
146
+ _BULLET_MARKERS = ("* ", "- ")
147
+ _SCHEME_RE = re.compile(r"\A[A-Za-z][A-Za-z0-9+.-]*:")
148
+
149
+
150
+ def _link_identity(target: str) -> str | None:
151
+ """Normalize a raw markdown link target to its bundle-relative identity.
152
+
153
+ A deliberately narrower, bundle-local twin of `lint.normalize_link`
154
+ (NOT imported from `lint`, per #922 -- `lint` imports `config` and
155
+ `okf` and is the higher "health" layer; importing it here would invert
156
+ layering). `index.md` always lives at the bundle root, so there is no
157
+ `source_rel_dir` parameter to thread through: a leading `/` (or
158
+ multiple leading `/`s, e.g. `//concepts/foo.md`) and a bare relative
159
+ link all resolve identically. A trailing `#fragment` or a quoted
160
+ ` "title"` suffix is stripped first; an external `scheme:` URL
161
+ (`http:`, `mailto:`, ...), an empty target, or one that escapes the
162
+ bundle root via `..` all normalize to `None` (never a match).
163
+
164
+ NOTE: this normalization must stay in lockstep with the BYTES
165
+ re-implementation in `openkos.vcs.git`'s `_FILE_INFO_CALLBACK_SNIPPET`
166
+ (that snippet runs inside `git filter-repo`'s own subprocess, which
167
+ cannot import `openkos`, hence the duplication) -- proven by the parity
168
+ test in `tests/unit/vcs/test_scrub_snippet_parity.py`.
169
+ """
170
+ target = target.split("#", 1)[0].strip()
171
+ if target.endswith('"') and ' "' in target:
172
+ target = target.rsplit(' "', 1)[0].strip()
173
+ if not target:
174
+ return None
175
+ if _SCHEME_RE.match(target):
176
+ return None
177
+ candidate = PurePosixPath(target.lstrip("/"))
178
+ parts: list[str] = []
179
+ for part in candidate.parts:
180
+ if part == "..":
181
+ if not parts:
182
+ return None
183
+ parts.pop()
184
+ else:
185
+ parts.append(part)
186
+ return "/".join(parts).removesuffix(".md")
187
+
188
+
189
+ def remove_index_entry(index_text: str, concept_id: str) -> tuple[str, int]:
190
+ """Drop every bullet whose FIRST markdown link resolves to `concept_id`.
191
+
192
+ Generic across all four sections (Sources, Concepts, People, Decisions,
193
+ #922): matching is by resolved LINK IDENTITY, never by section, so no
194
+ section-splitting or `# `-header parsing is needed here (unlike
195
+ `insert_source_entry`). Frontmatter is split off byte-for-byte via
196
+ `_split_frontmatter_verbatim` (raises `ValueError` on malformed
197
+ frontmatter, matching `insert_source_entry`'s contract) and the body is
198
+ walked line by line: a candidate line is one whose stripped text starts
199
+ with a list marker (`* ` or `- ` -- the engine always writes `*`, a
200
+ hand-authored bullet may use `-`); only its FIRST markdown link is
201
+ inspected, so a bullet that merely MENTIONS another concept later in its
202
+ description text is never mistakenly dropped.
203
+
204
+ Count semantics: zero matches returns `(index_text, 0)` completely
205
+ UNCHANGED -- not an error, since a file with no catalog entry is drift,
206
+ not a reason to refuse a deletion that is otherwise safe. One match
207
+ drops that line. More than one match (a duplicate catalog entry) drops
208
+ ALL of them, reporting the total count -- leaving any would create a
209
+ dangling reference to the now-deleted file. Only the matched line plus
210
+ its trailing newline is ever removed; every other byte -- blank lines,
211
+ other bullets, empty sections -- round-trips verbatim (no section
212
+ pruning, avoiding any reflow risk).
213
+ """
214
+ frontmatter_block, body = _split_frontmatter_verbatim(index_text)
215
+ lines = body.splitlines(keepends=True)
216
+ kept_lines: list[str] = []
217
+ removed = 0
218
+ for line in lines:
219
+ stripped = line.lstrip()
220
+ if stripped.startswith(_BULLET_MARKERS):
221
+ match = _LINK_RE.search(stripped)
222
+ if match is not None and _link_identity(match.group(1)) == concept_id:
223
+ removed += 1
224
+ continue
225
+ kept_lines.append(line)
226
+
227
+ if removed == 0:
228
+ return index_text, 0
229
+ return frontmatter_block + "".join(kept_lines), removed
@@ -0,0 +1,274 @@
1
+ """Inbound-link rewrite/reverse primitives for `merge`/`unmerge` (spec:
2
+ Inbound-Link Rewrite; ADR-0002's `link_rewrites`).
3
+
4
+ `find_inbound_link_rewrites` is the pure Phase-A SCAN: given every bundle
5
+ file's text already in memory, it finds bundle-relative markdown links
6
+ pointing at an absorbed concept-id and returns the `okf.LinkRewrite` records
7
+ a later unit (U4) would apply -- no file is read or written here, and no
8
+ CLI wiring happens in this module. Each returned record also carries the
9
+ `offset` where its `new_link` will begin in that file's post-merge text
10
+ (see `okf.LinkRewrite`'s docstring). `apply_link_rewrites` is pure
11
+ text-in/text-out and is BOUNDED to exactly one occurrence per recorded
12
+ `LinkRewrite` -- never a blind replace-all -- so a coincidental
13
+ pre-existing identical `[y](/survivor-id.md)` link elsewhere in the same
14
+ file is never touched by a rewrite of a *different* recorded occurrence,
15
+ and multiple recorded occurrences of the SAME target are each consumed in
16
+ turn. `reverse_link_rewrites` is the exact inverse `unmerge` (U5) needs for
17
+ round-trip parity -- made unambiguous by reverting at the recorded
18
+ `offset` rather than searching for `new_link`'s target string, which
19
+ cannot by itself distinguish a rewritten occurrence from a coincidental
20
+ pre-existing one sharing the same target.
21
+
22
+ `_LINK_RE`/`_mask_fenced_code_blocks` are a deliberate, intentional
23
+ DUPLICATE of `graph/sqlite_graph.py`'s copies (same bundle-relative
24
+ `[text](/….md)` link shape, same "blank out fenced lines" mask), not an
25
+ import -- the canonical layer (`openkos.model`, `openkos.bundle`,
26
+ `openkos.state`) MUST NOT import the derived `openkos.graph` package. This
27
+ mirrors `bundle/index.py`'s `_link_identity` precedent (#922: a narrower
28
+ bundle-local twin of a higher layer's link helper, kept separate rather
29
+ than inverting layering). Unlike `sqlite_graph.py`'s copy, `_LINK_RE` here
30
+ captures the `#anchor` suffix in its own group so it can be preserved
31
+ verbatim across a rewrite, rather than discarded.
32
+ """
33
+
34
+ import re
35
+ from collections.abc import Mapping
36
+ from typing import Final
37
+
38
+ from openkos.model.okf import LinkRewrite
39
+
40
+ _LINK_RE: Final = re.compile(r"\[[^\]]*\]\(/([^)\s#]+\.md)(#[^)\s]*)?\)")
41
+ """A bundle-relative `[text](/….md)` markdown link, per
42
+ `docs/knowledge-object-model.md`'s link shape. Group 1 is the `.md`-suffixed
43
+ bundle-relative path (the concept id once `.md` is stripped); group 2 is the
44
+ optional `#anchor` suffix INCLUDING its leading `#`, captured (not
45
+ discarded) so a rewrite can preserve it verbatim."""
46
+
47
+ _FENCE_MARKERS: Final = ("```", "~~~")
48
+
49
+
50
+ def _mask_fenced_code_blocks(body: str) -> str:
51
+ """Blank out every line inside a fenced code block (fence markers
52
+ included), keeping every other line byte-identical -- same algorithm as
53
+ `graph/sqlite_graph.py`'s copy (intentionally duplicated, see module
54
+ docstring). A masked line differs from the original at the SAME index,
55
+ which is what lets `_iter_safe_lines` below detect "this line is inside
56
+ a fence" without any character-offset bookkeeping.
57
+ """
58
+ lines = body.split("\n")
59
+ masked: list[str] = []
60
+ fence_marker: str | None = None
61
+ for line in lines:
62
+ stripped = line.lstrip()
63
+ opens_or_closes = stripped.startswith(_FENCE_MARKERS)
64
+ if fence_marker is None:
65
+ if opens_or_closes:
66
+ fence_marker = stripped[:3]
67
+ masked.append("")
68
+ else:
69
+ masked.append(line)
70
+ else:
71
+ if opens_or_closes and stripped[:3] == fence_marker:
72
+ fence_marker = None
73
+ masked.append("")
74
+ return "\n".join(masked)
75
+
76
+
77
+ def _iter_safe_lines(text: str) -> list[tuple[str, bool]]:
78
+ """Split `text` into `(line, is_safe)` pairs, where `is_safe` is False
79
+ for every line inside a fenced code block. Line-level (not
80
+ character-offset) granularity keeps this immune to the mask's blanked
81
+ lines having a different length than the original."""
82
+ lines = text.split("\n")
83
+ masked_lines = _mask_fenced_code_blocks(text).split("\n")
84
+ return [
85
+ (line, line == masked) for line, masked in zip(lines, masked_lines, strict=True)
86
+ ]
87
+
88
+
89
+ def find_inbound_link_rewrites(
90
+ files: Mapping[str, str], *, absorbed_id: str, survivor_id: str
91
+ ) -> list[LinkRewrite]:
92
+ """Pure Phase-A scan: find every bundle-relative markdown link, across
93
+ every file in `files` (bundle-relative path -> full text already in
94
+ memory), that resolves to `absorbed_id`, and return the `LinkRewrite`
95
+ each one needs so a later unit can repoint it at `survivor_id`. A link
96
+ inside a fenced code block is never matched (spec: Inbound-Link Rewrite
97
+ -- fence-masked code-block links MUST NOT be rewritten). No file is
98
+ read from disk and none is written here; `files` iteration order
99
+ determines result order, and matches within a file are in line order.
100
+
101
+ Each returned `LinkRewrite.offset` is the character offset, WITHIN THAT
102
+ FILE's own resulting (post-merge) text, where this occurrence's
103
+ `new_link` will begin once `apply_link_rewrites` substitutes it --
104
+ computed by walking the same text `apply_link_rewrites` will produce
105
+ (unchanged prefix, then either the original target's length for a
106
+ non-matching link or `new_link`'s length for a rewritten one, then the
107
+ closing `)`, one line at a time, joined by `"\\n"`) without actually
108
+ building it. This is the positional disambiguator `reverse_link_rewrites`
109
+ needs (see `LinkRewrite.offset`'s docstring); it is independent across
110
+ files, since each file's rewrites are only ever applied to that file's
111
+ own text.
112
+ """
113
+ rewrites: list[LinkRewrite] = []
114
+ for file, text in files.items():
115
+ safe_lines = _iter_safe_lines(text)
116
+ offset = 0
117
+ for index, (line, is_safe) in enumerate(safe_lines):
118
+ if is_safe:
119
+ last_end = 0
120
+ for match in _LINK_RE.finditer(line):
121
+ path, anchor = match.group(1), match.group(2) or ""
122
+ concept_id = path.removesuffix(".md")
123
+ # The literal "/" preceding group 1 starts the target
124
+ # text; the target ends at the anchor (if present) or
125
+ # the path otherwise -- the closing ")" always follows.
126
+ target_start = match.start(1) - 1
127
+ target_end = match.end(2) if match.group(2) else match.end(1)
128
+
129
+ offset += len(line[last_end:target_start])
130
+ if concept_id == absorbed_id:
131
+ new_link = f"/{survivor_id}.md{anchor}"
132
+ rewrites.append(
133
+ LinkRewrite(
134
+ file=file,
135
+ old_link=f"/{path}{anchor}",
136
+ new_link=new_link,
137
+ offset=offset,
138
+ )
139
+ )
140
+ offset += len(new_link)
141
+ else:
142
+ offset += target_end - target_start
143
+ offset += len(line[target_end : match.end()])
144
+ last_end = match.end()
145
+ offset += len(line[last_end:])
146
+ else:
147
+ offset += len(line)
148
+ if index != len(safe_lines) - 1:
149
+ offset += 1 # the "\n" `apply_link_rewrites` joins lines with
150
+ return rewrites
151
+
152
+
153
+ def _substitute_target(
154
+ text: str, *, old_target: str, new_target: str, missing_error: str
155
+ ) -> str:
156
+ """Shared bounded-substitution core for `apply_link_rewrites`: replace
157
+ `](old_target)` with `](new_target)` at exactly ONE occurrence -- the
158
+ first found in document (top-to-bottom) order among SAFE (non-fenced)
159
+ lines -- leaving fenced lines and every OTHER occurrence (including any
160
+ later occurrence of the SAME target) byte-identical. Matching on the
161
+ closing `]( ... )` pair (rather than a blind substring search) means a
162
+ target that only appears as plain prose text -- never inside a real
163
+ link -- is never touched either.
164
+
165
+ Bounding to exactly one occurrence per call is what lets a file with
166
+ MULTIPLE occurrences of the identical `old_target` (e.g. two separate
167
+ links to the same absorbed id) be rewritten correctly: `apply_link_rewrites`
168
+ calls this once per recorded `LinkRewrite`, in the SAME top-to-bottom
169
+ order `find_inbound_link_rewrites` recorded them in, so each call
170
+ consumes the next remaining occurrence in turn -- never all of them at
171
+ once, which would starve a later identical rewrite of any occurrence
172
+ left to substitute.
173
+
174
+ Two distinct "not found" outcomes are handled differently, so a
175
+ (defensively passed) rewrite whose only occurrence lives inside a
176
+ fenced code block degrades to a silent no-op rather than an error --
177
+ it was never eligible for rewriting in the first place, so leaving it
178
+ alone is correct, not a drift signal:
179
+
180
+ - `old_target` present nowhere at all (safe or fenced) -- the file
181
+ genuinely changed since the rewrite was recorded. Raises
182
+ `ValueError` so the caller fails closed instead of corrupting text.
183
+ - `old_target` present only inside a fenced code block -- never
184
+ eligible for rewriting; returns `text` unchanged, no error.
185
+ """
186
+ pattern = re.compile(r"\]\(" + re.escape(old_target) + r"\)")
187
+ replacement = f"]({new_target})"
188
+
189
+ out_lines: list[str] = []
190
+ found_anywhere = False
191
+ substituted = False
192
+ for line, is_safe in _iter_safe_lines(text):
193
+ if pattern.search(line):
194
+ found_anywhere = True
195
+ if is_safe and not substituted and pattern.search(line):
196
+ substituted = True
197
+ out_lines.append(pattern.sub(replacement, line, count=1))
198
+ else:
199
+ out_lines.append(line)
200
+
201
+ if not found_anywhere:
202
+ raise ValueError(
203
+ f"{missing_error}: no occurrence of link target "
204
+ f"{old_target!r} found in text"
205
+ )
206
+ if not substituted:
207
+ return text
208
+ return "\n".join(out_lines)
209
+
210
+
211
+ def apply_link_rewrites(text: str, *, file: str, rewrites: list[LinkRewrite]) -> str:
212
+ """Pure: apply every rewrite in `rewrites` whose `.file == file` to
213
+ `text`, bounded to the recorded `{old_link, new_link}` occurrence(s)
214
+ only. Rewrites recorded for a DIFFERENT file are ignored, so a caller
215
+ may pass a merge ledger entry's full `link_rewrites` list without
216
+ pre-filtering by file. Raises `ValueError` if a rewrite's `old_link` is
217
+ not found on an unfenced line (fail-closed; see `_substitute_target`).
218
+ """
219
+ result = text
220
+ for rewrite in rewrites:
221
+ if rewrite.file != file:
222
+ continue
223
+ result = _substitute_target(
224
+ result,
225
+ old_target=rewrite.old_link,
226
+ new_target=rewrite.new_link,
227
+ missing_error="cannot apply link rewrite: old_link",
228
+ )
229
+ return result
230
+
231
+
232
+ def reverse_link_rewrites(text: str, *, file: str, rewrites: list[LinkRewrite]) -> str:
233
+ """Pure inverse of `apply_link_rewrites`, made EXACT by the recorded
234
+ `offset`: for every rewrite whose `.file == file` (others ignored),
235
+ restore `old_link` in place of `new_link` at PRECISELY the recorded
236
+ character offset -- never by searching for `new_link`'s target string.
237
+
238
+ This is the fix for a real ambiguity a target-only reverse cannot
239
+ resolve: when a file links to BOTH the absorbed and survivor concepts,
240
+ the post-merge text has TWO occurrences shaped like `](/survivor.md)`
241
+ (one just rewritten, one coincidentally pre-existing) -- a target-string
242
+ search cannot tell them apart and may revert the wrong one, corrupting
243
+ the pre-existing link and breaking byte-parity. Reversing at the exact
244
+ recorded `offset` removes the ambiguity: each recorded rewrite reverts
245
+ only its own occurrence, regardless of what else in the file happens to
246
+ share its target string.
247
+
248
+ Before substituting, the bytes at `offset` are verified to still be the
249
+ recorded `new_link` -- an immediate-unmerge byte-parity assumption. If
250
+ they are not (the file changed since the merge), this degrades cleanly
251
+ with a `ValueError` rather than corrupting the text (spec: Unmerge
252
+ Achieves Round-Trip Parity's idempotence/safety contract) -- the same
253
+ fail-closed contract `apply_link_rewrites`'s absent-target case already
254
+ established.
255
+
256
+ When a file has multiple recorded rewrites, they are reverted
257
+ RIGHT-TO-LEFT (descending `offset`) so an earlier offset stays valid
258
+ even after a later (higher-offset) reversion changes the text's length.
259
+ """
260
+ file_rewrites = sorted(
261
+ (rw for rw in rewrites if rw.file == file),
262
+ key=lambda rw: rw.offset,
263
+ reverse=True,
264
+ )
265
+ for rewrite in file_rewrites:
266
+ end = rewrite.offset + len(rewrite.new_link)
267
+ if text[rewrite.offset : end] != rewrite.new_link:
268
+ raise ValueError(
269
+ "cannot reverse link rewrite: new_link "
270
+ f"{rewrite.new_link!r} not found at recorded offset "
271
+ f"{rewrite.offset} in text"
272
+ )
273
+ text = text[: rewrite.offset] + rewrite.old_link + text[end:]
274
+ return text