Skip to content
Draft
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
5 changes: 4 additions & 1 deletion cmd/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const (

func newCheckCmd() *cobra.Command {
var schemaFlag string
var verbose bool

c := &cobra.Command{
Use: "check [selector ...]",
Expand Down Expand Up @@ -51,7 +52,7 @@ reported as unmatched references (errors).`,
out, errOut := cmd.OutOrStdout(), cmd.ErrOrStderr()

if len(args) == 0 {
bad, err := runFilesystemChecks(errOut, e)
bad, err := runFilesystemChecks(errOut, e, verbose)
if err != nil {
return err
}
Expand Down Expand Up @@ -108,6 +109,8 @@ reported as unmatched references (errors).`,

c.Flags().StringVarP(&schemaFlag, "schema", "s", "",
"Path to a JSON Schema file. Overrides config-based resolution for every selected item.")
c.Flags().BoolVarP(&verbose, "verbose", "v", false,
"Show every unmatched filesystem file instead of grouped directory summaries.")
return c
}

Expand Down
66 changes: 66 additions & 0 deletions cmd/check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,72 @@ collections: {}
}
}

func TestCheck_filesystemUnmatchedFilesGroupsDisallowedSubtrees(t *testing.T) {
dir := t.TempDir()
writeProject(t, dir, map[string]string{
"bases/local.yaml": `type: filesystem
root: .
filesystemChecks:
- name: docs
path: docs
include: ["README.md", "ongoing/*.md", "episodic/**"]
checks:
- kind: filesystem_unmatched_files
collections: {}
`,
})
chdir(t, dir)
mustWrite(t, filepath.Join(dir, "docs/ongoing/page.md"), "---\ntitle: Page\n---\n# Page\n")
mustWrite(t, filepath.Join(dir, "docs/ongoing/stray.tmp"), "stray\n")
mustWrite(t, filepath.Join(dir, "docs/one-time/a.md"), "# A\n")
mustWrite(t, filepath.Join(dir, "docs/one-time/deep/b.md"), "# B\n")

_, stderr, err := runRoot(t, "check")
if err == nil {
t.Fatalf("expected unmatched filesystem file failure")
}
if !strings.Contains(stderr, "filesystem docs: one-time/: /: unmatched files (2 files;") {
t.Errorf("expected grouped subtree diagnostic, got: %q", stderr)
}
if strings.Contains(stderr, "one-time/a.md") || strings.Contains(stderr, "one-time/deep/b.md") {
t.Errorf("grouped subtree should hide per-file diagnostics, got: %q", stderr)
}
if !strings.Contains(stderr, "filesystem docs: ongoing/stray.tmp: /: unmatched file") {
t.Errorf("expected allowed-directory stray file diagnostic, got: %q", stderr)
}
}

func TestCheck_filesystemUnmatchedFilesVerboseReportsEachFile(t *testing.T) {
dir := t.TempDir()
writeProject(t, dir, map[string]string{
"bases/local.yaml": `type: filesystem
root: .
filesystemChecks:
- name: docs
path: docs
include: ["README.md"]
checks:
- kind: filesystem_unmatched_files
collections: {}
`,
})
chdir(t, dir)
mustWrite(t, filepath.Join(dir, "docs/one-time/a.md"), "# A\n")
mustWrite(t, filepath.Join(dir, "docs/one-time/deep/b.md"), "# B\n")

_, stderr, err := runRoot(t, "check", "--verbose")
if err == nil {
t.Fatalf("expected unmatched filesystem file failure")
}
if strings.Contains(stderr, "filesystem docs: one-time/: /: unmatched files") {
t.Errorf("verbose output should not group subtree diagnostics, got: %q", stderr)
}
if !strings.Contains(stderr, "filesystem docs: one-time/a.md: /: unmatched file") ||
!strings.Contains(stderr, "filesystem docs: one-time/deep/b.md: /: unmatched file") {
t.Errorf("verbose output should include individual files, 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
7 changes: 4 additions & 3 deletions cmd/filesystem_check.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ type runtimeFileCheck struct {
needsDoc bool
}

func runFilesystemChecks(errOut io.Writer, e *engine) (bool, error) {
func runFilesystemChecks(errOut io.Writer, e *engine, verbose bool) (bool, error) {
bad := false
for _, scope := range e.proj.FilesystemCheckScopes() {
scopeBad, err := runFilesystemScope(errOut, e, scope)
scopeBad, err := runFilesystemScope(errOut, e, scope, verbose)
if err != nil {
return false, err
}
Expand All @@ -30,7 +30,7 @@ func runFilesystemChecks(errOut io.Writer, e *engine) (bool, error) {
return bad, nil
}

func runFilesystemScope(errOut io.Writer, e *engine, scope filesystemcheck.Scope) (bool, error) {
func runFilesystemScope(errOut io.Writer, e *engine, scope filesystemcheck.Scope, verbose bool) (bool, error) {
expanded, err := filesystemcheck.Expand(scope)
if err != nil {
return false, asUsageErr(err)
Expand All @@ -52,6 +52,7 @@ func runFilesystemScope(errOut io.Writer, e *engine, scope filesystemcheck.Scope
Unmatched: rels(expanded.Unmatched),
Include: scope.Include,
Exclude: scope.Exclude,
Verbose: verbose,
}
for _, file := range expanded.Selected {
var doc *markdownbodytext.Document
Expand Down
1 change: 1 addition & 0 deletions cmd/testdata/snapshots/help/check.txt
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,4 @@ Usage:
Flags:
-h, --help help for check
-s, --schema string Path to a JSON Schema file. Overrides config-based resolution for every selected item.
-v, --verbose Show every unmatched filesystem file instead of grouped directory summaries.
1 change: 1 addition & 0 deletions internal/checks/collection.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ type FileSetContext struct {
Unmatched []string
Include []string
Exclude []string
Verbose bool
}

// CollectionContext is the historical name for FileSetContext.
Expand Down
51 changes: 51 additions & 0 deletions internal/checks/filesystem/filesystem_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,57 @@ func TestUniqueFilename_flagsCollision(t *testing.T) {
}
}

func TestUnmatchedFilesRunCollection_groupsDisallowedSubtrees(t *testing.T) {
root := filepath.Join(t.TempDir(), "docs")
violations := filesystem.UnmatchedFiles{}.RunCollection(checks.CollectionContext{
Root: root,
Items: []checks.ItemContext{
{FilePath: filepath.Join(root, "ongoing/page.md")},
},
Unmatched: []string{
"one-time/a.md",
"one-time/deep/b.md",
"ongoing/stray.tmp",
"sunday-school/a.md",
"sunday-school/b.md",
},
Include: []string{"README.md", "ongoing/*.md", "episodic/**"},
})
if len(violations) != 3 {
t.Fatalf("expected 3 violations, got %d: %v", len(violations), violations)
}
wantFiles := []string{"one-time/", "ongoing/stray.tmp", "sunday-school/"}
for i, want := range wantFiles {
if violations[i].File != want {
t.Errorf("violation %d file = %q, want %q", i, violations[i].File, want)
}
}
for _, i := range []int{0, 2} {
if !strings.Contains(violations[i].Message, "2 files") {
t.Errorf("grouped violation %d should include file count, got %q", i, violations[i].Message)
}
}
if strings.Contains(violations[1].Message, "files") {
t.Errorf("single unmatched file should keep singular message, got %q", violations[1].Message)
}
}

func TestUnmatchedFilesRunCollection_verboseReportsEachFile(t *testing.T) {
violations := filesystem.UnmatchedFiles{}.RunCollection(checks.CollectionContext{
Unmatched: []string{
"one-time/a.md",
"one-time/deep/b.md",
},
Verbose: true,
})
if len(violations) != 2 {
t.Fatalf("expected 2 violations, got %d: %v", len(violations), violations)
}
if violations[0].File != "one-time/a.md" || violations[1].File != "one-time/deep/b.md" {
t.Fatalf("verbose output should keep individual files, got %v", violations)
}
}

func TestIndexFileRequired_flagsMissing(t *testing.T) {
root := t.TempDir()
withIndex := filepath.Join(root, "has")
Expand Down
116 changes: 112 additions & 4 deletions internal/checks/filesystem/unmatched_files.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package filesystem

import (
"fmt"
slashpath "path"
"path/filepath"
"sort"
"strings"

"github.com/abegong/katalyst/internal/checks"
Expand All @@ -12,16 +15,121 @@ import (
type UnmatchedFiles struct{}

func (UnmatchedFiles) RunCollection(ctx checks.CollectionContext) []checks.Violation {
out := make([]checks.Violation, 0, len(ctx.Unmatched))
for _, rel := range ctx.Unmatched {
reports := unmatchedReports(ctx)
out := make([]checks.Violation, 0, len(reports))
for _, report := range reports {
message := fmt.Sprintf("unmatched file (matches no include pattern %s and no exclude pattern %s)", patternList(ctx.Include), patternList(ctx.Exclude))
if report.Count > 1 {
message = fmt.Sprintf("unmatched files (%d files; matches no include pattern %s and no exclude pattern %s)", report.Count, patternList(ctx.Include), patternList(ctx.Exclude))
}
out = append(out, checks.Violation{
File: rel,
Message: fmt.Sprintf("unmatched file (matches no include pattern %s and no exclude pattern %s)", patternList(ctx.Include), patternList(ctx.Exclude)),
File: report.File,
Message: message,
})
}
return out
}

type unmatchedReport struct {
File string
Count int
}

func unmatchedReports(ctx checks.CollectionContext) []unmatchedReport {
unmatched := append([]string(nil), ctx.Unmatched...)
sort.Strings(unmatched)
if ctx.Verbose {
reports := make([]unmatchedReport, 0, len(unmatched))
for _, rel := range unmatched {
reports = append(reports, unmatchedReport{File: rel, Count: 1})
}
return reports
}

selectedDirs := selectedSubtreeDirs(ctx)
unmatchedCounts := unmatchedSubtreeCounts(unmatched)

groups := map[string][]string{}
var singles []string
for _, rel := range unmatched {
group := shallowestUnmatchedDir(rel, selectedDirs, unmatchedCounts)
if group == "" {
singles = append(singles, rel)
continue
}
groups[group] = append(groups[group], rel)
}

reports := make([]unmatchedReport, 0, len(singles)+len(groups))
for _, rel := range singles {
reports = append(reports, unmatchedReport{File: rel, Count: 1})
}
for dir, members := range groups {
if len(members) < 2 {
reports = append(reports, unmatchedReport{File: members[0], Count: 1})
continue
}
reports = append(reports, unmatchedReport{File: dir + "/", Count: len(members)})
}
sort.Slice(reports, func(i, j int) bool {
return reports[i].File < reports[j].File
})
return reports
}

func selectedSubtreeDirs(ctx checks.CollectionContext) map[string]bool {
out := map[string]bool{}
for _, it := range ctx.Items {
rel := relFromRoot(ctx.Root, it.FilePath)
for _, dir := range ancestorDirs(rel) {
out[dir] = true
}
}
return out
}

func unmatchedSubtreeCounts(rels []string) map[string]int {
out := map[string]int{}
for _, rel := range rels {
for _, dir := range ancestorDirs(rel) {
out[dir]++
}
}
return out
}

func shallowestUnmatchedDir(rel string, selectedDirs map[string]bool, unmatchedCounts map[string]int) string {
for _, dir := range ancestorDirs(rel) {
if !selectedDirs[dir] && unmatchedCounts[dir] > 1 {
return dir
}
}
return ""
}

func ancestorDirs(rel string) []string {
dir := slashpath.Dir(slashpath.Clean(rel))
if dir == "." || dir == "/" {
return nil
}
parts := strings.Split(dir, "/")
out := make([]string, 0, len(parts))
for i := range parts {
out = append(out, strings.Join(parts[:i+1], "/"))
}
return out
}

func relFromRoot(root, filePath string) string {
if root != "" {
rel, err := filepath.Rel(root, filePath)
if err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return filepath.ToSlash(rel)
}
}
return filepath.ToSlash(filePath)
}

func patternList(patterns []string) string {
if len(patterns) == 0 {
return "[]"
Expand Down
Loading
Loading