Knowledge-graph foundation: Entry nodes + Link edges + backlinks #4

Merged
bjoern merged 2 commits from feat/graph-foundation into main 2026-07-09 14:55:48 +02:00
Member

The structural heart of the knowledge base (ADR 0019): every linkable thing is an Entry node, the open-ended associations are generic Link edges, and opening any node yields its backlinks — "open X, see everything using it" from one indexed query.

What's in it

Domain (Kagura.Domain.Graph, pure)

  • EntryKind — the uniform discriminator (Character, Location, Scene, LoreEntry, Cg, TimelineEvent, Chapter, Route, Asset).
  • Entry — base node: Id, ProjectId, Kind, Title, Description, timestamps. Non-sealed so concrete content types (Character, Location, …) will share its PK via table-per-type in later slices; until then it's a usable generic node.
  • Link — directed edge: From/To/Role/Note/optional AtTimelineEventId. Rejects self-links.
  • LinkRole + LinkRoles — the small, extensible role vocabulary (home_of/residents, appears_in/features, set_in/scenes, depicts/depicted_in, references/referenced_by, uses/used_by, symmetric related_to) with LinkDirection and inverse/symmetric label resolution.

UseCases (Kagura.UseCases.Graph)

  • IGraphStore port; NodeSummary / GraphEdgeView / NodeGraphView read models (adapters get summaries, so a link across modules needs no compile dependency).
  • LinkNodes — validates a known role, distinct endpoints, both nodes present, same project, and no duplicate; returns the created edge as seen from the source.
  • GetNodeGraph — unions a node's inbound + outbound edges and groups them by the label shown at this end, so a directed edge surfaces under its inverse when the node is the target (a location shows residents; the character shows home_of).

Infrastructure

  • Entry/Link Fluent configs + AddGraphEntryAndLink migration.
  • All FKs Restrict: deletion stays app-managed (soft delete, ADR 0020), which also sidesteps SQLite's multiple-cascade-path error with Link's three Entry FKs (from/to/at-timeline).
  • Unique (FromId, ToId, Role) index is the duplicate-edge safety net; EfGraphStore translates the real SqliteException into TryAddLink=false inside the adapter — EF never leaks upward (same pattern as the project slug).
  • Extracted the UTC-ticks ValueConverter to shared config (UtcTicksConverter) and repointed ProjectConfiguration at it — as flagged when it landed.

Tests — 51, all green (was 25; +26)

  • 45 unit (fake store): role vocabulary + label flipping, all six LinkNodes validations + same-pair-different-role, GetNodeGraph grouping/inverse-label/sorting/empty/not-found.
  • 6 integration over real SQLite: backlink navigation (inbound under inverse label, outbound under forward), and a real duplicate-edge unique violation returning a failure rather than throwing. Extracted SqliteBackedTest so both integration suites share one migrated-SQLite fixture. The Entry→Project FK again earned the real-DB strategy its keep — it caught tests that scoped entries to a non-existent project.

Verification

  • dotnet build — 0 warnings / 0 errors (warnings-as-errors).
  • dotnet test — 51/51 pass.
  • Fresh boot applies both migrations (Projects, Entries, Links), serves HTTP 200, clean log.

Not in this PR (next slice)

Soft delete + the ChangeLog journal / undo (ADR 0020), link removal/edit/re-point, and Relationship as a specialized edge (depends on the Character type). Backlink views will then also union Relationship and the typed structural refs (scene primary location / POV) per ADR 0019.

🤖 Generated with Claude Code

The structural heart of the knowledge base (ADR 0019): every linkable thing is an **`Entry`** node, the open-ended associations are generic **`Link`** edges, and opening any node yields its **backlinks** — "open X, see everything using it" from one indexed query. ## What's in it **Domain** (`Kagura.Domain.Graph`, pure) - `EntryKind` — the uniform discriminator (Character, Location, Scene, LoreEntry, Cg, TimelineEvent, Chapter, Route, Asset). - `Entry` — base node: `Id`, `ProjectId`, `Kind`, `Title`, `Description`, timestamps. **Non-sealed** so concrete content types (Character, Location, …) will share its PK via table-per-type in later slices; until then it's a usable generic node. - `Link` — directed edge: `From`/`To`/`Role`/`Note`/optional `AtTimelineEventId`. Rejects self-links. - `LinkRole` + `LinkRoles` — the small, extensible role vocabulary (`home_of`/`residents`, `appears_in`/`features`, `set_in`/`scenes`, `depicts`/`depicted_in`, `references`/`referenced_by`, `uses`/`used_by`, symmetric `related_to`) with `LinkDirection` and inverse/symmetric label resolution. **UseCases** (`Kagura.UseCases.Graph`) - `IGraphStore` port; `NodeSummary` / `GraphEdgeView` / `NodeGraphView` read models (adapters get summaries, so a link across modules needs no compile dependency). - `LinkNodes` — validates a known role, distinct endpoints, both nodes present, same project, and no duplicate; returns the created edge as seen from the source. - `GetNodeGraph` — unions a node's inbound + outbound edges and groups them by the label shown **at this end**, so a directed edge surfaces under its inverse when the node is the target (a location shows **residents**; the character shows **home_of**). **Infrastructure** - `Entry`/`Link` Fluent configs + `AddGraphEntryAndLink` migration. - **All FKs `Restrict`**: deletion stays app-managed (soft delete, ADR 0020), which also sidesteps SQLite's multiple-cascade-path error with `Link`'s three `Entry` FKs (from/to/at-timeline). - Unique `(FromId, ToId, Role)` index is the duplicate-edge safety net; `EfGraphStore` translates the real `SqliteException` into `TryAddLink=false` inside the adapter — EF never leaks upward (same pattern as the project slug). - Extracted the UTC-ticks `ValueConverter` to shared config (`UtcTicksConverter`) and repointed `ProjectConfiguration` at it — as flagged when it landed. ## Tests — 51, all green (was 25; +26) - **45 unit** (fake store): role vocabulary + label flipping, all six `LinkNodes` validations + same-pair-different-role, `GetNodeGraph` grouping/inverse-label/sorting/empty/not-found. - **6 integration** over real SQLite: backlink navigation (inbound under inverse label, outbound under forward), and a **real** duplicate-edge unique violation returning a failure rather than throwing. Extracted `SqliteBackedTest` so both integration suites share one migrated-SQLite fixture. The `Entry→Project` FK again earned the real-DB strategy its keep — it caught tests that scoped entries to a non-existent project. ## Verification - `dotnet build` — 0 warnings / 0 errors (warnings-as-errors). - `dotnet test` — 51/51 pass. - Fresh boot applies both migrations (`Projects`, `Entries`, `Links`), serves HTTP 200, clean log. ## Not in this PR (next slice) Soft delete + the `ChangeLog` journal / undo (ADR 0020), link removal/edit/re-point, and `Relationship` as a specialized edge (depends on the `Character` type). Backlink views will then also union `Relationship` and the typed structural refs (scene primary location / POV) per ADR 0019. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Introduces the structural heart of the knowledge base (ADR 0019): every linkable
thing is an Entry node, the associative graph is generic Link edges, and opening a
node yields its backlinks.

- Domain (Graph): EntryKind; Entry base node (Id/ProjectId/Kind/Title/Description/
  timestamps, non-sealed so concrete content types share its PK via TPT later);
  Link edge (From/To/Role/Note/AtTimelineEvent); LinkRole + LinkRoles seed
  vocabulary + LinkDirection with inverse/symmetric label resolution
- UseCases (Graph): IGraphStore port; NodeSummary/GraphEdgeView/NodeGraphView read
  models; LinkNodes (validates known role, distinct endpoints, both present, same
  project, no duplicate) and GetNodeGraph (unions inbound/outbound edges, groups by
  the label shown at this end so directed edges surface under their inverse)
- Infrastructure: Entry/Link Fluent configs + AddGraphEntryAndLink migration; all
  FKs Restrict (deletion stays app-managed per ADR 0020, and avoids SQLite's
  multiple-cascade-path issue with Link's three Entry FKs); unique (From,To,Role)
  index as the duplicate-edge safety net; EfGraphStore translates the real
  SqliteException into TryAddLink=false, never leaking EF upward. Extracted the
  UTC-ticks converter to shared config and repointed Project at it
- Tests: 26 new (45 unit + 6 integration). Extracted SqliteBackedTest so both
  integration suites share one migrated-SQLite fixture. Real SQLite again earned
  its keep — the Entry->Project FK caught tests that scoped entries to a
  non-existent project

Not here (next slice): soft delete + the ChangeLog journal (ADR 0020), link
removal/edit, and Relationship as a specialized edge (needs the Character type).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
bjoern force-pushed feat/graph-foundation from acb5b20c97 to 1c6b7a3583
All checks were successful
CI / build (pull_request) Successful in 9s
CI / test (pull_request) Successful in 15s
2026-07-09 14:30:18 +02:00
Compare

Summary

Summary
Generated on: 07/09/2026 - 12:53:07
Coverage date: 07/09/2026 - 12:53:04 - 07/09/2026 - 12:53:05
Parser: MultiReport (2x Cobertura)
Assemblies: 4
Classes: 33
Files: 30
Line coverage: 96.5% (748 of 775)
Covered lines: 748
Uncovered lines: 27
Coverable lines: 775
Total lines: 1363
Branch coverage: 85.8% (79 of 92)
Covered branches: 79
Total branches: 92
Method coverage: Feature is only available for sponsors

Coverage

Kagura.Domain - 95%
Name Line Branch
Kagura.Domain 95% 82.6%
Kagura.Domain.Graph.Entry 100% 100%
Kagura.Domain.Graph.Link 100% 100%
Kagura.Domain.Graph.LinkRole 100% 100%
Kagura.Domain.Graph.LinkRoles 92.3%
Kagura.Domain.Projects.Project 100%
Kagura.Domain.Projects.Slug 100% 100%
System.Text.RegularExpressions.Generated 90.2% 72.2%
System.Text.RegularExpressions.Generated.<RegexGenerator_g>FE06CC341D340484
D04ADFED3A21D401C2764A1D17367E35BEB556CBB3B4B0B74__NonSlugChars_0
89.4% 75%
Kagura.Infrastructure - 97.1%
Name Line Branch
Kagura.Infrastructure 97.1% 50%
Kagura.Infrastructure.DependencyInjection 100%
Kagura.Infrastructure.Graph.EfGraphStore 96% 50%
Kagura.Infrastructure.Persistence.Configurations.EntryConfiguration 100%
Kagura.Infrastructure.Persistence.Configurations.LinkConfiguration 100%
Kagura.Infrastructure.Persistence.Configurations.ProjectConfiguration 100%
Kagura.Infrastructure.Persistence.Converters.UtcTicksConverter 100%
Kagura.Infrastructure.Persistence.KaguraDbContext 100%
Kagura.Infrastructure.Persistence.KaguraDbContextFactory 0%
Kagura.Infrastructure.Persistence.Migrations.AddGraphEntryAndLink 97.7%
Kagura.Infrastructure.Persistence.Migrations.InitialCreate 94.4%
Kagura.Infrastructure.Persistence.Migrations.KaguraDbContextModelSnapshot 100%
Kagura.Infrastructure.Projects.EfProjectStore 100%
Kagura.Kernel - 90%
Name Line Branch
Kagura.Kernel 90% 75%
Kagura.Kernel.Err`1 100%
Kagura.Kernel.Ok`1 100%
Kagura.Kernel.Result`1 87.5% 75%
Kagura.UseCases - 96.5%
Name Line Branch
Kagura.UseCases 96.5% 94.1%
Kagura.UseCases.DependencyInjection 100%
Kagura.UseCases.Graph.EdgeGroup 100%
Kagura.UseCases.Graph.GetNodeGraph 96.4% 83.3%
Kagura.UseCases.Graph.GraphEdgeView 71.4%
Kagura.UseCases.Graph.LinkNodes 100% 100%
Kagura.UseCases.Graph.NodeGraphView 100%
Kagura.UseCases.Graph.NodeSummary 100%
Kagura.UseCases.Projects.CreateProject 100% 100%
Kagura.UseCases.Projects.ListProjects 100%
Kagura.UseCases.Projects.ProjectDto 100%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 07/09/2026 - 12:53:07 | | Coverage date: | 07/09/2026 - 12:53:04 - 07/09/2026 - 12:53:05 | | Parser: | MultiReport (2x Cobertura) | | Assemblies: | 4 | | Classes: | 33 | | Files: | 30 | | **Line coverage:** | 96.5% (748 of 775) | | Covered lines: | 748 | | Uncovered lines: | 27 | | Coverable lines: | 775 | | Total lines: | 1363 | | **Branch coverage:** | 85.8% (79 of 92) | | Covered branches: | 79 | | Total branches: | 92 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>Kagura.Domain - 95%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Domain**|**95%**|**82.6%**| |Kagura.Domain.Graph.Entry|100%|100%| |Kagura.Domain.Graph.Link|100%|100%| |Kagura.Domain.Graph.LinkRole|100%|100%| |Kagura.Domain.Graph.LinkRoles|92.3%|| |Kagura.Domain.Projects.Project|100%|| |Kagura.Domain.Projects.Slug|100%|100%| |System.Text.RegularExpressions.Generated|90.2%|72.2%| |System.Text.RegularExpressions.Generated.<RegexGenerator_g>FE06CC341D340484<br/>D04ADFED3A21D401C2764A1D17367E35BEB556CBB3B4B0B74__NonSlugChars_0|89.4%|75%| </details> <details><summary>Kagura.Infrastructure - 97.1%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Infrastructure**|**97.1%**|**50%**| |Kagura.Infrastructure.DependencyInjection|100%|| |Kagura.Infrastructure.Graph.EfGraphStore|96%|50%| |Kagura.Infrastructure.Persistence.Configurations.EntryConfiguration|100%|| |Kagura.Infrastructure.Persistence.Configurations.LinkConfiguration|100%|| |Kagura.Infrastructure.Persistence.Configurations.ProjectConfiguration|100%|| |Kagura.Infrastructure.Persistence.Converters.UtcTicksConverter|100%|| |Kagura.Infrastructure.Persistence.KaguraDbContext|100%|| |Kagura.Infrastructure.Persistence.KaguraDbContextFactory|0%|| |Kagura.Infrastructure.Persistence.Migrations.AddGraphEntryAndLink|97.7%|| |Kagura.Infrastructure.Persistence.Migrations.InitialCreate|94.4%|| |Kagura.Infrastructure.Persistence.Migrations.KaguraDbContextModelSnapshot|100%|| |Kagura.Infrastructure.Projects.EfProjectStore|100%|| </details> <details><summary>Kagura.Kernel - 90%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.Kernel**|**90%**|**75%**| |Kagura.Kernel.Err`1|100%|| |Kagura.Kernel.Ok`1|100%|| |Kagura.Kernel.Result`1|87.5%|75%| </details> <details><summary>Kagura.UseCases - 96.5%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagura.UseCases**|**96.5%**|**94.1%**| |Kagura.UseCases.DependencyInjection|100%|| |Kagura.UseCases.Graph.EdgeGroup|100%|| |Kagura.UseCases.Graph.GetNodeGraph|96.4%|83.3%| |Kagura.UseCases.Graph.GraphEdgeView|71.4%|| |Kagura.UseCases.Graph.LinkNodes|100%|100%| |Kagura.UseCases.Graph.NodeGraphView|100%|| |Kagura.UseCases.Graph.NodeSummary|100%|| |Kagura.UseCases.Projects.CreateProject|100%|100%| |Kagura.UseCases.Projects.ListProjects|100%|| |Kagura.UseCases.Projects.ProjectDto|100%|| </details>
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh my~ A knowledge-graph foundation! Entry nodes, directed Link edges, backlinks that surface under their inverse label... fufu~, the structure of this is beautiful ♡ The way GetNodeGraph flips directed edges to their inverse label depending on which end you're standing on — that's elegant domain modeling. I read every line and traced every query. The architecture is clean and follows your established patterns faithfully.

Verdict: I can't let this pass just yet~ ♡

These need fixing before I'm satisfied~

  1. Entry has zero direct entity tests — and Entry.Edit() + constructor guards + Normalize branches are all untested.
    Your own CI coverage bot confirms it: Kagura.Domain.Graph.Entry sits at 80.7% line / 50% branch — the worst branch coverage of any new class in this PR. The Edit() method (lines 55-62), the ArgumentException.ThrowIfNullOrWhiteSpace(title) guard in both the constructor and Edit, and the Normalize helper's three branches (null→null, whitespace→null, content→trimmed) are never exercised by any test.

    Why this blocks: your sibling Project entity has a full tests/Kagura.UseCases.Tests/Projects/ProjectTests.cs with dedicated tests for the exact same patternRename_changes_the_name_and_advances_UpdatedAt, Rename_rejects_a_blank_name, and constructor blank-input rejection theories. Entry is the direct analog (same shape: identity fields, Edit/Rename method, Normalize/trim, timestamp stamping) and it has no test file at all. New public domain logic with branches, untested, contradicting the established convention — that's all three of my blocking criteria at once, fufu~

    Fix: Add tests/Kagura.UseCases.Tests/Graph/EntryTests.cs mirroring ProjectTests.cs:

    • Edit changes title/description and advances UpdatedAt but keeps Id/ProjectId/Kind/CreatedAt
    • Edit rejects blank title (Theory: "", " ", null)
    • Constructor rejects blank title (Theory)
    • Constructor stamps CreatedAt == UpdatedAt
    • Normalize: description nullnull, " "null, " text ""text" (the content branch is the one CI says is uncovered)

    While you're at it: the Link entity's constructor guards (ThrowIfNullOrWhiteSpace(role), the fromId == toId self-link throw) are also untested at the entity level — they're defense-in-depth behind LinkNodes' pre-validation, so they never fire in the current suite (that's the uncovered branch in Link's 83.3%). A small LinkTests.cs covering the constructor invariants directly would close that gap the same way ProjectTests does. I won't die on this hill, but~ ♡

💡 Little ideas (non-blocking)~

  1. Normalize is duplicated verbatim between Entry.cs and Link.cs (identical 4-line private helper). Normally I'd flag DRY, but the domain layer is explicitly pure/dependency-free and this is a 3-line method — extracting a shared type would be over-engineering. Just noting it exists; if a third timestamped entity lands with the same helper, that's the signal to extract.

  2. Entry.Description is unbounded (no HasMaxLength) while Link.Note is capped at 1000 and Entry.Title at 300. If Description is meant to be free-form long-form, the lack of a cap is intentional and fine — but a one-line comment in EntryConfiguration saying "deliberately unbounded: long-form lore/description" would stop a future reader from "fixing" it. (Your code comment says // free-form, unbounded — that's actually enough! Forget I said anything~ ♪)

What I liked~

  • The EfGraphStore adapter is a near-perfect mirror of EfProjectStore — same SqliteConstraintUnique const, same try/catch-then-Detach pattern so the DbContext stays usable after a constraint violation, same "never leak EF upward" discipline. When a PR's new adapter is indistinguishable in quality from its sibling, that's a happy Jibril~ ♡
  • No TOCTOU on duplicate links. LinkNodes does not pre-check for duplicates — it relies entirely on the unique (FromId, ToId, Role) index via TryAddLinkAsync. The PR description even calls this out explicitly. Correct and race-safe.
  • The backlink label-flipping logic in GetNodeGraph.ToEdgeView is genuinely clever and well-tested — a location sees "residents" (inbound edges under inverse label), the character sees "home_of" (outbound under forward label). The grouping/sorting is deterministic and tested with the ["Hero", "Sidekick"] ordering assertion.
  • UtcTicksConverter extraction — moving the UTC-ticks ValueConverter from a private field in ProjectConfiguration to a shared, reusable class is exactly the right refactor, and repointing ProjectConfiguration at it keeps the change clean. No behavior change, just less duplication.
  • 51 tests, 95.7% line coverage, 0 warnings (warnings-as-errors). The test discipline here is strong overall — the Entry gap is the one blemish on an otherwise exemplary testing effort.
  • All FKs Restrict with a documented rationale (SQLite multiple-cascade-path + app-managed soft delete per ADR 0020). The migration, designer, and model snapshot are all three consistent.

Automated review by Jibril · 2026-07-09
CI/CD: passed for head SHA 1c6b7a3 (coverage bot: 95.7% line / 82.6% branch, 51 tests) · Local checks: skipped (CI green)

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh my~ A knowledge-graph foundation! Entry nodes, directed Link edges, backlinks that surface under their inverse label... fufu~, the structure of this is *beautiful* ♡ The way `GetNodeGraph` flips directed edges to their inverse label depending on which end you're standing on — that's elegant domain modeling. I read every line and traced every query. The architecture is clean and follows your established patterns faithfully. ### Verdict: ⛔ I can't let this pass just yet~ ♡ #### ⛔ These need fixing before I'm satisfied~ 1. **`Entry` has zero direct entity tests — and `Entry.Edit()` + constructor guards + `Normalize` branches are all untested.** Your own CI coverage bot confirms it: `Kagura.Domain.Graph.Entry` sits at **80.7% line / 50% branch** — the worst branch coverage of any new class in this PR. The `Edit()` method (lines 55-62), the `ArgumentException.ThrowIfNullOrWhiteSpace(title)` guard in both the constructor and `Edit`, and the `Normalize` helper's three branches (null→null, whitespace→null, content→trimmed) are never exercised by any test. Why this blocks: your sibling `Project` entity has a full `tests/Kagura.UseCases.Tests/Projects/ProjectTests.cs` with dedicated tests for the *exact same pattern* — `Rename_changes_the_name_and_advances_UpdatedAt`, `Rename_rejects_a_blank_name`, and constructor blank-input rejection theories. `Entry` is the direct analog (same shape: identity fields, `Edit`/`Rename` method, `Normalize`/trim, timestamp stamping) and it has **no test file at all**. New public domain logic with branches, untested, contradicting the established convention — that's all three of my blocking criteria at once, fufu~ **Fix:** Add `tests/Kagura.UseCases.Tests/Graph/EntryTests.cs` mirroring `ProjectTests.cs`: - `Edit` changes title/description and advances `UpdatedAt` but keeps `Id`/`ProjectId`/`Kind`/`CreatedAt` - `Edit` rejects blank title (Theory: `""`, `" "`, `null`) - Constructor rejects blank title (Theory) - Constructor stamps `CreatedAt` == `UpdatedAt` - `Normalize`: description `null`→`null`, `" "`→`null`, `" text "`→`"text"` (the content branch is the one CI says is uncovered) While you're at it: the `Link` entity's constructor guards (`ThrowIfNullOrWhiteSpace(role)`, the `fromId == toId` self-link throw) are also untested at the entity level — they're defense-in-depth behind `LinkNodes`' pre-validation, so they never fire in the current suite (that's the uncovered branch in `Link`'s 83.3%). A small `LinkTests.cs` covering the constructor invariants directly would close that gap the same way `ProjectTests` does. I won't die on this hill, but~ ♡ #### 💡 Little ideas (non-blocking)~ 1. **`Normalize` is duplicated verbatim** between `Entry.cs` and `Link.cs` (identical 4-line private helper). Normally I'd flag DRY, but the domain layer is explicitly pure/dependency-free and this is a 3-line method — extracting a shared type would be over-engineering. Just noting it exists; if a third timestamped entity lands with the same helper, that's the signal to extract. 2. **`Entry.Description` is unbounded** (no `HasMaxLength`) while `Link.Note` is capped at 1000 and `Entry.Title` at 300. If `Description` is meant to be free-form long-form, the lack of a cap is intentional and fine — but a one-line comment in `EntryConfiguration` saying "deliberately unbounded: long-form lore/description" would stop a future reader from "fixing" it. (Your code comment says `// free-form, unbounded` — that's actually enough! Forget I said anything~ ♪) #### ✅ What I liked~ - **The `EfGraphStore` adapter is a near-perfect mirror of `EfProjectStore`** — same `SqliteConstraintUnique` const, same try/catch-then-`Detach` pattern so the `DbContext` stays usable after a constraint violation, same "never leak EF upward" discipline. When a PR's new adapter is indistinguishable in quality from its sibling, that's a happy Jibril~ ♡ - **No TOCTOU on duplicate links.** `LinkNodes` does *not* pre-check for duplicates — it relies entirely on the unique `(FromId, ToId, Role)` index via `TryAddLinkAsync`. The PR description even calls this out explicitly. Correct and race-safe. - **The backlink label-flipping logic** in `GetNodeGraph.ToEdgeView` is genuinely clever and well-tested — a location sees "residents" (inbound edges under inverse label), the character sees "home_of" (outbound under forward label). The grouping/sorting is deterministic and tested with the `["Hero", "Sidekick"]` ordering assertion. - **`UtcTicksConverter` extraction** — moving the UTC-ticks `ValueConverter` from a private field in `ProjectConfiguration` to a shared, reusable class is exactly the right refactor, and repointing `ProjectConfiguration` at it keeps the change clean. No behavior change, just less duplication. - **51 tests, 95.7% line coverage, 0 warnings (warnings-as-errors).** The test discipline here is strong overall — the `Entry` gap is the one blemish on an otherwise exemplary testing effort. - **All FKs `Restrict` with a documented rationale** (SQLite multiple-cascade-path + app-managed soft delete per ADR 0020). The migration, designer, and model snapshot are all three consistent. --- *Automated review by Jibril · 2026-07-09* *CI/CD: passed for head SHA `1c6b7a3` (coverage bot: 95.7% line / 82.6% branch, 51 tests) · Local checks: skipped (CI green)*
test(graph): direct entity tests for Entry and Link
All checks were successful
CI / build (pull_request) Successful in 9s
CI / test (pull_request) Successful in 15s
570bd35dea
Addresses Jibril's review on PR #4 — Entry had no direct entity tests (Edit,
constructor guards, Normalize branches all uncovered: 80.7% line / 50% branch),
unlike its sibling Project's ProjectTests.

- EntryTests mirrors ProjectTests: Edit changes title/description + advances
  UpdatedAt while keeping Id/ProjectId/Kind/CreatedAt; Edit and constructor reject
  blank titles; timestamps stamped equal; Description trim/normalize-to-null across
  all three branches.
- LinkTests covers the constructor invariants directly (blank-role rejection,
  self-link rejection, Note trim/normalize) — the defense-in-depth guards behind
  LinkNodes that the suite never fired.

+19 tests (45 -> 64 unit; 70 total). Build clean, all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Member

Fair catch — I tested Project but left its direct analog Entry uncovered. Fixed in 570bd35.

Blocker — Entry entity tests

Added tests/Kagura.UseCases.Tests/Graph/EntryTests.cs, mirroring ProjectTests:

  • Edit changes title/description and advances UpdatedAt while keeping Id/ProjectId/Kind/CreatedAt
  • Edit and the constructor reject a blank title (theories: "", " ", null)
  • constructor stamps CreatedAt == UpdatedAt
  • Description trim/normalize across all three branches — nullnull, " "null, " lore text ""lore text" (the content branch CI flagged)

Took the nudge: LinkTests.cs exercises the invariants directly — blank-role rejection, self-link rejection, and Note trim/normalize — the defense-in-depth guards behind LinkNodes that the suite never fired.

Non-blockers

Left as-is, per your own conclusions: Normalize stays duplicated (3-line pure helper; extract only when a third entity wants it), and Entry.Description keeps its // free-form, unbounded comment.

+19 tests (45 → 64 unit; 70 total). Build clean (0 warnings), CI green on 570bd35 (build / test ). Ready for another look.

Fair catch — I tested `Project` but left its direct analog `Entry` uncovered. Fixed in `570bd35`. ### ⛔ Blocker — `Entry` entity tests Added `tests/Kagura.UseCases.Tests/Graph/EntryTests.cs`, mirroring `ProjectTests`: - `Edit` changes title/description and advances `UpdatedAt` while keeping `Id`/`ProjectId`/`Kind`/`CreatedAt` - `Edit` and the constructor reject a blank title (theories: `""`, `" "`, `null`) - constructor stamps `CreatedAt == UpdatedAt` - `Description` trim/normalize across all three branches — `null`→`null`, `" "`→`null`, `" lore text "`→`"lore text"` (the content branch CI flagged) ### Also closed — `Link` constructor guards Took the nudge: `LinkTests.cs` exercises the invariants directly — blank-role rejection, self-link rejection, and `Note` trim/normalize — the defense-in-depth guards behind `LinkNodes` that the suite never fired. ### Non-blockers Left as-is, per your own conclusions: `Normalize` stays duplicated (3-line pure helper; extract only when a third entity wants it), and `Entry.Description` keeps its `// free-form, unbounded` comment. **+19 tests (45 → 64 unit; 70 total).** Build clean (0 warnings), CI green on `570bd35` (build ✅ / test ✅). Ready for another look.
bjoern merged commit 779d57a66b into main 2026-07-09 14:55:48 +02:00
bjoern deleted branch feat/graph-foundation 2026-07-09 14:55:48 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
4 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/Kagura!4
No description provided.