Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
edc4732
feat(schema): add scope and imports to the v1 format
jbeda Jul 26, 2026
2546612
feat(lint): resolve local imports and qualified attribute types
jbeda Jul 26, 2026
840a9e1
docs: document scope, imports, and cross-model references
jbeda Jul 26, 2026
9399bdd
feat(schema)!: bind an import's scope in the importer, not the import…
jbeda Jul 26, 2026
3dfba31
docs(adr): record ADR-0012 and align the schema reference
jbeda Jul 26, 2026
e779cb1
docs(example): reference a peer payments model from the parking garage
jbeda Jul 26, 2026
df2616e
fix(imports): escape import paths and tighten the reference diagnostics
jbeda Jul 26, 2026
83cfc2c
feat(lint)!: confine import resolution to the enclosing repository
jbeda Jul 26, 2026
8f62920
fix(lint): take the resolution root from the model's real directory
jbeda Jul 26, 2026
8c5e729
refactor(lint): put import containment behind the file seam
jbeda Jul 26, 2026
ffcd1fd
fix(lint): report the imports layer alongside a qualified entity refe…
jbeda Jul 26, 2026
7c42c45
fix(model): derive an import scope from a .modelith.yml file too
jbeda Jul 26, 2026
4935a3a
feat(schema): mark a model shared so its vocabulary may be unused loc…
jbeda Jul 26, 2026
44571aa
docs(schema): say a dot in an attribute type is reserved
jbeda Jul 26, 2026
1318c66
fix(render): stop labelling the imports link with the source path
jbeda Jul 26, 2026
f9cd4f6
docs(parking-garage): say what shared: true is for where it comes up
jbeda Jul 26, 2026
6fdbd62
fix(render): relativize import links against the output directory
jbeda Jul 27, 2026
01c3899
fix(lint): make entity-position cross-model refs honest with the impo…
jbeda Jul 27, 2026
7cff4cf
docs(plugin): bring the skills up to the imports/shared contract
jbeda Jul 27, 2026
8de4124
docs(plugin): add two import error classes to domain-model-lint's list
jbeda Jul 27, 2026
bf84a8b
fix(lint): assign the named return instead of shadowing it
jbeda Jul 27, 2026
e93ab27
fix(render): don't relativize an absolute import path
jbeda Jul 27, 2026
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
29 changes: 20 additions & 9 deletions cmd/modelith/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ func lintCmd() *cobra.Command {
if err != nil {
return fmt.Errorf("%s: %w", path, err)
}
res, err := lint.Run(data)
res, err := lint.Run(path, data, lint.OSFiles{})
if err != nil {
return fmt.Errorf("%s: %w", path, err)
}
Expand Down Expand Up @@ -223,9 +223,17 @@ func renderCmd() *cobra.Command {
if err != nil {
return err
}
rendered := markdown.Render(m)

sourceDir, err := filepath.Abs(filepath.Dir(in))
if err != nil {
return fmt.Errorf("resolving %s: %w", in, err)
}

if stdout {
// There is no output file to relativize import links against, so
// they stay relative to the source — the same links a default,
// beside-the-source render would produce.
rendered := markdown.Render(m, sourceDir, sourceDir)
_, err := fmt.Fprint(cmd.OutOrStdout(), rendered)
return err
}
Expand All @@ -234,6 +242,11 @@ func renderCmd() *cobra.Command {
if target == "" {
target = defaultOut(in)
}
outDir, err := filepath.Abs(filepath.Dir(target))
if err != nil {
return fmt.Errorf("resolving %s: %w", target, err)
}
rendered := markdown.Render(m, sourceDir, outDir)

if check {
existing, err := os.ReadFile(target)
Expand Down Expand Up @@ -263,13 +276,11 @@ func renderCmd() *cobra.Command {
return cmd
}

func defaultOut(in string) string {
ext := filepath.Ext(in)
if ext == ".yaml" || ext == ".yml" {
return strings.TrimSuffix(in, ext) + ".md"
}
return in + ".md"
}
// defaultOut is where render writes when no -o is given: a model's .md beside
// its .yaml source. It is the rendered location an import link assumes for the
// model it names (see importLinkTarget in internal/render/markdown) — a link
// resolves only when the imported model was, in fact, rendered here.
func defaultOut(in string) string { return model.RenderedPath(in) }

// ---- schema ----

Expand Down
70 changes: 70 additions & 0 deletions cmd/modelith/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,76 @@ func TestRenderStdoutDoesNotWriteFile(t *testing.T) {
}
}

// TestRenderOutFlagRelativizesImportLinks pins the R2-1 fix at the CLI level:
// `render -o <dir>/x.md` used to emit import links relative to the source
// directory, so a link built for `-o` landed at a path that didn't exist next
// to the file `-o` actually wrote. The output directory here (a fresh temp
// dir) shares no ancestor with the source's temp dir short of the OS temp
// root, so a fix that only handles a common-prefix case would still fail
// this.
func TestRenderOutFlagRelativizesImportLinks(t *testing.T) {
srcDir := t.TempDir()
const payments = `kind: DomainModel
version: v1
entities:
Payment:
definition: A payment.
enums:
PaymentMethod:
values:
- {name: card, definition: A card payment.}
`
paymentsYAML := writeTemp(t, srcDir, "payments.modelith.yaml", payments)
if _, err := run(t, "render", paymentsYAML); err != nil {
t.Fatalf("rendering the imported model failed: %v", err)
}

const main = `kind: DomainModel
version: v1
imports:
- ./payments.modelith.yaml
entities:
Ticket:
definition: A parking ticket.
attributes:
- {name: paidWith, type: payments.PaymentMethod, description: how the fee was paid}
`
mainYAML := writeTemp(t, srcDir, "main.modelith.yaml", main)

outDir := t.TempDir() // a distinct temp dir: no shared ancestor but the OS temp root.
target := filepath.Join(outDir, "rendered.md")
if _, err := run(t, "render", "-o", target, mainYAML); err != nil {
t.Fatalf("render -o failed: %v", err)
}
rendered, err := os.ReadFile(target)
if err != nil {
t.Fatalf("expected %s to be written: %v", target, err)
}

link := importLinkFromMarkdown(t, string(rendered))
resolved := filepath.Join(filepath.Dir(target), link)
if _, err := os.Stat(resolved); err != nil {
t.Fatalf("import link %q resolved to %s, which does not exist: %v\nrendered output:\n%s", link, resolved, err, rendered)
}
}

// importLinkFromMarkdown extracts the destination of the single "[rendered](...)"
// link an Imports section emits, failing the test if it can't find exactly one.
func importLinkFromMarkdown(t *testing.T, md string) string {
t.Helper()
const marker = "([rendered]("
start := strings.Index(md, marker)
if start < 0 {
t.Fatalf("no import link found in:\n%s", md)
}
start += len(marker)
end := strings.IndexByte(md[start:], ')')
if end < 0 {
t.Fatalf("unterminated import link in:\n%s", md)
}
return md[start : start+end]
}

func TestRenderInvalidFileGivesFriendlyError(t *testing.T) {
// Missing the required `definition`, so structural validation fails.
const invalid = `kind: DomainModel
Expand Down
7 changes: 7 additions & 0 deletions docs/05-parking-garage/garage.modelith.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@

Tracks the state of a single parking garage: its spots, the cars that occupy them, and the two ways a car enters — a monthly parker's keycard or a temporary visitor's pay-on-exit ticket.

## Imports

Items defined in these models are referenced below as `scope.Name`.

- **`payments`** — `./payments.modelith.yaml` ([rendered](./payments.modelith.md))

## Glossary

- **`Driver`** — A person who enters and exits using a monthly `Account`'s `Keycard`. The human behind a `Car`; not modeled as an entity.
Expand Down Expand Up @@ -178,6 +184,7 @@ A temporary entry credential issued at the gate to a visitor with no `Account`.
| `issuedAt` | timestamp | |
| `amountDue` | integer | Fee owed, in the smallest currency unit. _Derived:_ Computed at payment time from the parking duration (issue time to now) and the `Garage`'s rate schedule. |
| `paidAt` | timestamp | When the `Ticket` was paid; unset until then. |
| `paidWith` | [payments.PaymentMethod](./payments.modelith.md#paymentmethod) | How the fee was settled at the `Kiosk`; unset until paid. The garage does not define what a payment method is — the payments model does. |
| `paid` | boolean | _Derived:_ True once `paidAt` is set. |

**Actions**
Expand Down
8 changes: 8 additions & 0 deletions docs/05-parking-garage/garage.modelith.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ description: >-
them, and the two ways a car enters — a monthly parker's keycard or a
temporary visitor's pay-on-exit ticket.

imports:
- ./payments.modelith.yaml

glossary:
Operator: >-
Garage staff or management who open `Account`s, issue `Keycard`s, register
Expand Down Expand Up @@ -245,6 +248,11 @@ entities:
- name: paidAt
type: timestamp
description: When the `Ticket` was paid; unset until then.
- name: paidWith
type: payments.PaymentMethod
description: >-
How the fee was settled at the `Kiosk`; unset until paid. The garage
does not define what a payment method is — the payments model does.
- name: paid
type: boolean
derived: true
Expand Down
64 changes: 64 additions & 0 deletions docs/05-parking-garage/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,3 +231,67 @@ the source.
- **[View the full source YAML →](https://github.com/stacklok/modelith/blob/main/docs/05-parking-garage/garage.modelith.yaml)**

Want to try it yourself? See [Authoring with Agents](../02-getting-started.md).

## Advanced: borrowing a concept from another model

:::note[Advanced]

Skip this until you have two models. One model is the normal case, and
everything above works without it.

:::

Late in the session, one more concept turned up — and it did **not** belong to
the garage:

> **You:** The kiosk takes card or cash. Add a payment method to the ticket.
>
> **Agent:** Happy to, but a payment method isn't a parking concept — it's the
> same list your billing system already has, and the moment both models define
> it, they start to drift. Is there another model that owns money?

> **You:** There's a small payments model, yes.

Rather than a second copy of the same list, the garage **references** the one in
the payments model. The payments model needs no changes at all — it doesn't know
it is being referenced:

```yaml
# payments.modelith.yaml — an ordinary model that happens to define the enum
enums:
PaymentMethod:
values:
- name: card
- name: cash
- name: account
```

The garage lists the file and writes `scope.Name` at the reference site. The
scope comes from the filename, so `./payments.modelith.yaml` is written
`payments.`:

```yaml
# garage.modelith.yaml
imports:
- ./payments.modelith.yaml

# ... in Ticket:
- name: paidWith
type: payments.PaymentMethod
```

The linter now resolves that reference — a typo like `payments.PaymentMode` is
an error, not a shrug — and the [rendered model](./garage.modelith.md) links
`paidWith` straight to the enum in the
[payments model](./payments.modelith.md). One definition, in the context that
owns it.

The one thing the payments model *may* want to say is that it is on the
receiving end of this. A model whose enums are used only by the models that
import it collects an "enum is defined but no attribute uses it" advisory for
each one, since the uses are in files it cannot see. `shared: true` at the top
level retires that class of advisory — and nothing else. This one declares it,
though its own `Payment` happens to use the enum too.

Full rules, including how to name a scope explicitly when the filename won't do:
[Imports](../06-schema-reference.md#imports).
112 changes: 112 additions & 0 deletions docs/05-parking-garage/payments.modelith.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
{/* Generated by `modelith render`. Do not edit by hand; edit the .modelith.yaml source and re-render. */}

# Payments

How money is taken and given back. A deliberately small context that owns the vocabulary of settling a charge — nothing in it knows what was bought, which is why the parking-garage model can reference its `PaymentMethod` instead of keeping a second copy of the same list.

## Glossary

- **`Merchant`** — Whoever took the money and can give it back. Issues a `Refund`; a role, not modeled as an entity.
- **`Payer`** — Whoever hands over the money for a `Payment` — a person at a terminal, or a standing arrangement charged automatically. A role, not modeled as an entity.

## Enums

### `PaymentMethod`

How a `Payment` was tendered.

| Value | Definition |
| --- | --- |
| `card` | A payment card, tapped or inserted at a terminal. |
| `cash` | Notes or coins, accepted by a machine or by a person. |
| `account` | Charged to a standing arrangement and settled later, so the `Payer` is billed rather than paying at the moment of the `Payment`. |

## Entities

### `Payment`

One completed transfer of money for one charge: an amount, the method it was tendered with, and the moment it succeeded. A `Payment` records a transfer that settled — a declined card produces no `Payment` — and its amount never changes afterwards; money given back is a `Refund`.

**Relationships**

- `Refund` — 1:n — owned — Money returned from this `Payment`. There may be several partial ones, and none at all is the ordinary case.

**Attributes**

| Name | Type | Description |
| --- | --- | --- |
| `amount` | integer | What was taken, in the smallest currency unit. |
| `method` | PaymentMethod | |
| `settledAt` | timestamp | When the transfer succeeded. |
| `refundedTotal` | integer | _Derived:_ The sum of the amounts of this `Payment`'s `Refund`s. |

**Actions**

- `take` — actor `Payer`; preserves payment-amount-positive — Tender the amount and, on success, record the `Payment`.

**Invariants**

- **payment-amount-positive** — A `Payment`'s amount is greater than zero.
- **payment-method-fixed** — A `Payment`'s method is fixed once it has settled; paying a different way is a new `Payment`.

### `Refund`

Money returned from one `Payment`, in whole or in part. A `Refund` belongs to exactly one `Payment` and is always given back by the method that `Payment` used, so it carries no method of its own.

**Attributes**

| Name | Type | Description |
| --- | --- | --- |
| `amount` | integer | What was returned, in the smallest currency unit. |
| `issuedAt` | timestamp | |

**Actions**

- `issue` — actor `Merchant`; preserves refund-within-payment, refund-after-settlement

**Invariants**

- **refund-within-payment** — The amounts of a `Payment`'s `Refund`s never total more than that `Payment`'s amount.
- **refund-after-settlement** — A `Refund` is issued at or after its `Payment` settled.

## Relationships

```mermaid
erDiagram
Payment {}
Refund {}
Payment ||--o{ Refund : ""
```

## Scenarios

### A charge is settled by card

**Actors:** Payer, Payment

**Steps**

1. A `Payer` is asked for an amount and taps a card at the terminal.
2. The card is accepted, so a `Payment` records the amount, the method `card`, and the moment it settled.
3. Asked later to pay the same charge by cash instead, the system declines: the settled `Payment`'s method does not change.

**Invariants touched**

- **payment-amount-positive** — A `Payment`'s amount is greater than zero.
- **payment-method-fixed** — A `Payment`'s method is fixed once it has settled; paying a different way is a new `Payment`.

### Part of a charge is given back

**Actors:** Merchant, Payment, Refund

**Steps**

1. A settled `Payment` of 500 exists, with nothing returned from it yet.
2. The `Merchant` issues a `Refund` of 200 against it; the `Payment`'s refunded total becomes 200.
3. A second `Refund` of 400 is attempted; the system refuses it, because the two together would exceed the `Payment`.

**Invariants touched**

- **refund-within-payment** — The amounts of a `Payment`'s `Refund`s never total more than that `Payment`'s amount.
- **refund-after-settlement** — A `Refund` is issued at or after its `Payment` settled.

Loading
Loading