feat(okf): bundle-import iterator (mode b) — per-concept validate+stamp, aggregate disposition, log.md (T7, TDD, +8)
This commit is contained in:
parent
eac3c91b89
commit
320a40244f
2 changed files with 171 additions and 0 deletions
|
|
@ -38,9 +38,12 @@ __all__ = [
|
|||
"trust_for",
|
||||
"stamp_concept",
|
||||
"format_log_entry",
|
||||
"import_bundle",
|
||||
"Origin",
|
||||
"Channel",
|
||||
"ProvenanceStamp",
|
||||
"ConceptResult",
|
||||
"BundleResult",
|
||||
"OKFError",
|
||||
"OKFFrontmatterError",
|
||||
"OKFPathError",
|
||||
|
|
@ -273,6 +276,99 @@ def format_log_entry(stamp, *, timestamp=None):
|
|||
return "\t".join(fields)
|
||||
|
||||
|
||||
# --- T7: bundle-import iterator (mode b) -------------------------------------
|
||||
|
||||
# WARN < QUARANTINE_REVIEW < FAIL_SECURE — the aggregate is the most severe.
|
||||
_DISPOSITION_ORDER = (
|
||||
Disposition.WARN,
|
||||
Disposition.QUARANTINE_REVIEW,
|
||||
Disposition.FAIL_SECURE,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConceptResult:
|
||||
"""The outcome of validating one concept in a bundle.
|
||||
|
||||
``error`` is ``None`` on a concept that passed the gates (and then carries a
|
||||
``stamp``); a non-``None`` ``error`` means a hard reject (bad path, unsafe
|
||||
frontmatter, or a non-https ``resource``) — ``disposition`` is FAIL_SECURE
|
||||
and no stamp is produced, so the concept must not be merged.
|
||||
"""
|
||||
|
||||
path: str
|
||||
concept_id: str | None
|
||||
disposition: Disposition
|
||||
stamp: ProvenanceStamp | None
|
||||
report: Report
|
||||
error: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BundleResult:
|
||||
"""Per-concept results plus the bundle's aggregate (most-severe) disposition."""
|
||||
|
||||
concepts: tuple
|
||||
disposition: Disposition
|
||||
|
||||
def log(self):
|
||||
"""The ``log.md`` body — one line per concept, rejected ones marked."""
|
||||
lines = []
|
||||
for c in self.concepts:
|
||||
if c.stamp is not None:
|
||||
lines.append(format_log_entry(c.stamp))
|
||||
else:
|
||||
lines.append("\t".join([c.path, "REJECTED", c.disposition.value, c.error or ""]))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def import_bundle(bundle, *, origin=Origin.EXTERNAL, channel=Channel.AUTOMATIC):
|
||||
"""Validate a received OKF bundle concept-by-concept before merge (mode b).
|
||||
|
||||
``bundle`` maps concept path (e.g. ``tables/users.md``) to its raw document
|
||||
text. Each concept runs the full per-concept gate — path/reserved-name (T4),
|
||||
frontmatter parse-safety (T2), ``resource`` allowlist (T3), whole-concept
|
||||
scan (T1) and provenance stamping (T6). A concept that fails a hard gate is
|
||||
rejected (FAIL_SECURE) and recorded, but iteration continues, so the caller
|
||||
sees every issue in the bundle, not only the first. The bundle disposition is
|
||||
the most severe across its concepts.
|
||||
"""
|
||||
results = tuple(
|
||||
_validate_concept(path, bundle[path], origin, channel)
|
||||
for path in sorted(bundle)
|
||||
)
|
||||
aggregate = _most_severe(r.disposition for r in results)
|
||||
return BundleResult(results, aggregate)
|
||||
|
||||
|
||||
def _validate_concept(path, doc, origin, channel):
|
||||
try:
|
||||
concept_id = validate_concept_path(path)
|
||||
except OKFPathError as exc:
|
||||
return ConceptResult(path, None, Disposition.FAIL_SECURE, None, Report(), str(exc))
|
||||
try:
|
||||
frontmatter, _body = parse_frontmatter(doc)
|
||||
except OKFFrontmatterError as exc:
|
||||
return ConceptResult(path, concept_id, Disposition.FAIL_SECURE, None, Report(), str(exc))
|
||||
resource = frontmatter.get("resource")
|
||||
if isinstance(resource, str):
|
||||
try:
|
||||
validate_resource_url(resource)
|
||||
except OKFResourceError as exc:
|
||||
return ConceptResult(path, concept_id, Disposition.FAIL_SECURE, None, Report(), str(exc))
|
||||
report = scan_concept(doc)
|
||||
stamp = stamp_concept(concept_id, report, origin, channel)
|
||||
return ConceptResult(path, concept_id, stamp.disposition, stamp, report, None)
|
||||
|
||||
|
||||
def _most_severe(dispositions):
|
||||
worst = Disposition.WARN
|
||||
for disposition in dispositions:
|
||||
if _DISPOSITION_ORDER.index(disposition) > _DISPOSITION_ORDER.index(worst):
|
||||
worst = disposition
|
||||
return worst
|
||||
|
||||
|
||||
def _parse_flat(fm_lines):
|
||||
result = {}
|
||||
i = 0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue