Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ jobs:
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: playbook/pnpm-lock.yaml
- run: cargo fmt --all -- --check
- run: cargo clippy --workspace --all-targets -- -D warnings
- run: cargo test --workspace
Expand All @@ -27,6 +35,23 @@ jobs:
cd containers/kr0ki-mcp
python3 test_bridge.py
python3 test_http_worker.py
- name: kr0ki-storyb00k-agent Python tests
run: |
cd containers/kr0ki-storyb00k-agent
python3 -m venv .venv
.venv/bin/pip install -q -r requirements.txt
.venv/bin/python3 -m unittest discover -p 'test_*.py' -v
# `pnpm run build` is deliberately not run here: it would need the
# vendored @assistant-ui/vue package's dist/ built, which requires
# installing the entire elasticdotventures/assistant-ui monorepo
# (2,347 packages, ~2GB) for one small package -- not yet worth the
# CI cost (PR #37 review). The one test file that needs that dist/
# is excluded here via playbook/vite.config.js's CI-conditional
# exclude; it still runs for local developers who've built it.
- name: playbook (Vue) tests
run: |
pnpm --dir playbook install --frozen-lockfile
pnpm --dir playbook test

# SysML-v2-Release conformance harness (phase 1). Runs in parallel with
# `check` and does not block it: the corpus fetch (~110 MB tarball) and the
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@
/.worktrees
__pycache__/
/playbook/.histoire
.venv/
8 changes: 8 additions & 0 deletions containers/kr0ki-storyb00k-agent/test_planning_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ def setUp(self):
server._pending_questions.clear()
server._drafts.clear()

# This suite runs the real HTTP server end-to-end but must not depend
# on a live kr0ki-server being reachable at KR0KI_URL: fetch_manifest()
# is the one real network call _run() makes before it ever reaches the
# (also mocked) LLM. Same pattern test_server.py already uses.
self.manifest = patch("server.fetch_manifest", return_value=[])
self.manifest.start()
self.addCleanup(self.manifest.stop)

# Mock LLM: first call asks the user a multiple-choice question.
self.llm = patch("llm_client.OpenAICompatibleClient")
mock_cls = self.llm.start()
Expand Down
225 changes: 225 additions & 0 deletions crates/kr0ki-core/tests/requirements_contract.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
//! Consumer-level contract tests for `kr0ki_core::requirements`.
//!
//! The semantic model (`RequirementGraph`, the five viewpoints, promotion of
//! inferred/proposed relations) lives in `ufo_types::mbse::requirements`;
//! `kr0ki_core::requirements` is a `pub use` re-export (kr0ki M1,
//! 2026-09-19), and `kr0ki_core::requirements_render::to_d2` is the only
//! graph-to-D2 adapter kr0ki-core owns. This file exercises both exactly as
//! an external consumer would — through kr0ki-core's public API only, an
//! integration test rather than a `src/` unit test — to prove the
//! re-export/adapter seam itself behaves as the HTTP routes in
//! `kr0ki-server` depend on, not just that the upstream crate's own test
//! suite passes in isolation.

use std::collections::BTreeMap;

use kr0ki_core::requirements::{
BaselineIdentity, EvidenceRef, ModelIdentity, NonAuthoritativeRelation, Provenance,
RelationAuthority, Requirement, RequirementError, RequirementGraph, RequirementRelation,
RequirementRelationKind, TraversalDirection, ViewKind, ViewRequest, ViewScope,
};
use kr0ki_core::requirements_render::to_d2;

fn baseline() -> BaselineIdentity {
BaselineIdentity {
id: "BL-1".into(),
revision: "commit-1".into(),
import_artifact_sha256: None,
exported_baseline_sha256: None,
}
}

fn provenance() -> Provenance {
Provenance {
source_uri: "reqif://fixture".into(),
artifact_sha256: None,
locator: None,
}
}

fn requirement(id: &str, title: &str) -> Requirement {
Requirement {
id: id.into(),
title: title.into(),
text: title.into(),
baseline: baseline(),
provenance: provenance(),
attributes: BTreeMap::new(),
evidence: vec![],
}
}

