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
8 changes: 7 additions & 1 deletion docs/04-reading-the-diagrams.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ diagram is the structure; the text is the detail.**

The one exception is an entity related to *itself*, which appears as a row
inside its own box — see [Self-relationships](#self-relationships-live-inside-the-box).
A qualified relationship target from a direct import also appears as a node,
labelled with its full `scope.Entity` name. It represents the external entity
only; its attributes and relationships remain in the imported model's rendered
document.

## The lines: relationships and cardinality

Expand Down Expand Up @@ -201,7 +205,9 @@ them in the text:
`Owner`"* is an **invariant**, listed under the entity — not something the
crow's foot captures.
- **Attributes, derived values, and enums** are in the per-entity tables and the
Enums section.
Enums section. A qualified `subtypeOf` is the narrow exception: it appears as
a `subtypeOf "scope.Entity"` row in the local entity's Mermaid box because
Mermaid ER has no generalization edge.
- **Actions** (what can be done to an entity, and which invariants they
preserve) are listed per entity.

Expand Down
29 changes: 19 additions & 10 deletions docs/06-schema-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ Each key under `entities` is the entity's canonical name (PascalCase, e.g.
| Field | Type | Required | Notes |
|---|---|---|---|
| `definition` | string | yes | Two to four sentences: what it is, what it is not. |
| `subtypeOf` | string | no | Names the entity this one is a kind of (an is-a link). Must reference a defined entity. |
| `subtypeOf` | string | no | Names the entity this one is a kind of (an is-a link). May name a defined local entity or an entity in a direct import as `scope.Entity`. |
| `relationships` | list | no | See [Relationship](#relationship). |
| `attributes` | list | no | See [Attribute](#attribute). |
| `actions` | list | no | Mutations the system exposes. See [Action](#action). |
Expand All @@ -115,20 +115,23 @@ versions in play, so the ER stays a deliberately lossy view; the Markdown text
is the source of truth.

Use `subtypeOf` for generalization — when one entity *is a kind of* another
(a `Card` is a `PaymentMethod`). The child declares it, and it must name a
defined entity; the linter errors on an undefined parent or a cycle. A parent's
invariants are understood to cover its subtypes, so a subtype that adds no rule
of its own is not flagged for having no invariants. The Mermaid ER diagram does
not draw the is-a link — erDiagram has no generalization notation, so the
hierarchy lives in the rendered Markdown (each child names its supertype and
each parent lists its subtypes), a deliberately lossy ER per the same principle
as derived entities.
(a `Card` is a `PaymentMethod`). The child declares it. The parent may be a
local entity or a direct import qualified as `scope.Entity`; the latter must
resolve to an entity in that import. The linter errors on an undefined local
parent, missing imported parent, or a cycle among local entities. A local
parent's invariants are understood to cover its subtypes, so a subtype that adds
no rule of its own is not flagged for having no invariants. An imported parent
is a boundary: modelith does not walk its ancestry or inherit its invariants.
The Mermaid ER diagram does not draw the is-a link — erDiagram has no
generalization notation, so the hierarchy lives in the rendered Markdown (each
child names its supertype and each local parent lists its subtypes), a
deliberately lossy ER per the same principle as derived entities.

## Relationship

| Field | Type | Required | Notes |
|---|---|---|---|
| `entity` | string | yes | Target entity name. Must reference a defined entity. |
| `entity` | string | yes | Target entity name. Must reference a defined local entity or an entity in a direct import as `scope.Entity`. |
| `cardinality` | string | yes | Written `left:right` (see below). `1:1`, `1:n`, `n:1`, `n:n` are the common shorthands. |
| `symmetric` | boolean | no | The relationship carries no inherent order: `(a, b)` is the same as `(b, a)`. Only valid on a self-referential relationship or one whose target side is more than one. |
| `role` | string | no | The **short** role the related entity plays (`Owner`, `Predecessor`) — ideally a glossary term. Backtick entity and glossary names. It is the only label the diagram draws, so prose belongs in `note`; the linter warns on a role that reads as a sentence. |
Expand All @@ -150,6 +153,12 @@ invert to themselves). The linter errors on a contradiction, and the renderer co
a matching pair into a single edge. Declaring it once is fine; the renderer
shows the edge either way.

A relationship may target an entity from a direct import as `scope.Entity`. The
linter validates that imported entity exists, and the renderer shows it as a
qualified external node. Validation stops at the import boundary: reciprocity,
pairing, and mutual-ownership checks apply only to relationships declared in
this model, even when the local declaration uses `ownership: owned`.

When there's an intuitive **parent** — the entity that owns or contains the
other, or sits on the "one" side of a one-to-many — prefer declaring the
relationship there (e.g. on `Project`, not `Policy`). It keeps each link in one
Expand Down
123 changes: 52 additions & 71 deletions internal/lint/imports.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,26 +86,23 @@ type importLoadFailure struct {
version string
}

// runImports resolves the model's imports, checks every qualified attribute
// type against them, and reports an import nothing references.
//
// modelPath is the path of the model being linted; imports resolve relative to
// its directory. entityScopes are the scopes named by a cross-model reference
// in an entity position (relationship.entity, subtypeOf) — unsupported there,
// but still a real reference: an import bound to one of them is not also
// reported as unreferenced (see reportQualifiedEntityRefs).
// runImports resolves the model's imports, checks every qualified reference
// against them, and reports an import nothing references.
//
// vendored says the model is a copy whose home is another repository, which
// silences the errors its imports list would raise here (see loadImports).
func runImports(modelPath string, m *model.Model, files Files, res *Result, entityScopes map[string]bool, vendored bool) {
func runImports(modelPath string, m *model.Model, files Files, res *Result, vendored bool) {
byScope, claimed := loadImports(modelPath, m, files, res, vendored)
used := checkQualifiedTypes(m, byScope, claimed, res)
for scope := range checkQualifiedEntities(m, byScope, claimed, res) {
used[scope] = true
}
// An unreferenced import is a completeness finding, alongside the unused
// enum and the unused glossary term: vocabulary the model declares and
// nothing uses. Sharing their category means sharing their promotion under
// --completeness error.
for _, scope := range sortedMapKeys(byScope) {
if used[scope] || entityScopes[scope] {
if used[scope] {
continue
}
imp := byScope[scope]
Expand Down Expand Up @@ -326,6 +323,51 @@ func checkQualifiedTypes(m *model.Model, byScope map[string]importedModel, claim
return used
}

// checkQualifiedEntities resolves qualified relationship targets and subtype
// parents against direct imports. Their imported semantics end at the boundary:
// local reciprocity, ownership, and subtype traversal do not inspect that model.
func checkQualifiedEntities(m *model.Model, byScope map[string]importedModel, claimed map[string]string, res *Result) map[string]bool {
used := map[string]bool{}
check := func(path, ref, kind string) {
match := qualifiedRefRE.FindStringSubmatch(ref)
if match == nil {
return
}
scope, item := match[1], match[2]
imp, ok := byScope[scope]
if !ok {
if _, listed := claimed[scope]; listed {
used[scope] = true
return
}
res.Findings = append(res.Findings, Finding{
Severity: SeverityError,
Category: CategorySemantic,
Path: path,
Message: fmt.Sprintf("%s %q references the scope %q, which no import binds — add the model that defines %s to `imports:`", kind, ref, scope, item),
})
return
}
used[scope] = true
if _, ok := imp.model.Entities[item]; !ok {
res.Findings = append(res.Findings, Finding{
Severity: SeverityError,
Category: CategorySemantic,
Path: path,
Message: fmt.Sprintf("%s %q names no entity %q in %q — check the name, or whether you meant to import a different model", kind, ref, item, imp.path),
})
}
}
for _, name := range m.EntityNames() {
ent := m.Entities[name]
check(fmt.Sprintf("/entities/%s/subtypeOf", name), ent.SubtypeOf, "subtype parent")
for i, rel := range ent.Relationships {
check(fmt.Sprintf("/entities/%s/relationships/%d/entity", name, i), rel.Entity, "relationship target")
}
}
return used
}

// unresolvedItemMessage explains a qualified type whose scope resolved but
// whose item is not there.
//
Expand Down Expand Up @@ -394,64 +436,3 @@ func malformedRefReason(typ string) string {
return fmt.Sprintf("the item name %q is not PascalCase", item)
}
}

// reportQualifiedEntityRefs reports a cross-model reference in an entity
// position — relationship.entity or subtypeOf. It returns the instance paths
// it reported, so the schema's own finding for the same value is suppressed,
// and the scopes those references named, so an import that exists to support
// one of them is not also reported as unreferenced (runImports) even though no
// attribute type resolves it.
//
// Both fields carry pattern ^[A-Z][A-Za-z0-9]+$, so "payments.Card" already
// fails validation with a message about a pattern. This says what is actually
// wrong, in the spirit of the unsupported-version check. Cross-model entity
// references are deferred, not planned against: ADR-0010 records why.
func reportQualifiedEntityRefs(inst any, res *Result) (reported map[string]bool, scopes map[string]bool) {
reported = map[string]bool{}
scopes = map[string]bool{}
doc, ok := inst.(map[string]any)
if !ok {
return reported, scopes
}
entities, ok := doc["entities"].(map[string]any)
if !ok {
return reported, scopes
}
report := func(path, value string) {
reported[path] = true
scope, _, _ := strings.Cut(value, ".")
scopes[scope] = true
res.Findings = append(res.Findings, Finding{
Severity: SeverityError,
Category: CategoryStructural,
Path: path,
Message: fmt.Sprintf(
"%q is a cross-model reference, which is not supported in an entity position — only an attribute `type` can be qualified as scope.Name",
value,
),
})
}
for _, name := range sortedMapKeys(entities) {
ent, ok := entities[name].(map[string]any)
if !ok {
continue
}
if parent, ok := ent["subtypeOf"].(string); ok && qualifiedRefRE.MatchString(parent) {
report(fmt.Sprintf("/entities/%s/subtypeOf", name), parent)
}
rels, ok := ent["relationships"].([]any)
if !ok {
continue
}
for i, r := range rels {
rel, ok := r.(map[string]any)
if !ok {
continue
}
if target, ok := rel["entity"].(string); ok && qualifiedRefRE.MatchString(target) {
report(fmt.Sprintf("/entities/%s/relationships/%d/entity", name, i), target)
}
}
}
return reported, scopes
}
Loading
Loading