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
4 changes: 2 additions & 2 deletions .github/workflows/lint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
go-version-file: go.mod
cache: false # Let golangcilint handle caching
- name: golangci-lint
uses: golangci/golangci-lint-action@v6
uses: golangci/golangci-lint-action@v9
with:
version: v1.61
version: v2.8
args: --timeout=4m
116 changes: 64 additions & 52 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -1,54 +1,66 @@
version: "2"
linters:
enable:
- bodyclose
- copyloopvar
- dogsled
- dupl
- errcheck
- errorlint
- exhaustive
- forbidigo
- gci
- gochecknoinits
- goconst
- gocritic
- godot
- godox
- gofumpt
- goheader
- goimports
- gomoddirectives
- gomodguard
- goprintffuncname
- gosec
- gosimple
- govet
- importas
- ineffassign
- lll
- makezero
- misspell
- nakedret
- nestif
- nilnil
- nolintlint
- predeclared
- promlinter
- revive
- staticcheck
- stylecheck
- tenv
- testpackage
- thelper
- typecheck
- unconvert
- unparam
- unused
- whitespace
- wrapcheck
- wsl
linters-settings:
godox:
keywords:
- FIXME
- BUG
- bodyclose
- copyloopvar
- dogsled
- dupl
- errorlint
- exhaustive
- forbidigo
- gochecknoinits
- goconst
- gocritic
- godot
- godox
- goheader
- gomoddirectives
- gomodguard
- goprintffuncname
- gosec
- importas
- lll
- makezero
- misspell
- nakedret
- nestif
- nilnil
- nolintlint
- predeclared
- promlinter
- revive
- staticcheck
- testpackage
- thelper
- unconvert
- unparam
- whitespace
- wrapcheck
- wsl_v5
settings:
godox:
keywords:
- FIXME
- BUG
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
formatters:
enable:
- gci
- gofumpt
- goimports
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,39 @@ A list of available block attributes, and whether they can be used in pattern ma
| contenttype | The content type of the resource that the block describes | Yes |
| role | The role that the block or resource has | Yes |

## Document type variants

Document type variants allow documents to use a suffixed type like `"core/article+template"` and still match the base declaration `"core/article"`. Variants are configured on the validator, not in constraint sets, so that the set of allowed suffixes is controlled by the application.

Configure variants using `WithVariants`:

``` go
validator, err := revisor.NewValidator(constraints...)
if err != nil {
log.Fatal(err)
}

// Allow "+template" for all declared document types.
validator = validator.WithVariants(revisor.Variant{
Name: "template",
})

// Or restrict a variant to specific base types.
validator = validator.WithVariants(
revisor.Variant{
Name: "template",
},
revisor.Variant{
Name: "example",
Types: []string{"core/planning-item"},
},
)
```

When `Types` is empty the variant applies to all declared document types. When `Types` is set the variant only applies to the listed base types; a document with a non-matching base type will be treated as an undeclared type.

Variants are preserved across `WithConstraints` calls.

## Testing

Revisor implements a file-driven test in `TestValidateDocument` that checks so that all the "testdata/results/*.json" files match the validation results for the corresponding document under "testdata/". Result files with the prefix "base-" will be validated against "constraints/naviga.json", for result files with the prefix "example-" the "constraints/example.json" constraints will be used as well.
Expand Down
1 change: 1 addition & 0 deletions cmd/revisor/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ func main() {

if err := app.Run(os.Args); err != nil {
_, _ = fmt.Fprintln(os.Stderr, err.Error())

os.Exit(1)
}
}
20 changes: 12 additions & 8 deletions collection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ func TestCollection(t *testing.T) {
)

testValidator, err := revisor.NewValidator(testConstraints...)
must(t, err, "failed to create test validator")
mustf(t, err, "failed to create test validator")

testValidator = testValidator.WithVariants(revisor.Variant{
Name: "template",
})

