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.
- openkos/__init__.py +1 -0
- openkos/bundle/__init__.py +4 -0
- openkos/bundle/bundle.py +28 -0
- openkos/bundle/index.py +229 -0
- openkos/bundle/links.py +274 -0
- openkos/bundle/log.py +162 -0
- openkos/bundle/merge.py +195 -0
- openkos/bundle/provenance.py +98 -0
- openkos/bundle/references.py +170 -0
- openkos/bundle/relations.py +229 -0
- openkos/cli/__init__.py +1 -0
- openkos/cli/main.py +4853 -0
- openkos/cli/observability.py +66 -0
- openkos/config.py +410 -0
- openkos/extraction/__init__.py +8 -0
- openkos/extraction/concept.py +231 -0
- openkos/fsio.py +97 -0
- openkos/graph/__init__.py +13 -0
- openkos/graph/analysis.py +39 -0
- openkos/graph/base.py +57 -0
- openkos/graph/sqlite_graph.py +439 -0
- openkos/lifecycle.py +98 -0
- openkos/lint.py +451 -0
- openkos/llm/__init__.py +9 -0
- openkos/llm/base.py +42 -0
- openkos/llm/ollama.py +336 -0
- openkos/llm/parsing.py +95 -0
- openkos/model/__init__.py +4 -0
- openkos/model/okf.py +1039 -0
- openkos/model/relations.py +68 -0
- openkos/model/types.py +88 -0
- openkos/py.typed +0 -0
- openkos/resolution/__init__.py +22 -0
- openkos/resolution/adjudication.py +269 -0
- openkos/resolution/candidates.py +195 -0
- openkos/resolution/contradiction.py +438 -0
- openkos/resolution/edge_typing.py +291 -0
- openkos/resolution/normalize.py +29 -0
- openkos/resolution/similarity.py +85 -0
- openkos/resolution/volatility_typing.py +281 -0
- openkos/retrieval/__init__.py +7 -0
- openkos/retrieval/answer.py +485 -0
- openkos/retrieval/fusion.py +81 -0
- openkos/retrieval/graph_retrieve.py +77 -0
- openkos/retrieval/pool.py +20 -0
- openkos/sensitivity.py +123 -0
- openkos/state/__init__.py +4 -0
- openkos/state/derived.py +227 -0
- openkos/state/fts.py +351 -0
- openkos/state/reindex.py +323 -0
- openkos/state/vectorstore.py +465 -0
- openkos/templates/agents.md.template +50 -0
- openkos/templates/openkos.yaml.template +15 -0
- openkos/vcs/__init__.py +6 -0
- openkos/vcs/git.py +512 -0
- openkos-0.1.0.dist-info/METADATA +176 -0
- openkos-0.1.0.dist-info/RECORD +59 -0
- openkos-0.1.0.dist-info/WHEEL +4 -0
- 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."""
|
openkos/bundle/bundle.py
ADDED
|
@@ -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))
|
openkos/bundle/index.py
ADDED
|
@@ -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
|
openkos/bundle/links.py
ADDED
|
@@ -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
|