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
24 changes: 11 additions & 13 deletions cmd/modelith/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -489,24 +489,22 @@ func lintCmd() *cobra.Command {
}
completenessAsError := completeness == "error"

type fileResult struct {
File string `json:"file"`
Findings []lint.Finding `json:"findings"`
}
var all []fileResult
blocking := false

inputs := make([]lint.Input, 0, len(args))
for _, path := range args {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("%s: %w", path, err)
}
res, err := lint.Run(path, data, lint.OSFiles{})
if err != nil {
return fmt.Errorf("%s: %w", path, err)
}
all = append(all, fileResult{File: path, Findings: res.Findings})
if res.HasBlocking(completenessAsError) {
inputs = append(inputs, lint.Input{Path: path, Source: data})
}
all, err := lint.Plan(inputs, lint.OSFiles{})
if err != nil {
return err
}

blocking := false
for _, fr := range all {
if (&lint.Result{Findings: fr.Findings}).HasBlocking(completenessAsError) {
blocking = true
}
}
Expand Down
65 changes: 65 additions & 0 deletions cmd/modelith/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"testing"

"github.com/stacklok/modelith/internal/deps"
"github.com/stacklok/modelith/internal/lint"
"github.com/stacklok/modelith/internal/provenance"
)

Expand Down Expand Up @@ -125,6 +126,70 @@ func TestLintMissingFileErrors(t *testing.T) {
}
}

func TestLintReportsDiscoveredVendoredChildUnderChildPath(t *testing.T) {
dir := t.TempDir()
childPath := filepath.Join(dir, "child.modelith.yaml")
child := strings.Replace(minimalValid, "A thing that exists in the model.", "A changed thing.", 1)
writeTemp(t, dir, "child.modelith.yaml", vendorHeader(minimalValid)+child)
rootPath := writeTemp(t, dir, "root.modelith.yaml", `kind: DomainModel
version: v1
imports:
- ./child.modelith.yaml
entities:
Root:
definition: The root model.
`)

out, err := run(t, "lint", rootPath)
if !errors.Is(err, errBlocking) {
t.Fatalf("expected errBlocking, got %v\noutput:\n%s", err, out)
}
if !strings.Contains(out, childPath+":\n error [semantic] (root): this vendored file no longer matches the digest") {
t.Fatalf("discovered provenance finding was not grouped under %s:\n%s", childPath, out)
}
}

func TestLintDoesNotDuplicateExplicitDiscoveredChild(t *testing.T) {
dir := t.TempDir()
childPath := filepath.Join(dir, "child.modelith.yaml")
child := strings.Replace(minimalValid, "A thing that exists in the model.", "A changed thing.", 1)
writeTemp(t, dir, "child.modelith.yaml", vendorHeader(minimalValid)+child)
rootPath := writeTemp(t, dir, "root.modelith.yaml", `kind: DomainModel
version: v1
imports:
- ./child.modelith.yaml
entities:
Root:
definition: The root model.
`)

out, err := run(t, "lint", "--format", "json", rootPath, childPath)
if !errors.Is(err, errBlocking) {
t.Fatalf("expected errBlocking, got %v\noutput:\n%s", err, out)
}
var payload struct {
Files []struct {
File string `json:"file"`
Findings []lint.Finding `json:"findings"`
} `json:"files"`
}
if err := json.Unmarshal([]byte(out), &payload); err != nil {
t.Fatalf("invalid JSON: %v\noutput:\n%s", err, out)
}
var children []struct {
File string `json:"file"`
Findings []lint.Finding `json:"findings"`
}
for _, file := range payload.Files {
if file.File == childPath {
children = append(children, file)
}
}
if len(children) != 1 || len(children[0].Findings) != 1 {
t.Fatalf("child results = %+v, want one provenance finding", children)
}
}

