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
7 changes: 6 additions & 1 deletion .cursor/skills/add-katalyst-check-type/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Copy this checklist and keep it updated:
```text
Check Type Task Progress:
- [ ] 1) Kind constant added (internal/checks/kinds.go)
- [ ] 2) Check file added (struct + Run + args + Descriptor + registerParsed)
- [ ] 2) Check file added (struct + Run + args + Descriptor configurableIn/document-needs + registerParsed)
- [ ] 3) Tests added/updated
- [ ] 4) Fixtures/readmes updated
- [ ] 5) Reference regenerated
Expand Down Expand Up @@ -62,6 +62,11 @@ Add one file in the check type's family package — `internal/checks/structuredo
the family's `common.go`) rather than re-deriving.
- An `init()` calling the family's `registerParsed(descriptor, parse, build,
buildColl)`:
- Set `descriptor.ConfigurableIn`. Use `checks.ConfigCollection` for collection
`checks:` and add `checks.ConfigFilesystem` only when the same
implementation works under `filesystemChecks`.
- Set `descriptor.NeedsDocument` when the check reads frontmatter, markdown
body text, or source line maps. Path-only checks leave it false.
- `parse func(*yaml.Node) (any, error)` decodes the node into the args struct
and validates it. Use `internal/checks/argcheck` helpers (`RequireString`,
`OneOf`, …) for uniform, test-stable error phrasing, plus any family-local
Expand Down
15 changes: 12 additions & 3 deletions cmd/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,23 @@ reported as unmatched references (errors).`,
if err != nil {
return err
}
anyInvalid := false
out, errOut := cmd.OutOrStdout(), cmd.ErrOrStderr()

if len(args) == 0 {
bad, err := runFilesystemChecks(errOut, e)
if err != nil {
return err
}
if bad {
anyInvalid = true
}
}
res, err := resolveSelectors(e.proj, args)
if err != nil {
return err
}

anyInvalid := false
out, errOut := cmd.OutOrStdout(), cmd.ErrOrStderr()