#[test]
fn promotion_is_isolated_until_an_explicit_human_decision() {
let mut graph = RequirementGraph {
baseline: baseline(),
requirements: vec![requirement("r1", "One"), requirement("r2", "Two")],
evidence: vec![],
relations: vec![RequirementRelation {
id: "candidate".into(),
source: "r1".into(),
target: "r2".into(),
kind: RequirementRelationKind::Derives,
authority: RelationAuthority::Proposed(NonAuthoritativeRelation {
confidence: 0.9,
rationale: "candidate".into(),
evidence: vec![],
model: ModelIdentity {
name: "opa".into(),
version: "1".into(),
},
}),
provenance: provenance(),
promotion: None,
}],
};
let authoritative = ViewRequest {
kind: ViewKind::Decomposition,
scope: ViewScope::Authoritative,
root_id: None,
max_depth: None,
direction: TraversalDirection::Downstream,
confirmed_behaviour: None,
};
assert_eq!(graph.view(&authoritative).unwrap().graph.relations.len(), 0);

graph
.promote_relation("candidate", "alice", "reviewed")
.unwrap();
assert_eq!(graph.view(&authoritative).unwrap().graph.relations.len(), 1);
}

#[test]
fn impact_view_is_bounded_and_reports_cycles() {
let graph = RequirementGraph {
baseline: baseline(),
requirements: vec![
requirement("r1", "One"),
requirement("r2", "Two"),
requirement("r3", "Three"),
],
evidence: vec![],
relations: vec![
RequirementRelation {
id: "a".into(),
source: "r1".into(),
target: "r2".into(),
kind: RequirementRelationKind::Requires,
authority: RelationAuthority::Asserted,
provenance: provenance(),
promotion: None,
},
RequirementRelation {
id: "cycle".into(),
source: "r2".into(),
target: "r1".into(),
kind: RequirementRelationKind::Requires,
authority: RelationAuthority::Asserted,
provenance: provenance(),
promotion: None,
},
],
};
let result = graph
.view(&ViewRequest {
kind: ViewKind::Impact,
scope: ViewScope::Authoritative,
root_id: Some("r1".into()),
max_depth: Some(2),
direction: TraversalDirection::Downstream,
confirmed_behaviour: None,
})
.unwrap();
assert_eq!(result.diagnostics.bounded_at_depth, Some(2));
assert!(result.diagnostics.cycles.contains(&"cycle".to_string()));
}

#[test]
fn verification_coverage_reports_unverified_requirements_and_orphan_evidence() {
let graph = RequirementGraph {
baseline: baseline(),
requirements: vec![requirement("r1", "One"), requirement("r2", "Two")],
evidence: vec![EvidenceRef {
id: "t1".into(),
label: "orphan test".into(),
uri: None,
provenance: None,
}],
relations: vec![],
};
let result = graph
.view(&ViewRequest {
kind: ViewKind::VerificationCoverage,
scope: ViewScope::Authoritative,
root_id: None,
max_depth: None,
direction: TraversalDirection::Downstream,
confirmed_behaviour: None,
})
.unwrap();
let coverage = result.coverage.unwrap();
assert!(coverage.unverified.contains(&"r1".to_string()));
assert!(coverage.unverified.contains(&"r2".to_string()));
assert_eq!(coverage.orphan_evidence, vec!["t1"]);
}

#[test]
fn validation_rejects_a_relation_with_an_unknown_endpoint() {
let graph = RequirementGraph {
baseline: baseline(),
requirements: vec![requirement("r1", "One")],
evidence: vec![],
relations: vec![RequirementRelation {
id: "dangling".into(),
source: "r1".into(),
target: "does-not-exist".into(),
kind: RequirementRelationKind::Requires,
authority: RelationAuthority::Asserted,
provenance: provenance(),
promotion: None,
}],
};
let error = graph.validate().unwrap_err();
assert_eq!(error, RequirementError::UnknownEndpoint("dangling".into()));
}

#[test]
fn an_induced_view_lowers_through_to_d2_with_no_renderer_source_leaking_into_the_view_itself() {
let graph = RequirementGraph {
baseline: baseline(),
requirements: vec![requirement("r1", "One"), requirement("r2", "Two")],
evidence: vec![],
relations: vec![RequirementRelation {
id: "e".into(),
source: "r1".into(),
target: "r2".into(),
kind: RequirementRelationKind::Contains,
authority: RelationAuthority::Asserted,
provenance: provenance(),
promotion: None,
}],
};
let view = graph
.view(&ViewRequest {
kind: ViewKind::Decomposition,
scope: ViewScope::Authoritative,
root_id: None,
max_depth: None,
direction: TraversalDirection::Downstream,
confirmed_behaviour: None,
})
.unwrap();
// The view itself is typed data, not diagram source — this is the
// contract kr0ki_server::requirements_view (POST /requirements/views)
// depends on.
assert_eq!(view.graph.relations.len(), 1);
// Only requirements_render::to_d2, the one adapter kr0ki-core owns,
// turns that typed view into D2 — exactly what
// kr0ki_server::render_requirements_view (POST /render/requirements-view)
// calls.
let d2 = to_d2(&view);
assert_eq!(
d2,
"\"r1\": \"One\"\n\"r2\": \"Two\"\n\"r1\" -> \"r2\": Contains (asserted)\n"
);
}
18 changes: 15 additions & 3 deletions docs/HANDOFF-2026-09-19-reqif-flexo.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,26 @@ keeps ReqIF/Flexo mechanics outside `kr0ki-core` rendering semantics.

## Verification performed

**Updated 2026-09-19 (post-M1, post-PR-#36-review):** the semantic model moved to
`ufo_types::mbse::requirements`; the promotion/impact/behaviour/coverage tests moved
with it and now run in that crate, not this one. The line below originally read
"5 passed" for `cargo test -p kr0ki-core requirements` — that count was the semantic
tests plus the D2 adapter test, all still local to kr0ki-core at the time this doc was
written. Once M1 landed, only the D2 adapter test remained local; this doc's own claim
went stale in the same PR that made it true. Corrected numbers below, plus the
consumer-level contract suite PR #36 review asked for.

```text
cargo test -p kr0ki-core requirements # 5 passed
(in the ufo-types repo) cargo test mbse::requirements # 4 passed (the moved semantic tests; not runnable from kr0ki's workspace — ufo-types is a git dependency, not a workspace member)
cargo test -p kr0ki-core --test requirements_contract # 5 passed (kr0ki_core::requirements re-export + requirements_render::to_d2, exercised through kr0ki-core's public API only)
cargo test -p kr0ki-core requirements # 2 passed (requirements_render's own D2-adapter tests, incl. adversarial-title coverage)
cargo test -p kr0ki-server --test http requirements_view_returns_typed_induced_graph_not_renderer_source # passed
cargo clippy -p kr0ki-core -p kr0ki-server -- -D warnings # clean
cargo clippy -p kr0ki-core -p kr0ki-server -- -D warnings # clean
```

Tests cover promotion isolation, bounded impact/cycles, behaviour recommendation plus
human selection, coverage gaps, deterministic D2 emission, typed HTTP response, and
human selection, coverage gaps, deterministic D2 emission (including adversarial ReqIF
titles containing D2 structural characters — PR #36 review), typed HTTP response, and
the confirmation gate. They do not exercise a live Kroki backend, Flexo, ReqIF, or
StrictDoc.

Expand Down
3 changes: 3 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ build:
test:
cargo test --workspace
cd containers/kr0ki-mcp && python3 test_bridge.py && python3 test_http_worker.py
cd containers/kr0ki-storyb00k-agent && python3 -m venv .venv && .venv/bin/pip install -q -r requirements.txt && .venv/bin/python3 -m unittest discover -p 'test_*.py'
pnpm --dir playbook install --frozen-lockfile
pnpm --dir playbook test

# Live render test against a real Kroki (needs a backend URL).
test-live backend="https://kroki.io":
Expand Down
1 change: 1 addition & 0 deletions playbook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@vue/test-utils": "^2.5.1",
"histoire": "1.0.0-beta.1",
"jsdom": "^29.1.1",
"react": "^19.3.0",
"vite": "7.3.6",
"vitest": "^5.0.1"
}
Expand Down
Loading
Loading