feat(okf): in-import cross-link graph — extract/resolve/reject links, dangling-link signal (T5a/A, TDD, +10)
This commit is contained in:
parent
320a40244f
commit
30aa0a42a1
2 changed files with 191 additions and 0 deletions
|
|
@ -39,15 +39,20 @@ __all__ = [
|
|||
"stamp_concept",
|
||||
"format_log_entry",
|
||||
"import_bundle",
|
||||
"extract_link_targets",
|
||||
"resolve_link",
|
||||
"link_graph",
|
||||
"Origin",
|
||||
"Channel",
|
||||
"ProvenanceStamp",
|
||||
"ConceptResult",
|
||||
"BundleResult",
|
||||
"LinkGraphResult",
|
||||
"OKFError",
|
||||
"OKFFrontmatterError",
|
||||
"OKFPathError",
|
||||
"OKFResourceError",
|
||||
"OKFLinkError",
|
||||
]
|
||||
|
||||
_FENCE = "---"
|
||||
|
|
@ -76,6 +81,10 @@ class OKFResourceError(OKFError):
|
|||
"""A ``resource`` URL is not on the https allowlist."""
|
||||
|
||||
|
||||
class OKFLinkError(OKFError):
|
||||
"""A cross-link target is unsafe (dangerous scheme or bundle escape)."""
|
||||
|
||||
|
||||
_URL_SCHEME_RE = re.compile(r"^([A-Za-z][A-Za-z0-9+.\-]*):")
|
||||
|
||||
|
||||
|
|
@ -369,6 +378,122 @@ def _most_severe(dispositions):
|
|||
return worst
|
||||
|
||||
|
||||
# --- T5a / A: cross-link graph (in-import) -----------------------------------
|
||||
# The persisted cross-run graph (B) that would catch "plant a link now, write
|
||||
# the poisoned target in a LATER run" (§7.2) is deferred to stream 2, where the
|
||||
# consumer that owns the corpus decides where the durable graph state lives.
|
||||
# This in-import graph resolves links within a single bundle merge.
|
||||
|
||||
_MD_LINK_RE = re.compile(r"\[[^\]]*\]\(\s*([^)\s]+)")
|
||||
# Active-content schemes are refused in a link, mirroring the resource gate (T3).
|
||||
_DANGEROUS_LINK_SCHEMES = frozenset({"javascript", "data", "vbscript", "file", "blob"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LinkGraphResult:
|
||||
"""In-import cross-link resolution over one bundle.
|
||||
|
||||
``dangling`` — ``(from_id, target_concept_id)`` for in-bundle ``.md`` links
|
||||
whose target concept is **not present** in the bundle: the dormant-injection
|
||||
signal of §7.2 (a link planted to a not-yet-written concept). ``rejected`` —
|
||||
``(from_id, target, reason)`` for links refused outright (dangerous scheme or
|
||||
bundle escape). ``resolved`` — ``(from_id, target_concept_id)`` for links to
|
||||
concepts present in the bundle.
|
||||
"""
|
||||
|
||||
dangling: tuple
|
||||
rejected: tuple
|
||||
resolved: tuple
|
||||
|
||||
|
||||
def extract_link_targets(body):
|
||||
"""Return the destinations of markdown ``[text](target)`` links in ``body``."""
|
||||
return _MD_LINK_RE.findall(body)
|
||||
|
||||
|
||||
def resolve_link(target, from_concept_id):
|
||||
"""Resolve one link target to an in-bundle concept-ID, or reject it (T5a).
|
||||
|
||||
Returns the target concept-ID for an in-bundle ``.md`` link (bundle-absolute
|
||||
``/x.md`` or relative ``./x.md`` / ``../y.md``, resolved against the linking
|
||||
concept's directory). Returns ``None`` for an external ``http(s)``/other
|
||||
non-active link (not a concept edge) and for non-``.md`` targets. Raises
|
||||
:class:`OKFLinkError` for an active-content scheme or a ``..`` escape past the
|
||||
bundle root.
|
||||
"""
|
||||
candidate = target.strip().split("#", 1)[0].split("?", 1)[0]
|
||||
if not candidate:
|
||||
return None
|
||||
|
||||
scheme_match = _URL_SCHEME_RE.match(candidate)
|
||||
if scheme_match:
|
||||
scheme = scheme_match.group(1).lower()
|
||||
if scheme in _DANGEROUS_LINK_SCHEMES:
|
||||
raise OKFLinkError("link uses a dangerous scheme %r: %r" % (scheme, target))
|
||||
return None # external (http/https/mailto/…): not an in-bundle concept edge
|
||||
|
||||
if not candidate.endswith(".md"):
|
||||
return None # not a concept-document link (asset, anchor, …)
|
||||
|
||||
if candidate.startswith("/"):
|
||||
normalized = _normalize_bundle_path(candidate[1:])
|
||||
else:
|
||||
from_dir = from_concept_id.rsplit("/", 1)[0] if "/" in from_concept_id else ""
|
||||
joined = from_dir + "/" + candidate if from_dir else candidate
|
||||
normalized = _normalize_bundle_path(joined)
|
||||
|
||||
return normalized[: -len(".md")]
|
||||
|
||||
|
||||
def link_graph(bundle):
|
||||
"""Resolve every cross-link in ``bundle`` against the concepts it contains.
|
||||
|
||||
``bundle`` maps concept path to document text (as :func:`import_bundle`). Only
|
||||
the body is scanned for links. See :class:`LinkGraphResult` for the outcome.
|
||||
"""
|
||||
present = {p[: -len(".md")] for p in bundle if p.endswith(".md")}
|
||||
dangling, rejected, resolved = [], [], []
|
||||
|
||||
for path in sorted(bundle):
|
||||
if not path.endswith(".md"):
|
||||
continue
|
||||
from_id = path[: -len(".md")]
|
||||
try:
|
||||
_frontmatter, body = parse_frontmatter(bundle[path])
|
||||
except OKFFrontmatterError:
|
||||
body = bundle[path] # unparseable frontmatter is T2's reject, not ours
|
||||
|
||||
for target in extract_link_targets(body):
|
||||
try:
|
||||
concept_id = resolve_link(target, from_id)
|
||||
except OKFLinkError as exc:
|
||||
rejected.append((from_id, target, str(exc)))
|
||||
continue
|
||||
if concept_id is None:
|
||||
continue
|
||||
if concept_id in present:
|
||||
resolved.append((from_id, concept_id))
|
||||
else:
|
||||
dangling.append((from_id, concept_id))
|
||||
|
||||
return LinkGraphResult(tuple(dangling), tuple(rejected), tuple(resolved))
|
||||
|
||||
|
||||
def _normalize_bundle_path(path):
|
||||
"""Normalize a ``/``-separated bundle path; raise if it escapes the root."""
|
||||
parts = []
|
||||
for segment in path.split("/"):
|
||||
if segment in ("", "."):
|
||||
continue
|
||||
if segment == "..":
|
||||
if not parts:
|
||||
raise OKFLinkError("link target escapes the bundle root: %r" % path)
|
||||
parts.pop()
|
||||
else:
|
||||
parts.append(segment)
|
||||
return "/".join(parts)
|
||||
|
||||
|
||||
def _parse_flat(fm_lines):
|
||||
result = {}
|
||||
i = 0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue