The operator built a 2313-concept bundle from one project's own documentation, asked it a question in his own words, and judged the result unusable. The generated skill was an audit contract: all its discipline sat on the accounting -- markings, denominators, budget lines, source pointers -- and none of it on understanding the question, searching again, or writing one coherent answer. Two sentences actively forbade the second of those. **The two forbidding sentences are gone and their replacements are tested from both sides.** "Do not go looking for context the pre-pass deliberately withheld" read as "one run per question", and no wording of the operator's question put the right document inside a single run's cut -- so a rule against a second run was a rule against finding it at all. "Not something to retry with a narrower question" generalised a budget-refusal case into the same ban. SS 2.2 of the contract said the first of them, so the contract moved with the skill rather than being left to disagree with it: a second pre-pass run with other terms, and a fetch of a concept the payload NAMED, are reachable; SS 9's two real boundaries -- directory enumeration, the verdict layer -- are not. **Two new sections, and the checker requires them.** `## Working method`: read the bundle's map, put the question into the bundle's own words, split a broad question into 2-4 sub-questions, search per sub-question, read what lay just outside the cut and search again with its words, same method across several bundles, then assemble ONE answer ordered by sub-question, saying which source holds and what is not covered. `## Answer form`: the questioner's language, plain prose, no `below_k`, no digests, no budget lines, no denominators; short textbook-style references (document + section, plus bundle where several were read); and the audit trail written only when the questioner asks for it or into a document that travels without the skill. `REQUIRED_SECTIONS` follows the template and the contract's new SS 2.5 and SS 2.6 -- never the other way round. **The generic skill becomes what `okf skill` and `okf project` write.** A per-bundle skill's numbers go stale the moment its bundle is rebuilt, one copy per consuming project, and a project with two bundles installs two near-identical skills; the generic form carries no bundle's numbers and names `okf card` for them. `--for-bundle` is the opt-in for the instantiated copy, which still refuses out loud on a stale pairing -- safe to keep, not enough to keep default. `rule_bundle_identity` learned to tell a generic skill from an unfilled template by the frontmatter name the generator writes, so the template still fails for the opposite reason: it declares no identity because it is unfinished. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
306 lines
12 KiB
Python
306 lines
12 KiB
Python
"""`okf project`: one folder in, one bundle plus one skill out.
|
|
|
|
The command adds no rule, and owns exactly ONE flag that changes a bundle's
|
|
bytes -- `--gate`, which is a screen and not a segmentation rule. These tests
|
|
are mostly about the rest: 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 ( # noqa: E402
|
|
build,
|
|
parse_args,
|
|
)
|
|
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" / project.SKILL_NAME / "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"
|
|
# The SKILL directory does not carry the id, and that is the point of the
|
|
# generic form: a second bundle in the same project reuses this skill
|
|
# instead of installing a second one that says the same thing.
|
|
assert skill_path.parent.name == project.SKILL_NAME
|
|
|
|
|
|
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)
|
|
|
|
|
|
def test_the_gate_reaches_the_build_and_the_bundle_says_which_one(
|
|
folder: Path, tmp_path: Path
|
|
) -> None:
|
|
"""`okf project --gate` is the one flag here that MAY move a bundle's bytes.
|
|
|
|
`project.create` called `build()` with five keyword arguments and no
|
|
`gate=`, so the gate name was unreachable from this command: every project
|
|
bundle was screened by the package default and nothing said so was a
|
|
choice. The gate's name is written into the bundle's own `log.md`, so the
|
|
check is the bundle's, not the call's.
|
|
"""
|
|
out = tmp_path / "project"
|
|
bundle, _, _ = project.create(folder, out=out, gate="none")
|
|
log = (bundle / "log.md").read_text(encoding="utf-8")
|
|
assert "NOTHING WAS SCREENED" in log
|
|
|
|
default = tmp_path / "default"
|
|
other, _, _ = project.create(folder, out=default)
|
|
assert "NOTHING WAS SCREENED" not in (other / "log.md").read_text(encoding="utf-8")
|
|
|
|
|
|
def test_the_gate_flag_is_parsed_by_the_project_command(folder: Path, tmp_path: Path) -> None:
|
|
args = project.parse_args([str(folder), "--out", str(tmp_path), "--gate", "none"])
|
|
assert args.gate == "none"
|
|
|
|
|
|
def test_an_unknown_gate_name_does_not_start_the_run(folder: Path, tmp_path: Path) -> None:
|
|
"""A fallback would reproduce the defect the gate was added to close."""
|
|
with pytest.raises(IngestError) as caught:
|
|
project.create(folder, out=tmp_path / "project", gate="guard-nonesuch")
|
|
assert caught.value.code == "gate_invalid"
|