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
2 changes: 1 addition & 1 deletion .claude/rules/esdm.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ These are deliberate product decisions, not gaps. Do not "fix" them.

## Linter Rules

- Rules do not set `RuleID` or `Severity`; the runner stamps both from `Meta()`.
- Rules do not set `RuleID`, `Severity`, or `DocumentationURL`; the runner stamps all three from `Meta()`.
- RuleID format: `esdm/<category>/<name>`. Allowed categories: `structure`, `naming`, `modeling`, `linguistic`, `system`, `gwt` (rules over the given-when-then extension).
- Severity is fixed per rule, never per finding.
- A new rule is a new file in `rules/` with a `newXxxRule()` constructor and an entry in `Catalog()`.
Expand Down
17 changes: 12 additions & 5 deletions diag/diagnostic.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,19 @@ type Related struct {
// through the esdm linter pipeline. It is produced by the
// parser, resolver, and rules, and consumed by the
// reporter's formatters.
//
// DocumentationURL is the address of the finding's entry
// in the published documentation. Producers leave it
// empty; the runner fills it in for every diagnostic, so
// that formatters can point the reader at the explanation
// without knowing how the documentation is laid out.
type Diagnostic struct {
RuleID string
Severity Severity
Message string
Location Location
Related []Related
RuleID string
Severity Severity
Message string
Location Location
Related []Related
DocumentationURL string
}

// Reporter collects Diagnostics during a linter run.
Expand Down
8 changes: 5 additions & 3 deletions documentation/docs/getting-started/running-esdm-lint.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,23 @@ To lint a directory other than the current one, pass `-d` (or `--directory`). Th

## Reading a Finding

When the linter has something to say, it groups the output around four pieces of information per finding: the **severity**, the **ID of the rule** that threw it, the **location** (file, line, column), and a one-line **message** that explains what's wrong.
When the linter has something to say, it groups the output around five pieces of information per finding: the **severity**, the **ID of the rule** that threw it, the **location** (file, line, column), a one-line **message** that explains what's wrong, and a **link** to the rule's entry in the documentation.

For example, if the `state` field on `book.esdm.yaml` is renamed to `stateOmitted`, the linter reports:

```text
error: esdm/structure/missing-required-field
at book.esdm.yaml:1:1
missing required field "state"
see https://www.esdm.io/reference/linter-rules/#structure-missing-required-field

error: esdm/structure/unknown-field
at book.esdm.yaml:11:3
unknown field "stateOmitted"
see https://www.esdm.io/reference/linter-rules/#structure-unknown-field
```

Multiple findings on the same file are reported in source order. The location format is `<file>:<line>:<column>`, which most editors and CI log viewers render as a clickable link. Every rule ID has an entry on the **[Linter Rules](/reference/linter-rules.md)** page that says what the rule checks and why, so the ID is what to look up when the message alone does not tell you what to change.
Multiple findings on the same file are reported in source order. The location format is `<file>:<line>:<column>`, which most editors and CI log viewers render as a clickable link. The `see` line points at the rule's entry on the **[Linter Rules](/reference/linter-rules.md)** page, which says what the rule checks and why; follow it when the message alone does not tell you what to change.

## Severity and Exit Code

Expand All @@ -60,7 +62,7 @@ For tooling, editor integrations, or CI pipelines that want to parse findings ra
./esdm lint --format json
```

Each finding is emitted as a single JSON object on its own line. The shape carries the same severity, location, and message that the human format prints, plus a stable identifier for the rule that flagged the finding. Stream the output, group it, or post-process it as you prefer.
The output is a JSON array with one object per finding. The shape carries the same severity, location, message, and rule ID that the human format prints, plus `documentationUrl`, the address of the rule's entry on the Linter Rules page. Parse it, group it, or post-process it as you prefer.

## Linting in CI

Expand Down
4 changes: 2 additions & 2 deletions documentation/docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,13 @@ esdm lint [flags]

`esdm lint` walks a directory and lints every `.esdm.yaml` file it finds. The directory is selected with `-d` / `--directory`, defaulting to the current working directory. The walk is recursive; the linter does not stop at sub-directory boundaries.

The output format is selected with `--format`. The default is `human`, which produces a readable report grouped by file, with severity-colored headers and source excerpts. The alternative is `json`, which produces a machine-readable stream of one diagnostic per line, suitable for CI pipelines and editor integrations. The `--color` flag controls human-output coloring: `auto` detects the terminal capability, `always` forces colors on, and `never` suppresses them. The flag is ignored when `--format` is `json`.
The output format is selected with `--format`. The default is `human`, which produces a readable report grouped by file, with severity-colored headers and source excerpts. The alternative is `json`, which produces a machine-readable JSON array with one object per finding, suitable for CI pipelines and editor integrations. The `--color` flag controls human-output coloring: `auto` detects the terminal capability, `always` forces colors on, and `never` suppresses them. The flag is ignored when `--format` is `json`.

The optional `--warnings-as-errors` flag escalates warning-severity findings to errors for exit-code purposes only. The output and the formatted findings are unchanged; the flag affects nothing other than whether a warning-only run exits with `0` or with a non-zero status. Default is off.

**The exit code reflects the model's correctness rather than the run's success.** `esdm lint` exits with `0` when the model is clean and with a non-zero status when at least one finding has severity `error`. Findings of severity `warning` are reported but do not by themselves cause a non-zero exit, unless `--warnings-as-errors` is set – then a single warning is enough.

Every rule, its ID, and its severity is listed on **[Linter Rules](/reference/linter-rules.md)**.
Every rule, its ID, and its severity is listed on **[Linter Rules](/reference/linter-rules.md)**, and every finding carries the address of its entry there: the human format prints it as a trailing `see` line, the JSON format as `documentationUrl`.

## `esdm update-schema`

Expand Down
2 changes: 1 addition & 1 deletion documentation/docs/reference/linter-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

`esdm lint` checks a model in two stages. First, the parser and the resolver make sure the documents are well-formed: valid YAML, a known `apiVersion`, the shape the schema prescribes, and references that point at declared elements. Only when that stage reports no error do the rules run. A rule looks at the resolved model as a whole and throws when it finds something the schema cannot express: an Event that nothing publishes, a Feature that names a Command the model does not have, two domain types that share a name.

Every finding carries the ID of the rule that threw it, in the form `esdm/<category>/<name>`. This page has one entry per rule over the core schema: the severity it throws at, and what it checks and why. The rules of the extensions live next to the extension they belong to, on **[Given-When-Then: Linter Rules](/extensions/given-when-then/reference/linter-rules.md)** and **[Domain Storytelling: Linter Rules](/extensions/domain-storytelling/reference/linter-rules.md)**.
Every finding carries the ID of the rule that threw it, in the form `esdm/<category>/<name>`, and the address of its entry on this page, so you can follow it straight from the terminal or from the JSON output. This page has one entry per rule over the core schema: the severity it throws at, and what it checks and why. The rules of the extensions live next to the extension they belong to, on **[Given-When-Then: Linter Rules](/extensions/given-when-then/reference/linter-rules.md)** and **[Domain Storytelling: Linter Rules](/extensions/domain-storytelling/reference/linter-rules.md)**.

## Severity

Expand Down
59 changes: 59 additions & 0 deletions documentation/documentation_url_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package documentation_test

import (
"net/url"
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/thenativeweb/esdm/rules"
)

// TestDocumentationURLsPointAtExistingPages makes sure the URL a
// diagnostic carries resolves to a page of this documentation. The
// rules package derives the page path from a rule's extension
// without knowing the site layout, and the pages are maintained by
// hand, so this test is what ties the two together: a renamed or
// moved page fails here before a user follows a dead link.
func TestDocumentationURLsPointAtExistingPages(t *testing.T) {
t.Parallel()

byPage := map[string]string{}
for _, rule := range rules.Catalog() {
meta := rule.Meta()
byPage[pagePathOf(t, meta.DocumentationURL())] = meta.ID
}

// The pipeline diagnostics carry a bare ID and resolve to the
// core page; include one so the core page is covered even if
// every catalog rule ever moved to an extension.
pipelineMeta := rules.Meta{ID: "esdm/structure/unresolved-reference"}
byPage[pagePathOf(t, pipelineMeta.DocumentationURL())] = pipelineMeta.ID

for page, ruleID := range byPage {
t.Run(page, func(t *testing.T) {
t.Parallel()

_, err := os.Stat(filepath.Join("docs", filepath.FromSlash(page)))
assert.NoErrorf(t, err, "rule %s links to a page that does not exist", ruleID)
})
}
}

// pagePathOf turns a documentation URL into the Markdown source
// path relative to docs/: the site serves directory URLs, so
// /reference/linter-rules/ is built from reference/linter-rules.md.
func pagePathOf(t *testing.T, documentationURL string) string {
t.Helper()

parsed, err := url.Parse(documentationURL)
require.NoError(t, err)
require.Equal(t, "www.esdm.io", parsed.Host)
require.NotEmpty(t, parsed.Fragment, "documentation URL %s has no anchor", documentationURL)

return strings.TrimSuffix(strings.TrimPrefix(parsed.Path, "/"), "/") + ".md"
}
17 changes: 15 additions & 2 deletions reporter/human_formatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ import (
// at /tmp/model.esdm.yaml:29:14
// unresolved aggregate "ordr"
// note: did you mean "order"? (/tmp/model.esdm.yaml:13:7)
// see https://www.esdm.io/reference/linter-rules/#structure-unresolved-reference
//
// warning: esdm/modeling/event-name-aggregate-prefix
// warning: esdm/modeling/event-name-with-aggregate-prefix
// at /tmp/model.esdm.yaml:47:7
// event name "invoice-issued" does not start with its aggregate's name "order"
// event name "order-placed" repeats its aggregate's name "order"
// see https://www.esdm.io/reference/linter-rules/#modeling-event-name-with-aggregate-prefix
//
// The Colors field toggles ANSI escape codes. Callers
// decide based on their own TTY detection (or a
Expand Down Expand Up @@ -95,6 +97,17 @@ func (f *HumanFormatter) writeDiagnostic(w io.Writer, d diag.Diagnostic) error {
}
}

// The see line closes the block: the notes above belong to
// the message, the URL belongs to the rule as a whole, so it
// comes last, dimmed like the location so the message keeps
// the visual weight.
if d.DocumentationURL != "" {
_, err = fmt.Fprintf(w, " %s %s\n", f.paint("see", ansiDim), f.paint(d.DocumentationURL, ansiDim))
if err != nil {
return err
}
}

return nil
}

Expand Down
49 changes: 49 additions & 0 deletions reporter/human_formatter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,55 @@ func TestHumanFormatter(t *testing.T) {
assert.Equal(t, expected, buf.String())
})

t.Run("renders the documentation URL as a trailing see line", func(t *testing.T) {
f := reporter.NewHumanFormatter()

var buf bytes.Buffer
err := f.Format(&buf, []diag.Diagnostic{
{
RuleID: "esdm/structure/unresolved-reference",
Severity: diag.SeverityError,
Message: `unresolved aggregate "ordr"`,
Location: diag.Location{File: "event.esdm.yaml", Line: 7, Column: 14},
Related: []diag.Related{
{
Message: `did you mean "order"?`,
Location: diag.Location{File: "aggregate.esdm.yaml", Line: 3, Column: 7},
},
},
DocumentationURL: "https://www.esdm.io/reference/linter-rules/#structure-unresolved-reference",
},
})
require.NoError(t, err)

// The see line comes last so that the block ends with the
// pointer to further reading, after the notes that belong
// to the message.
expected := "error: esdm/structure/unresolved-reference\n" +
" at event.esdm.yaml:7:14\n" +
" unresolved aggregate \"ordr\"\n" +
" note: did you mean \"order\"? (aggregate.esdm.yaml:3:7)\n" +
" see https://www.esdm.io/reference/linter-rules/#structure-unresolved-reference\n"
assert.Equal(t, expected, buf.String())
})

t.Run("omits the see line when a diagnostic has no documentation URL", func(t *testing.T) {
f := reporter.NewHumanFormatter()

var buf bytes.Buffer
err := f.Format(&buf, []diag.Diagnostic{
{
RuleID: "esdm/x/y",
Severity: diag.SeverityWarning,
Message: "m",
Location: diag.Location{File: "a", Line: 1, Column: 1},
},
})
require.NoError(t, err)

assert.NotContains(t, buf.String(), "see ")
})

t.Run("wraps the severity label and rule id in ANSI escape codes when Colors is enabled", func(t *testing.T) {
f := reporter.NewHumanFormatter()
f.Colors = true
Expand Down
15 changes: 9 additions & 6 deletions reporter/json_formatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ import (
// "severity": "warning",
// "message": "...",
// "location": {"file": "a.esdm.yaml", "line": 3, "column": 7},
// "related": [{"message": "...", "location": {...}}]
// "related": [{"message": "...", "location": {...}}],
// "documentationUrl": "https://www.esdm.io/reference/linter-rules/#naming-event-past-tense"
// },
// ...
// ]
Expand All @@ -41,11 +42,12 @@ type jsonRelated struct {
}

type jsonDiagnostic struct {
RuleID string `json:"ruleId"`
Severity string `json:"severity"`
Message string `json:"message"`
Location jsonLocation `json:"location"`
Related []jsonRelated `json:"related,omitempty"`
RuleID string `json:"ruleId"`
Severity string `json:"severity"`
Message string `json:"message"`
Location jsonLocation `json:"location"`
Related []jsonRelated `json:"related,omitempty"`
DocumentationURL string `json:"documentationUrl,omitempty"`
}

// Format writes the diagnostics as a pretty-printed JSON
Expand All @@ -62,6 +64,7 @@ func (f *JSONFormatter) Format(w io.Writer, diagnostics []diag.Diagnostic) error
Line: d.Location.Line,
Column: d.Location.Column,
},
DocumentationURL: d.DocumentationURL,
}

if len(d.Related) > 0 {
Expand Down
44 changes: 44 additions & 0 deletions reporter/json_formatter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,50 @@ func TestJSONFormatter(t *testing.T) {
assert.Equal(t, float64(7), loc["column"])
})

t.Run("renders the documentation URL as documentationUrl", func(t *testing.T) {
f := reporter.NewJSONFormatter()

var buf bytes.Buffer
err := f.Format(&buf, []diag.Diagnostic{
{
RuleID: "esdm/naming/event-past-tense",
Severity: diag.SeverityWarning,
Message: "m",
Location: diag.Location{File: "a.esdm.yaml", Line: 3, Column: 7},
DocumentationURL: "https://www.esdm.io/reference/linter-rules/#naming-event-past-tense",
},
})
require.NoError(t, err)

var got []map[string]any
require.NoError(t, json.Unmarshal(buf.Bytes(), &got))
require.Len(t, got, 1)

assert.Equal(t, "https://www.esdm.io/reference/linter-rules/#naming-event-past-tense", got[0]["documentationUrl"])
})

t.Run("omits documentationUrl when a diagnostic has none", func(t *testing.T) {
f := reporter.NewJSONFormatter()

var buf bytes.Buffer
err := f.Format(&buf, []diag.Diagnostic{
{
RuleID: "esdm/x/y",
Severity: diag.SeverityWarning,
Message: "m",
Location: diag.Location{File: "a", Line: 1, Column: 1},
},
})
require.NoError(t, err)

var got []map[string]any
require.NoError(t, json.Unmarshal(buf.Bytes(), &got))
require.Len(t, got, 1)

_, hasField := got[0]["documentationUrl"]
assert.False(t, hasField)
})

t.Run("renders an empty slice as an empty JSON array", func(t *testing.T) {
f := reporter.NewJSONFormatter()

Expand Down
17 changes: 17 additions & 0 deletions rules/meta_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,20 @@ func TestCatalogExtensions(t *testing.T) {
}
})
}

func TestMetaDocumentationURL(t *testing.T) {
t.Run("points a core rule at its entry on the core Linter Rules page", func(t *testing.T) {
meta := rules.Meta{ID: "esdm/modeling/aggregate-without-commands"}
assert.Equal(t, "https://www.esdm.io/reference/linter-rules/#modeling-aggregate-without-commands", meta.DocumentationURL())
})

t.Run("points an extension rule at the Linter Rules page of its extension", func(t *testing.T) {
meta := rules.Meta{ID: "esdm/gwt/scenario-without-then", Extension: "given-when-then"}
assert.Equal(t, "https://www.esdm.io/extensions/given-when-then/reference/linter-rules/#gwt-scenario-without-then", meta.DocumentationURL())
})

t.Run("points a pipeline diagnostic outside the catalog at the core page", func(t *testing.T) {
meta := rules.Meta{ID: "esdm/structure/unresolved-reference"}
assert.Equal(t, "https://www.esdm.io/reference/linter-rules/#structure-unresolved-reference", meta.DocumentationURL())
})
}
20 changes: 20 additions & 0 deletions rules/rule.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,26 @@ func (m Meta) Name() string {
return name
}

// documentationSite is the root of the published documentation;
// the Linter Rules pages live underneath it.
const documentationSite = "https://www.esdm.io"

// DocumentationURL returns the address of the rule's entry in the
// documentation: the Linter Rules page of its extension, or the
// core page when Extension is empty, followed by the anchor. The
// page paths mirror the navigation of the documentation site; a
// test in the documentation package checks that the pages exist.
// A Meta built from a bare ID, as the runner does for diagnostics
// of parser, resolver, and runner, resolves to the core page,
// where those diagnostics are documented by hand.
func (m Meta) DocumentationURL() string {
page := "/reference/linter-rules/"
if m.Extension != "" {
page = "/extensions/" + m.Extension + "/reference/linter-rules/"
}
return documentationSite + page + "#" + m.Anchor()
}

// Anchor returns the fragment identifier of the rule's
// entry on its Linter Rules page: the category and the
// name joined by a hyphen. The category is part of the
Expand Down
Loading