func TestRenderWritesFileBesideSource(t *testing.T) {
dir := t.TempDir()
yamlPath := writeTemp(t, dir, "m.modelith.yaml", minimalValid)
Expand Down
14 changes: 11 additions & 3 deletions docs/10-vendoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,11 @@ Two things change, and nothing else:
its own authors control. Without this, the [GitHub
Action](./08-github-action.md) — which lints every matched file — would fail
your build over someone else's model.
- **Its own `imports:` raise nothing.** A vendored model's imports name paths
in *its* repository, which do not exist in yours. Those are skipped, along
with the references that resolve through them.
- **Its own `imports:` do not receive semantic diagnostics.** A vendored
model's imports commonly name paths in *its* repository, which do not exist in
yours. Missing or broken nested edges stay silent, along with references that
resolve through them; readable local edges still participate in provenance
verification.

**Structural and semantic checks still run.** A vendored file that is not a
valid domain model breaks your build, and that is your problem to solve — by
Expand Down Expand Up @@ -155,6 +157,12 @@ This is drift detection, not a security boundary: anyone editing the file can
recompute the header. It catches the well-meaning typo fix, which is the thing
that actually happens.

When lint starts from an importing model, it follows locally readable imports
and verifies every vendored copy it reaches. A mismatch is reported against the
copy that needs repair, not its importer. This stays offline and does not add
new diagnostics for a nested import that cannot be read; lint does not become a
recursive semantic validator.

## Keeping the copy current

The section above is about your copy. This one is about the model it came from,
Expand Down
95 changes: 71 additions & 24 deletions internal/lint/imports.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,34 @@ var (
type importedModel struct {
index int // position in the importing model's imports list, for the finding path
path string // the path as written in imports
model *model.Model
loadedImport
}

// loadedImport is a contained import that was read successfully. model is set only
// when the contents parsed as a supported domain model.
type loadedImport struct {
resolvedPath string
source []byte
model *model.Model
}

type importLoadFailureKind uint8

const (
importOutsideRepository importLoadFailureKind = iota + 1
importOutsideModelDirectory
importUnreadable
importNotDomainModel
importUnsupportedSchema
)

// importLoadFailure retains the data loadImports needs to preserve its
// import-specific diagnostics.
type importLoadFailure struct {
kind importLoadFailureKind
resolvedPath string
err error
version string
}

// runImports resolves the model's imports, checks every qualified attribute
Expand Down Expand Up @@ -113,7 +140,6 @@ func loadImports(modelPath string, m *model.Model, files Files, res *Result, ven
if len(m.Imports) == 0 {
return byScope, claimed
}
dir := filepath.Dir(modelPath)
root, inRepo := files.ResolutionRoot(modelPath)
for i, imp := range m.Imports {
reject := func(format string, args ...any) {
Expand Down Expand Up @@ -173,37 +199,58 @@ func loadImports(modelPath string, m *model.Model, files Files, res *Result, ven
// holds no model are four distinct diagnostics, and together they let a
// model from an untrusted source probe the filesystem of whatever runner
// lints it (ADR-0013).
joined := filepath.Join(dir, imp.Path)
if resolved := files.Resolve(joined); !withinRoot(root, resolved) {
if inRepo {
loaded, failure := loadImport(modelPath, root, inRepo, imp.Path, files)
if failure != nil {
switch failure.kind {
case importOutsideRepository:
reject("import %q resolves to %q, outside %q — that directory is the repository holding this model (the nearest ancestor with a .git entry), and an import may not name a file beyond it",
imp.Path, resolved, root)
} else {
imp.Path, failure.resolvedPath, root)
case importOutsideModelDirectory:
reject("import %q resolves to %q, outside %q — this model is in no repository, so resolution is confined to the directory holding it; move the imported model into that directory or below it",
imp.Path, resolved, root)
imp.Path, failure.resolvedPath, root)
case importUnreadable:
reject("import %q cannot be read: %v", imp.Path, failure.err)
case importNotDomainModel:
reject("import %q is not a domain model — lint it on its own with `modelith lint` to see why", imp.Path)
case importUnsupportedSchema:
reject("import %q declares schema version %q, which this modelith does not support: %s (upgrade modelith, or move that model to a supported version)",
imp.Path, failure.version, strings.Join(schema.SupportedVersions(), ", "))
}
continue
}
data, err := files.ReadFile(joined)
if err != nil {
reject("import %q cannot be read: %v", imp.Path, err)
continue
}
im, err := model.Parse(data)
if err != nil || im.Kind != "DomainModel" {
reject("import %q is not a domain model — lint it on its own with `modelith lint` to see why", imp.Path)
continue
}
if !schema.Supported(im.Version) {
reject("import %q declares schema version %q, which this modelith does not support: %s (upgrade modelith, or move that model to a supported version)",
imp.Path, im.Version, strings.Join(schema.SupportedVersions(), ", "))
continue
}
byScope[imp.Scope] = importedModel{index: i, path: imp.Path, model: im}
byScope[imp.Scope] = importedModel{index: i, path: imp.Path, loadedImport: loaded}
}
return byScope, claimed
}

// loadImport reads and validates an imported model after its path syntax and
// scope have been checked by loadImports.
func loadImport(modelPath, root string, inRepo bool, importPath string, files Files) (loadedImport, *importLoadFailure) {
joined := filepath.Join(filepath.Dir(modelPath), importPath)
resolvedPath := files.Resolve(joined)
if !withinRoot(root, resolvedPath) {
kind := importOutsideModelDirectory
if inRepo {
kind = importOutsideRepository
}
return loadedImport{}, &importLoadFailure{kind: kind, resolvedPath: resolvedPath}
}
data, err := files.ReadFile(joined)
if err != nil {
return loadedImport{}, &importLoadFailure{kind: importUnreadable, err: err}
}
loaded := loadedImport{resolvedPath: resolvedPath, source: data}
imported, err := model.Parse(data)
if err != nil || imported.Kind != "DomainModel" {
return loaded, &importLoadFailure{kind: importNotDomainModel}
}
if !schema.Supported(imported.Version) {
return loaded, &importLoadFailure{kind: importUnsupportedSchema, version: imported.Version}
}
loaded.model = imported
return loaded, nil
}

// checkQualifiedTypes resolves every qualified attribute type against the
// imports and returns the set of scopes that were referenced.
//
Expand Down
19 changes: 19 additions & 0 deletions internal/lint/imports_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,25 @@ func assertFindings(t *testing.T, got []Finding, want []wantFinding) {
}
}

func TestLoadImport_SuccessRetainsLoadedData(t *testing.T) {
t.Parallel()

files := fakeFiles{"docs/payments.modelith.yaml": paymentsModel}
loaded, failure := loadImport(importerPath, "docs", false, "./payments.modelith.yaml", files)
if failure != nil {
t.Fatalf("loadImport failed: %+v", failure)
}
if loaded.resolvedPath != "docs/payments.modelith.yaml" {
t.Errorf("resolved path = %q, want %q", loaded.resolvedPath, "docs/payments.modelith.yaml")
}
if string(loaded.source) != paymentsModel {
t.Errorf("source = %q, want %q", loaded.source, paymentsModel)
}
if loaded.model == nil || loaded.model.Kind != "DomainModel" || loaded.model.Version != "v1" {
t.Errorf("model = %+v, want parsed v1 domain model", loaded.model)
}
}

func TestImports_Resolution(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading