The slug inherited the whole filename stem with no length bound, so a long dropped name produced a filename the filesystem cannot hold — surfacing as an untyped OSError at the write, with an errno that differs per platform (63 on macOS, 36 on Linux). The inbox contract promises a typed per-file outcome, so the gate belongs in the pure function that forms the name. Refusal, not truncation: truncating is lossy AND collision-prone — two long names sharing a prefix would reduce to one filename and the second write would silently claim the first file. Same posture as `inbox_slug_empty`: the library never invents a filename the operator did not give it. The message carries the actual size and the limit, since the fix is to rename the dropped file. Limit verified empirically on APFS 2026-07-25: a 255-byte component writes, a 258-byte one raises errno 63. ext4 and NTFS cap at the same 255. New MaterializationError code `inbox_slug_too_long`, registered in the errors docstring and in the error-code conformance suite.
335 lines
12 KiB
Python
335 lines
12 KiB
Python
"""Door B inbox provenance rendering + filename slugging (Phase 2 step 2).
|
|
|
|
Pure functions, zero runtime dependency, no guard call: the slug reduces a
|
|
dropped file's name to the Phase 1 id grammar, the `inbox-` namespace keeps
|
|
generated concepts disjoint from `index.md`/`ingest-*`/`promoted-verdict-*`,
|
|
and the provenance layer carries the §7-analogous honesty marker. The verdict
|
|
reservation applies here unchanged — the promotion gate stays the only path
|
|
into that layer.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import re
|
|
|
|
import pytest
|
|
|
|
from llm_ingestion_okf import MaterializationError
|
|
from llm_ingestion_okf.inbox import inbox_filename, inbox_slug, render_inbox_concept
|
|
|
|
INGESTED_AT = "2026-07-25T12:00:00Z"
|
|
|
|
# Phase 1's id grammar (manifest.py `_ID_PATTERN`), restated independently so a
|
|
# change to that constant cannot silently widen what the inbox namespace admits.
|
|
ID_GRAMMAR = re.compile(r"[a-z0-9][a-z0-9-]*\Z")
|
|
|
|
# Names chosen to attack the grammar from every side: spaces, punctuation runs,
|
|
# case, unicode, path-ish characters, leading/trailing separators, digits first.
|
|
NASTY_NAMES = [
|
|
"My Notes 2026.md",
|
|
" padded .txt",
|
|
"UPPER.CSV",
|
|
"a__b c.txt",
|
|
"--weird--.md",
|
|
"2026-report.md",
|
|
"caf\u00e9.md",
|
|
"re:port(final)[v2].md",
|
|
"dots.in.name.md",
|
|
"tab\tseparated.txt",
|
|
]
|
|
|
|
|
|
# --- slug: reduction to the Phase 1 id grammar ---
|
|
|
|
|
|
def test_slug_reduces_filename_to_the_phase_1_id_grammar() -> None:
|
|
assert inbox_slug("My Notes 2026.md") == "my-notes-2026"
|
|
|
|
|
|
def test_slug_drops_the_extension() -> None:
|
|
assert inbox_slug("report.csv") == "report"
|
|
|
|
|
|
def test_slug_keeps_inner_dots_as_separators() -> None:
|
|
# A dot is not in the grammar; only the final extension is special.
|
|
assert inbox_slug("dots.in.name.md") == "dots-in-name"
|
|
|
|
|
|
def test_slug_collapses_separator_runs_to_a_single_hyphen() -> None:
|
|
assert inbox_slug("a__b c.txt") == "a-b-c"
|
|
|
|
|
|
def test_slug_strips_leading_and_trailing_separators() -> None:
|
|
assert inbox_slug("--weird--.md") == "weird"
|
|
|
|
|
|
def test_slug_lowercases() -> None:
|
|
assert inbox_slug("UPPER.CSV") == "upper"
|
|
|
|
|
|
def test_slug_allows_a_leading_digit() -> None:
|
|
# The Phase 1 grammar admits `[a-z0-9]` as the first character.
|
|
assert inbox_slug("2026-report.md") == "2026-report"
|
|
|
|
|
|
def test_slug_replaces_non_ascii_rather_than_transliterating() -> None:
|
|
# No guessing at a romanisation: every character outside the grammar is a
|
|
# separator. Transliterating would be a semantic claim the slugger cannot
|
|
# make - Norwegian "m\u00f8te" (meeting) would become "mote" (fashion).
|
|
# The readable name survives verbatim in `title:`; the slug is an id.
|
|
assert inbox_slug("caf\u00e9.md") == "caf"
|
|
|
|
|
|
def test_slug_is_stable_across_unicode_normalisation_forms() -> None:
|
|
"""macOS (APFS) hands filenames over DECOMPOSED. Without NFC normalisation
|
|
the combining mark alone becomes a separator and the base letter survives,
|
|
so one visual filename would slug two different ways depending on whether
|
|
it arrived from a directory listing or from an operator-typed string.
|
|
Escapes, not literals: the test must not depend on this file's own form.
|
|
"""
|
|
composed = "caf\u00e9.md" # NFC: e-acute as one code point
|
|
decomposed = "cafe\u0301.md" # NFD: plain e + combining acute
|
|
assert composed != decomposed
|
|
assert inbox_slug(composed) == inbox_slug(decomposed) == "caf"
|
|
|
|
|
|
def test_slug_does_not_transliterate_nordic_letters() -> None:
|
|
assert inbox_slug("m\u00f8te-notat.md") == "m-te-notat"
|
|
|
|
|
|
@pytest.mark.parametrize("name", NASTY_NAMES)
|
|
def test_slug_output_always_matches_the_id_grammar(name: str) -> None:
|
|
assert ID_GRAMMAR.match(inbox_slug(name))
|
|
|
|
|
|
def test_slug_is_deterministic() -> None:
|
|
assert inbox_slug("My Notes 2026.md") == inbox_slug("My Notes 2026.md")
|
|
|
|
|
|
def test_slug_of_a_hidden_file_uses_its_leading_dot_name() -> None:
|
|
# A dotfile has no extension to drop: `.hidden.md` stems to `.hidden`.
|
|
assert inbox_slug(".hidden.md") == "hidden"
|
|
|
|
|
|
@pytest.mark.parametrize("name", ["---.md", "!!!.txt", "\u65e5\u672c.md", ""])
|
|
def test_slug_rejects_a_name_that_reduces_to_nothing(name: str) -> None:
|
|
# Never invent a fallback name: an unusable filename is a typed per-file
|
|
# failure the caller reports, not a silent `untitled`.
|
|
with pytest.raises(MaterializationError) as excinfo:
|
|
inbox_slug(name)
|
|
assert excinfo.value.code == "inbox_slug_empty"
|
|
|
|
|
|
# --- filename: the `inbox-` namespace ---
|
|
|
|
|
|
def test_inbox_filename_namespaces_the_slug() -> None:
|
|
assert inbox_filename("my-notes") == "inbox-my-notes.md"
|
|
|
|
|
|
@pytest.mark.parametrize("name", NASTY_NAMES)
|
|
def test_inbox_namespace_is_disjoint_from_the_reserved_names(name: str) -> None:
|
|
"""Namespace safety: no admissible slug can collide with `index.md`, a
|
|
Door A concept (`ingest-*`), or a promoted verdict (`promoted-verdict-*`).
|
|
"""
|
|
generated = inbox_filename(inbox_slug(name))
|
|
assert generated != "index.md"
|
|
assert not generated.startswith("ingest-")
|
|
assert not generated.startswith("promoted-verdict-")
|
|
assert generated.startswith("inbox-")
|
|
|
|
|
|
# --- filename length: the one gate the filesystem enforces for us otherwise ---
|
|
|
|
# NAME_MAX restated independently of the module constant, for the same reason
|
|
# ID_GRAMMAR is: a change to the library's value must not silently move the
|
|
# boundary this suite pins. Verified empirically on APFS 2026-07-25 (a 255-byte
|
|
# component writes, a 258-byte one raises errno 63 ENAMETOOLONG); ext4 and NTFS
|
|
# use the same 255 limit, and the slug is ASCII-only so bytes == characters.
|
|
NAME_MAX = 255
|
|
# `inbox-` (6) + `.md` (3) is the namespace's fixed overhead.
|
|
LONGEST_ADMISSIBLE_SLUG = NAME_MAX - len("inbox-") - len(".md")
|
|
|
|
|
|
def test_longest_admissible_slug_is_246_characters() -> None:
|
|
# Pins the arithmetic itself: if the namespace prefix or suffix ever
|
|
# changes, this fails before the length tests below start lying.
|
|
assert LONGEST_ADMISSIBLE_SLUG == 246
|
|
|
|
|
|
def test_inbox_filename_at_the_length_limit_is_accepted() -> None:
|
|
slug = "a" * LONGEST_ADMISSIBLE_SLUG
|
|
generated = inbox_filename(slug)
|
|
assert len(generated.encode("utf-8")) == NAME_MAX
|
|
|
|
|
|
def test_inbox_filename_over_the_length_limit_is_rejected() -> None:
|
|
"""A name the filesystem cannot hold is a typed per-file failure, not an
|
|
untyped OSError from the write and not a truncation. Truncating would be
|
|
lossy AND collision-prone: two long names sharing a prefix would reduce to
|
|
one filename, and the second write would silently claim the first's file.
|
|
Refusing is the same posture as `inbox_slug_empty` — the library never
|
|
invents a filename the operator did not give it.
|
|
"""
|
|
slug = "a" * (LONGEST_ADMISSIBLE_SLUG + 1)
|
|
with pytest.raises(MaterializationError) as excinfo:
|
|
inbox_filename(slug)
|
|
assert excinfo.value.code == "inbox_slug_too_long"
|
|
|
|
|
|
def test_length_rejection_names_the_limit_and_the_actual_size() -> None:
|
|
# The operator's fix is to rename the dropped file, so the message has to
|
|
# say by how much — a bare "too long" leaves them guessing.
|
|
slug = "a" * 300
|
|
with pytest.raises(MaterializationError) as excinfo:
|
|
inbox_filename(slug)
|
|
message = str(excinfo.value)
|
|
assert str(NAME_MAX) in message
|
|
# 6 + 300 + 3 — the size the name WOULD have had.
|
|
assert "309" in message
|
|
|
|
|
|
def test_a_long_dropped_filename_fails_at_the_filename_not_at_the_write() -> None:
|
|
"""End-to-end on the pure functions: a 300-character dropped name reduces
|
|
to a 300-character slug, and the gate catches it before any I/O is
|
|
attempted — errno differs across platforms (63 on macOS, 36 on Linux), so
|
|
catching OSError by number was never a portable option.
|
|
"""
|
|
with pytest.raises(MaterializationError) as excinfo:
|
|
inbox_filename(inbox_slug("x" * 300 + ".md"))
|
|
assert excinfo.value.code == "inbox_slug_too_long"
|
|
|
|
|
|
# --- provenance rendering ---
|
|
|
|
|
|
def test_frontmatter_keys_in_exact_order() -> None:
|
|
out = render_inbox_concept(
|
|
"Body text",
|
|
okf_type="note",
|
|
title="My Notes",
|
|
source_file="My Notes 2026.md",
|
|
source_bytes=b"original",
|
|
ingested_at=INGESTED_AT,
|
|
)
|
|
digest = hashlib.sha256(b"original").hexdigest()
|
|
assert out == (
|
|
"---\n"
|
|
"type: note\n"
|
|
"title: My Notes\n"
|
|
"source_file: My Notes 2026.md\n"
|
|
f"source_sha256: {digest}\n"
|
|
f"ingested_at: {INGESTED_AT}\n"
|
|
"generated: true\n"
|
|
"---\n"
|
|
"\n"
|
|
"Body text\n"
|
|
)
|
|
|
|
|
|
def test_source_sha256_is_over_the_original_bytes_not_the_extracted_text() -> None:
|
|
"""The honesty marker must point at what was DROPPED, not at what the
|
|
extractor produced — otherwise provenance cannot be re-verified against the
|
|
operator's file.
|
|
"""
|
|
raw = b"name\nAda\n"
|
|
text = "| name |\n| --- |\n| Ada |\n"
|
|
out = render_inbox_concept(
|
|
text,
|
|
okf_type="note",
|
|
title="Data",
|
|
source_file="data.csv",
|
|
source_bytes=raw,
|
|
ingested_at=INGESTED_AT,
|
|
)
|
|
assert f"source_sha256: {hashlib.sha256(raw).hexdigest()}\n" in out
|
|
assert hashlib.sha256(text.encode("utf-8")).hexdigest() not in out
|
|
|
|
|
|
def test_body_is_lf_only_with_exactly_one_trailing_newline() -> None:
|
|
out = render_inbox_concept(
|
|
"line one\r\nline two\r\n\n\n",
|
|
okf_type="note",
|
|
title="T",
|
|
source_file="f.md",
|
|
source_bytes=b"x",
|
|
ingested_at=INGESTED_AT,
|
|
)
|
|
assert "\r" not in out
|
|
assert out.endswith("line one\nline two\n")
|
|
|
|
|
|
def test_rendering_is_deterministic() -> None:
|
|
kwargs = {
|
|
"okf_type": "note",
|
|
"title": "T",
|
|
"source_file": "f.md",
|
|
"source_bytes": b"x",
|
|
"ingested_at": INGESTED_AT,
|
|
}
|
|
assert render_inbox_concept("body", **kwargs) == render_inbox_concept("body", **kwargs)
|
|
|
|
|
|
# --- fail-fast gates ---
|
|
|
|
|
|
@pytest.mark.parametrize("moment", ["2026-07-25", "2026-07-25T12:00:00", "not-a-time", ""])
|
|
def test_ingested_at_must_be_iso_8601_utc_with_a_z_suffix(moment: str) -> None:
|
|
with pytest.raises(MaterializationError) as excinfo:
|
|
render_inbox_concept(
|
|
"body",
|
|
okf_type="note",
|
|
title="T",
|
|
source_file="f.md",
|
|
source_bytes=b"x",
|
|
ingested_at=moment,
|
|
)
|
|
assert excinfo.value.code == "ingested_at_invalid"
|
|
|
|
|
|
@pytest.mark.parametrize("okf_type", ["verdict", "Verdict", "VERDICT"])
|
|
def test_verdict_okf_type_rejected(okf_type: str) -> None:
|
|
"""Verdict reservation, second door: the promotion gate remains the only
|
|
path into that layer — the inbox can never write one.
|
|
"""
|
|
with pytest.raises(MaterializationError) as excinfo:
|
|
render_inbox_concept(
|
|
"body",
|
|
okf_type=okf_type,
|
|
title="T",
|
|
source_file="f.md",
|
|
source_bytes=b"x",
|
|
ingested_at=INGESTED_AT,
|
|
)
|
|
assert excinfo.value.code == "okf_type_reserved"
|
|
|
|
|
|
@pytest.mark.parametrize("title", ["a[b", "a]b", "two\nlines", "carriage\rreturn"])
|
|
def test_title_that_would_break_an_index_link_or_frontmatter_rejected(title: str) -> None:
|
|
# Same invariant as Door A's manifest validation: the title is rendered
|
|
# verbatim into `- [title](target)` and into line-oriented frontmatter.
|
|
with pytest.raises(MaterializationError) as excinfo:
|
|
render_inbox_concept(
|
|
"body",
|
|
okf_type="note",
|
|
title=title,
|
|
source_file="f.md",
|
|
source_bytes=b"x",
|
|
ingested_at=INGESTED_AT,
|
|
)
|
|
assert excinfo.value.code == "inbox_title_invalid"
|
|
|
|
|
|
@pytest.mark.parametrize("source_file", ["two\nlines.md", "carriage\rreturn.md"])
|
|
def test_source_file_that_would_inject_frontmatter_lines_rejected(source_file: str) -> None:
|
|
with pytest.raises(MaterializationError) as excinfo:
|
|
render_inbox_concept(
|
|
"body",
|
|
okf_type="note",
|
|
title="T",
|
|
source_file=source_file,
|
|
source_bytes=b"x",
|
|
ingested_at=INGESTED_AT,
|
|
)
|
|
assert excinfo.value.code == "inbox_source_file_invalid"
|