for _, item := range res.Items {
ok, err := checkItem(out, errOut, e, item)
if err != nil {
Expand Down
153 changes: 153 additions & 0 deletions cmd/check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,159 @@ func TestCheck_wholeProjectWhenNoSelector(t *testing.T) {
}
}

func TestCheck_filesystemChecks_runWithoutCollections(t *testing.T) {
dir := t.TempDir()
writeProject(t, dir, map[string]string{
"bases/local.yaml": `type: filesystem
root: .
filesystemChecks:
- name: docs
path: docs
include: ["**/*.md"]
checks:
- kind: filesystem_name_case
style: kebab
collections: {}
`,
})
chdir(t, dir)
mustWrite(t, filepath.Join(dir, "docs/BadName.md"), "---\ntitle: Bad\n---\n# Bad\n")

_, stderr, err := runRoot(t, "check")
if err == nil {
t.Fatalf("expected filesystem check failure")
}
if !strings.Contains(stderr, "filesystem docs: BadName.md") || !strings.Contains(stderr, "must be kebab-case") {
t.Errorf("expected filesystem name-case diagnostic, got: %q", stderr)
}
}

func TestCheck_selectorDoesNotRunFilesystemChecks(t *testing.T) {
dir := t.TempDir()
writeProject(t, dir, map[string]string{
"bases/local.yaml": `type: filesystem
root: .
filesystemChecks:
- name: docs
path: docs
include: ["**/*.md"]
checks:
- kind: filesystem_name_case
style: kebab
collections:
notes:
path: notes
checks:
- kind: markdown_requires_h1
`,
})
chdir(t, dir)
mustWrite(t, filepath.Join(dir, "docs/BadName.md"), "---\ntitle: Bad\n---\n# Bad\n")
mustWrite(t, filepath.Join(dir, "notes/good.md"), "---\ntitle: Good\n---\n# Good\n")

stdout, stderr, err := runRoot(t, "check", "notes")
if err != nil {
t.Fatalf("selector check should ignore filesystem scopes: %v\nstderr: %s", err, stderr)
}
if !strings.Contains(stdout, "good.md: OK") {
t.Errorf("expected collection item OK, got: %q", stdout)
}
if strings.Contains(stderr, "BadName") {
t.Errorf("selector run should not report filesystem scope diagnostics, got: %q", stderr)
}
}

func TestCheck_filesystemParseFailuresDefaultToError(t *testing.T) {
dir := t.TempDir()
writeProject(t, dir, map[string]string{
"bases/local.yaml": `type: filesystem
root: .
filesystemChecks:
- name: docs
path: docs
include: ["**/*.md"]
checks:
- kind: filesystem_name_matches_field
field: title
collections: {}
`,
})
chdir(t, dir)
mustWrite(t, filepath.Join(dir, "docs/bad.md"), "---\n: bad\n---\n# Bad\n")

_, stderr, err := runRoot(t, "check")
if err == nil {
t.Fatalf("expected parse failure to fail by default")
}
var coded interface{ Code() int }
if !errors.As(err, &coded) || coded.Code() != 1 {
t.Errorf("expected exit code 1, got: %v", err)
}
if !strings.Contains(stderr, "filesystem docs: bad.md") || !strings.Contains(stderr, "parse document") {
t.Errorf("expected parse diagnostic, got: %q", stderr)
}
}

func TestCheck_filesystemParseFailuresCanWarn(t *testing.T) {
dir := t.TempDir()
writeProject(t, dir, map[string]string{
"bases/local.yaml": `type: filesystem
root: .
filesystemChecks:
- name: docs
path: docs
include: ["**/*.md"]
parseFailures: warning
checks:
- kind: filesystem_name_matches_field
field: title
collections: {}
`,
})
chdir(t, dir)
mustWrite(t, filepath.Join(dir, "docs/bad.md"), "---\n: bad\n---\n# Bad\n")

_, stderr, err := runRoot(t, "check")
if err != nil {
t.Fatalf("warning parse failure should not fail the run: %v\nstderr: %s", err, stderr)
}
if !strings.Contains(stderr, "warning: /: parse document") {
t.Errorf("expected warning parse diagnostic, got: %q", stderr)
}
}

func TestCheck_filesystemUnmatchedFiles(t *testing.T) {
dir := t.TempDir()
writeProject(t, dir, map[string]string{
"bases/local.yaml": `type: filesystem
root: .
filesystemChecks:
- name: docs
path: docs
include: ["**/*.md"]
exclude: ["ignored/**"]
checks:
- kind: filesystem_unmatched_files
collections: {}
`,
})
chdir(t, dir)
mustWrite(t, filepath.Join(dir, "docs/page.md"), "---\ntitle: Page\n---\n# Page\n")
mustWrite(t, filepath.Join(dir, "docs/raw.txt"), "raw\n")
mustWrite(t, filepath.Join(dir, "docs/ignored/raw.txt"), "ignored\n")

_, stderr, err := runRoot(t, "check")
if err == nil {
t.Fatalf("expected unmatched filesystem file failure")
}
if !strings.Contains(stderr, "filesystem docs: raw.txt") || !strings.Contains(stderr, "unmatched file") {
t.Errorf("expected unmatched-file diagnostic, got: %q", stderr)
}
if strings.Contains(stderr, "ignored/raw.txt") {
t.Errorf("excluded files should not be reported, got: %q", stderr)
}
}

func TestCheck_unmatchedFileInCollectionDir_isError(t *testing.T) {
dir := setupNotesRepo(t, objectNotesConfig)
mustWrite(t, filepath.Join(dir, "notes/ok.md"), "---\ntitle: Ok\nyear: 1\n---\n# Ok\n")
Expand Down
2 changes: 2 additions & 0 deletions cmd/check_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ func runCheckTypesDetail(cmd *cobra.Command, checkType string, asJSON bool) erro
printSectionHeader(out, fmt.Sprintf("%s › %s", fam.Title, d.Title))
fmt.Fprintf(out, "- kind: %s\n", d.CheckType)
fmt.Fprintf(out, "- family: %s\n", d.Family)
fmt.Fprintf(out, "- configurableIn: %s\n", strings.Join(checks.DescriptorConfigurableIn(d), ", "))
scope := d.Scope
if scope == "" {
scope = "item"
Expand Down Expand Up @@ -256,6 +257,7 @@ func jsonDescriptor(d checks.Descriptor) checks.Descriptor {
if d.Fields == nil {
d.Fields = []checks.Field{}
}
d.ConfigurableIn = checks.DescriptorConfigurableIn(d)
return d
}

Expand Down
23 changes: 18 additions & 5 deletions cmd/check_types_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,10 @@ func TestCheckTypesList_jsonArrayShape(t *testing.T) {
}

var got []struct {
CheckType string `json:"check_type"`
Family string `json:"family"`
Fields []struct {
CheckType string `json:"check_type"`
Family string `json:"family"`
ConfigurableIn []string `json:"configurableIn"`
Fields []struct {
Name string `json:"name"`
} `json:"fields"`
ConfigExample string `json:"config_example"`
Expand All @@ -199,8 +200,10 @@ func TestCheckTypesList_jsonArrayShape(t *testing.T) {
t.Fatal("expected at least one descriptor")
}
seen := map[string]bool{}
configurableIn := map[string][]string{}
for i, d := range got {
seen[d.CheckType] = true
configurableIn[d.CheckType] = d.ConfigurableIn
if got[i].ConfigExample == "" {
t.Errorf("entry %d (%s): empty config_example", i, d.CheckType)
}
Expand All @@ -227,6 +230,12 @@ func TestCheckTypesList_jsonArrayShape(t *testing.T) {
if strings.Contains(stdout, `"default": ""`) {
t.Errorf("empty default should be omitted, not emitted")
}
if strings.Join(configurableIn["filesystem_name_case"], ",") != "collection,filesystem" {
t.Errorf("filesystem_name_case configurableIn = %v, want collection+filesystem", configurableIn["filesystem_name_case"])
}
if strings.Join(configurableIn["markdown_requires_h1"], ",") != "collection" {
t.Errorf("markdown_requires_h1 configurableIn = %v, want collection", configurableIn["markdown_requires_h1"])
}
}

func TestCheckTypesShow_jsonObject(t *testing.T) {
Expand All @@ -237,8 +246,9 @@ func TestCheckTypesShow_jsonObject(t *testing.T) {
}

var got struct {
CheckType string `json:"check_type"`
Fields []struct {
CheckType string `json:"check_type"`
ConfigurableIn []string `json:"configurableIn"`
Fields []struct {
Name string `json:"name"`
Required bool `json:"required"`
} `json:"fields"`
Expand All @@ -249,6 +259,9 @@ func TestCheckTypesShow_jsonObject(t *testing.T) {
if got.CheckType != "object_number_range" {
t.Errorf("got check type %q, want object_number_range", got.CheckType)
}
if strings.Join(got.ConfigurableIn, ",") != "collection" {
t.Errorf("configurableIn = %v, want collection", got.ConfigurableIn)
}
if len(got.Fields) != 3 {
t.Fatalf("got %d fields, want 3", len(got.Fields))
}
Expand Down
40 changes: 28 additions & 12 deletions cmd/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,6 @@ func (e *engine) checksFor(c project.Collection, meta map[string]any) ([]checks.
effective = append(effective, matched.Checks...)
}

if err := ensureLibrariesAvailable(effective); err != nil {
return nil, err
}

checkList := make([]checks.Check, 0, len(effective))

inlineSchema := ""
Expand Down Expand Up @@ -168,14 +164,11 @@ func (e *engine) checksFor(c project.Collection, meta map[string]any) ([]checks.
// Every non-object, per-item check is built from its registry entry. The
// object check is handled above (it needs a compiled schema); collection-
// scoped checks have no per-item builder, so Build skips them here.
for _, cc := range effective {
if cc.Kind == checks.CheckObject {
continue
}
if chk, ok := checks.Build(cc.Kind, cc.Args); ok {
checkList = append(checkList, chk)
}
fileChecks, err := e.fileChecksFor(effective)
if err != nil {
return nil, err
}
checkList = append(checkList, fileChecks...)

// An item that matched no variant under useExhaustiveVariants fails. The
// verdict rides through RunAll like any other check (so `check` and
Expand Down Expand Up @@ -228,8 +221,31 @@ func (unroutedCheck) Run(checks.Context) []checks.Violation {
// collectionChecksFor builds the collection-scoped checks configured for a
// collection. These run once per collection, after the per-item pass.
func (e *engine) collectionChecksFor(c project.Collection) ([]checks.CollectionCheck, error) {
return e.fileSetChecksFor(c.Checks)
}

func (e *engine) fileChecksFor(configured []checks.ConfiguredCheck) ([]checks.Check, error) {
if err := ensureLibrariesAvailable(configured); err != nil {
return nil, err
}
var out []checks.Check
for _, cc := range configured {
if cc.Kind == checks.CheckObject {
continue
}
if chk, ok := checks.Build(cc.Kind, cc.Args); ok {
out = append(out, chk)
}
}
return out, nil
}

func (e *engine) fileSetChecksFor(configured []checks.ConfiguredCheck) ([]checks.CollectionCheck, error) {
if err := ensureLibrariesAvailable(configured); err != nil {
return nil, err
}
var out []checks.CollectionCheck
for _, cc := range c.Checks {
for _, cc := range configured {
if col, ok := checks.BuildCollection(cc.Kind, cc.Args); ok {
out = append(out, col)
}
Expand Down
Loading
Loading