llm-ingestion-okf/tests/test_okf_project.py
Kjell Tore Guttormsen 1260fac154 feat(readme,skill,cli): the first screen an agent reads, three modes, and one flag that made two builds
`okf project` built a bundle two rules behind `okf build`. `cli.build`'s
signature defaulted `keep_table_heading` and `sheet_section_rows` to `False`
while argparse defaulted both to `True`, and `project.create` calls `build()`
as a function. Measured on a five-document folder: 15 concepts / 30 files
where `okf build` wrote 26 / 52, the whole difference in the priced sheet --
the document a question about price has to reach. The invariant test could not
see it: it compared `project.create` against the same function, and its two
fixture documents had neither a table nor a sheet. Both gaps are tests now,
and the two paths are byte-equal on that folder (`diff -rq`, 0 differences).

README opens with what / one install line / two commands / the three shapes of
request; the phase-status paragraph moved down, nothing deleted. One tag is
pinned everywhere: README pinned v0.4.0 on its install lines and v0.6.0 below,
llms.txt pinned v0.4.0, so an agent reading from the top installed a tag
without `okf project`.

The skill states three modes -- question, hypothesis (per premise, `confirmed`
/ `refuted` / `undecidable-from-bundle`), and a task producing a document
(source per claim in the artefact, an ungrounded paragraph written and marked
rather than dropped, the cut declared inside the document). The five markings
are untouched.

Generated skills state relative paths in the project layout: `okf consume
.okf/<id>` and `okf check --skill .claude/skills/<id>-consume/SKILL.md`,
runnable from where `okf project` tells the reader to start `claude`. Two
absolute paths to zero, measured with a query shown capable of finding first --
O5's published "4 -> 0" used `grep -c "^/"` against paths indented by two
spaces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:12:02 +02:00

267 lines
10 KiB
Python

"""`okf project`: one folder in, one bundle plus one skill out.
The command adds no rule and owns no flag that changes a bundle's bytes, so
these tests are mostly about that: the project bundle must be the SAME bytes
`okf build` writes for the same folder at the same stamp, or there are two
build paths and the reports are pinned to one of them.
"""
from __future__ import annotations
import hashlib
import inspect
import subprocess
import sys
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from llm_ingestion_okf import project # noqa: E402
from llm_ingestion_okf.cli import build # noqa: E402
from llm_ingestion_okf.cli import parse_args # noqa: E402
from llm_ingestion_okf.cli import main as okf_main # noqa: E402
from llm_ingestion_okf.errors import IngestError # noqa: E402
DOCUMENTS = {
"krav.md": (
"## 4 Grunnforhold\n\nGrunnen er morene over berg.\n\n"
"### 4.1 Loesmasser\n\nLoesmassene er telefarlige.\n"
),
"notat.md": "Et notat uten overskrift, uten tabell og uten nummerering.\n",
}
@pytest.fixture
def folder(tmp_path: Path) -> Path:
target = tmp_path / "Mine Dokumenter"
target.mkdir()
for name, body in DOCUMENTS.items():
(target / name).write_text(body, encoding="utf-8", newline="")
return target
def tree(root: Path) -> dict[str, str]:
return {
path.relative_to(root).as_posix(): hashlib.sha256(path.read_bytes()).hexdigest()
for path in sorted(root.rglob("*"))
if path.is_file()
}
def test_the_project_bundle_is_the_bytes_okf_build_writes(folder: Path, tmp_path: Path) -> None:
"""The invariant the whole command rests on: ONE build path, not two.
`okf project` runs `okf build` with this package's default and no flag list
of its own. If it ever grew one, a project bundle and a build bundle of the
same folder would differ, and every measurement report pinned to the build
path would be describing a bundle nobody produces.
"""
out = tmp_path / "project"
bundle, _, _ = project.create(folder, out=out)
reference = tmp_path / "reference"
build(folder, reference, bundle_id="mine-dokumenter", okf_version="0.2")
assert tree(bundle) == tree(reference)
def test_the_id_defaults_to_the_folder_name_in_the_id_grammar(folder: Path, tmp_path: Path) -> None:
out = tmp_path / "project"
bundle, skill_path, _ = project.create(folder, out=out)
assert bundle == out / ".okf" / "mine-dokumenter"
assert skill_path == out / ".claude" / "skills" / "mine-dokumenter-consume" / "SKILL.md"
assert bundle.is_dir() and skill_path.is_file()
def test_a_named_id_is_used_verbatim(folder: Path, tmp_path: Path) -> None:
out = tmp_path / "project"
bundle, skill_path, _ = project.create(folder, out=out, bundle_id="anbud-2026")
assert bundle.name == "anbud-2026"
assert skill_path.parent.name == "anbud-2026-consume"
def test_a_folder_name_that_reduces_to_nothing_refuses_by_code(tmp_path: Path) -> None:
"""A refusal with a code, not a bundle called `""`.
A folder named only in punctuation reduces to the empty string, and an
empty bundle id would produce a bundle whose concepts join on nothing.
"""
weird = tmp_path / "..."
weird.mkdir()
(weird / "a.md").write_text("# A\n\nKropp.\n", encoding="utf-8", newline="")
with pytest.raises(IngestError) as caught:
project.create(weird, out=tmp_path / "project")
assert caught.value.code == "manifest_invalid"
def test_the_folder_name_is_normalised_before_it_is_reduced(tmp_path: Path) -> None:
"""NFC first, for the reason the rest of this package normalises first.
macOS hands a filename over decomposed, so `é` arrives as `e` plus a
combining acute. Reduced without normalising, the same visible folder name
produces two different bundle ids depending on which form it arrived in.
"""
assert project.slug("Prosjekt É") == project.slug("Prosjekt É")
def test_the_summary_names_the_documents_that_landed_whole(folder: Path, tmp_path: Path) -> None:
"""SS 6.4 discipline applied to a summary: the number carries its denominator.
`notat.md` has no heading, no table and no numbered outline, so the rules
find no boundary and it lands as one concept. A reader who is told only
"3 concepts" cannot tell that asking about that document returns the whole
of it as one excerpt.
"""
out = tmp_path / "project"
_, _, summary = project.create(folder, out=out)
assert "Read 2 document(s)" in summary
assert "1 of 2 document(s) landed WHOLE" in summary
assert "notat.md" in summary
assert "krav.md" not in summary
assert "[sourced-not-sufficient]" in summary
assert f"NEXT: start claude again in {out}" in summary
def test_a_document_that_is_in_the_bundle_is_not_reported_as_missing(
folder: Path, tmp_path: Path
) -> None:
"""The known-positive for `inventory`'s first list.
Its own control: with every document ingested the list must be empty, and
with the bundle read against a DIFFERENT folder every document must appear.
A search that cannot find would report an empty list either way.
"""
out = tmp_path / "project"
bundle, _, _ = project.create(folder, out=out)
missing, whole = project.inventory(folder, bundle)
assert missing == ()
assert whole == ("notat.md",)
other = tmp_path / "other"
other.mkdir()
(other / "fremmed.md").write_text("# Fremmed\n\nKropp.\n", encoding="utf-8", newline="")
stranger, _ = project.inventory(other, bundle)
assert stranger == ("fremmed.md",)
def test_the_generated_skill_names_no_path_into_this_repository(
folder: Path, tmp_path: Path
) -> None:
"""O5's whole point, asserted where a user actually meets it.
The known-positive runs first: the string this searches for occurs in the
environment running the test, so a zero means the generator kept it out.
"""
out = tmp_path / "project"
_, skill_path, _ = project.create(folder, out=out)
text = skill_path.read_text(encoding="utf-8")
assert str(PROJECT_ROOT) in str(Path(__file__).resolve())
assert str(PROJECT_ROOT) not in text
assert "okf consume" in text
assert "okf check" in text
def test_the_subcommand_exists_and_reports_zero(folder: Path, tmp_path: Path) -> None:
assert okf_main(["project", str(folder), "--out", str(tmp_path / "project")]) == 0
def test_a_missing_folder_is_two_and_not_one(tmp_path: Path) -> None:
"""Three exit codes, not two: an unread folder is not a refused build."""
assert okf_main(["project", str(tmp_path / "nope"), "--out", str(tmp_path / "p")]) == 2
def test_the_installed_command_reaches_every_subcommand() -> None:
"""`okf --help` must LIST them, or a reader has to be told they exist.
The dispatch happens before argparse, so without the registration in
`parse_args` these four would work and be invisible.
"""
listed = subprocess.run(
[sys.executable, "-m", "llm_ingestion_okf.cli", "--help"],
capture_output=True,
text=True,
cwd=PROJECT_ROOT,
)
assert listed.returncode == 0
for command in ("build", "consume", "check", "skill", "project"):
assert command in listed.stdout, command
# --- The two defaults that were one flag apart (O6) ----------------------------
def test_the_build_signature_defaults_are_the_build_command_defaults() -> None:
"""One flag, one default. `okf project` reads the SIGNATURE, not argparse.
`project.create` calls `build()` as a Python function and passes no flag
list, so every segmentation value it gets is the signature's default. When
a flag moves to ON in argparse and is left OFF in the signature, there are
two defaults for one flag and `okf project` builds a bundle a rule behind
the command of the same name.
The byte-equality test above cannot see this: it calls the same function
with the same signature, so both sides carry the same wrong value. That is
the mechanism -- a test and the code agreeing over a set where the
difference cannot appear.
"""
parsed = parse_args(["build", "folder", "--bundle", "b", "--okf-version", "0.2"])
signature = inspect.signature(build)
disagreeing = {
name: (parameter.default, getattr(parsed, name))
for name, parameter in signature.parameters.items()
if hasattr(parsed, name)
and parameter.default is not inspect.Parameter.empty
and type(getattr(parsed, name)) is type(parameter.default)
}
disagreeing = {name: pair for name, pair in disagreeing.items() if pair[0] != pair[1]}
assert disagreeing == {}
def test_a_sheet_reaches_the_project_bundle_as_it_reaches_the_build_command(
tmp_path: Path,
) -> None:
"""The same folder through both entry doors, on a document that separates them.
`--sheet-section-rows` and `--keep-table-heading` are ON in `okf build`.
A markdown table under a heading is the smallest document whose concept
count moves with them, so this test's set is not empty by construction --
which is what let the invariant above pass while it was false.
"""
folder = tmp_path / "Ark"
folder.mkdir()
(folder / "krav.md").write_text(
"# Prisskjema\n\n"
"## 1 Poster\n\n"
"| Post | Beskrivelse | Pris |\n|---|---|---|\n"
"| 01 | Rigg og drift | 100 |\n"
"| 02 | Grunnarbeid | 200 |\n"
"| 03 | Betong | 300 |\n"
"| 04 | Staal | 400 |\n"
"| 05 | Tak | 500 |\n\n"
"## 2 Vilkaar\n\nBetaling skjer etter levering.\n",
encoding="utf-8",
newline="",
)
out = tmp_path / "project"
bundle, _, _ = project.create(folder, out=out)
reference = tmp_path / "reference"
assert (
okf_main(
[
"build",
str(folder),
"--bundle",
str(reference),
"--bundle-id",
"ark",
"--okf-version",
"0.2",
]
)
== 0
)
assert tree(bundle) == tree(reference)