tests := []validatorTest{
{
Expand All @@ -37,7 +41,7 @@ func TestCollection(t *testing.T) {
}

paths, err := filepath.Glob(filepath.Join("testdata", "results-collection", "*.json"))
must(t, err, "failed to glob for collection result files")
mustf(t, err, "failed to glob for collection result files")

for j := range tests {
testCase := tests[j]
Expand Down Expand Up @@ -75,15 +79,15 @@ func testCollectionAgainstGolden(
var document newsdoc.Document // want []revisor.ValidationResult

err := internal.UnmarshalFile(sourceDocPath, &document)
must(t, err, "failed to load document")
mustf(t, err, "failed to load document")

collector := revisor.NewValueCollector()

ctx := context.Background()

_, err = testCase.Validator.ValidateDocument(ctx, &document,
revisor.WithValueCollector(collector))
must(t, err, "validate document")
mustf(t, err, "validate document")

collected := make(map[string]collectedValues)

Expand Down Expand Up @@ -118,18 +122,18 @@ func testCollectionAgainstGolden(

if regenerate {
data, err := json.MarshalIndent(collected, "", " ")
must(t, err, "marshal for golden reference file")
mustf(t, err, "marshal for golden reference file")

data = append(data, '\n')

err = os.WriteFile(goldenPath, data, 0o600)
must(t, err, "write golden reference file")
mustf(t, err, "write golden reference file")
}

var want map[string]collectedValues

err = internal.UnmarshalFile(goldenPath, &want)
must(t, err, "failed to load expected result")
mustf(t, err, "failed to load expected result")

if diff := cmp.Diff(want, collected); diff != "" {
t.Fatalf("collection mismatch (-want +got):\n%s",
Expand All @@ -139,7 +143,7 @@ func testCollectionAgainstGolden(
}

//nolint:unparam
func must(t *testing.T, err error, format string, a ...any) {
func mustf(t *testing.T, err error, format string, a ...any) {
t.Helper()

if err != nil {
Expand Down
18 changes: 9 additions & 9 deletions deprecation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@ func TestDeprecation(t *testing.T) {
)

testValidator, err := revisor.NewValidator(testConstraints...)
must(t, err, "failed to create test validator")
mustf(t, err, "failed to create test validator")

var document newsdoc.Document

err = internal.UnmarshalFile("testdata/geo.json", &document)
must(t, err, "unmarshal geo doc")
mustf(t, err, "unmarshal geo doc")

var (
got []deprecationEntry
Expand Down Expand Up @@ -58,7 +58,7 @@ func TestDeprecation(t *testing.T) {

res, err := testValidator.ValidateDocument(ctx, &document,
revisor.WithDeprecationHandler(deprecationHandler))
must(t, err, "validate document")
mustf(t, err, "validate document")

t.Run("FoundDeprecations", func(t *testing.T) {
var (
Expand All @@ -68,14 +68,14 @@ func TestDeprecation(t *testing.T) {

if regenerate {
data, err := json.MarshalIndent(got, "", " ")
must(t, err, "marshal for golden reference file")
mustf(t, err, "marshal for golden reference file")

err = os.WriteFile(goldenPath, data, 0o600)
must(t, err, "write golden reference file")
mustf(t, err, "write golden reference file")
}

err = internal.UnmarshalFile(goldenPath, &want)
must(t, err, "unmarshal golden file")
mustf(t, err, "unmarshal golden file")

if diff := cmp.Diff(want, got); diff != "" {
t.Fatalf("deprecation mismatch (-want +got):\n%s",
Expand All @@ -100,14 +100,14 @@ func TestDeprecation(t *testing.T) {

if regenerate {
data, err := json.MarshalIndent(got, "", " ")
must(t, err, "marshal for golden reference file")
mustf(t, err, "marshal for golden reference file")

err = os.WriteFile(goldenPath, data, 0o600)
must(t, err, "write golden reference file")
mustf(t, err, "write golden reference file")
}

err = internal.UnmarshalFile(goldenPath, &want)
must(t, err, "unmarshal golden file")
mustf(t, err, "unmarshal golden file")

if diff := cmp.Diff(want, got); diff != "" {
t.Fatalf("enforcement mismatch (-want +got):\n%s",
Expand Down
49 changes: 45 additions & 4 deletions document_constraint.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package revisor

import (
"slices"
"strings"

"github.com/ttab/newsdoc"
)

Expand Down Expand Up @@ -52,15 +55,15 @@ func (dc DocumentConstraint) Matches(
d *newsdoc.Document, vCtx *ValidationContext,
) Match {
if dc.Declares != "" {
if d.Type == dc.Declares {
if d.Type == dc.Declares || resolveVariant(d.Type, vCtx.variants) == dc.Declares {
return MatchDeclaration
}

return NoMatch
}

for _, k := range dc.Match.Keys {
value, ok := documentMatchAttribute(d, k)
value, ok := documentMatchAttribute(d, k, vCtx.variants)
if !ok {
return NoMatch
}
Expand Down Expand Up @@ -95,9 +98,9 @@ const (
docAttrURL documentAttributeKey = "url"
)

func documentMatchAttribute(d *newsdoc.Document, name string) (string, bool) {
func documentMatchAttribute(d *newsdoc.Document, name string, variants []Variant) (string, bool) {
if documentAttributeKey(name) == docAttrType {
return d.Type, true
return resolveVariant(d.Type, variants), true
}

return "", false
Expand All @@ -121,3 +124,41 @@ func documentAttribute(d *newsdoc.Document, name string) (string, bool) {

return "", false
}

// Variant defines a document type variant suffix. When a document type
// contains a "+" separator (e.g. "core/article+template"), the part after the
// last "+" is matched against configured variants. If Types is empty, the
// variant applies to all declared document types.
type Variant struct {
Name string `json:"name"`
Types []string `json:"types,omitempty"`
}

// resolveVariant returns the base document type if the suffix after the last
// "+" matches a configured variant (and the base type is allowed for that
// variant). Returns docType unchanged if no variant matches.
func resolveVariant(docType string, variants []Variant) string {
idx := strings.LastIndex(docType, "+")
if idx == -1 {
return docType
}

base := docType[:idx]
suffix := docType[idx+1:]

for i := range variants {
if variants[i].Name != suffix {
continue
}

if len(variants[i].Types) == 0 {
return base
}

if slices.Contains(variants[i].Types, base) {
return base
}
}

return docType
}
Loading