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
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,10 @@ jobs:
- name: Merge coverage profiles
run: |
head -1 unit.cov > merged.cov
# Exclude cmd/mdsmith from unit profile — those functions
# are only exercised via the subprocess binary, so the
# test-process counters are always zero.
tail -n +2 unit.cov | grep -v 'cmd/mdsmith/' >> merged.cov || true
# Include all unit-test coverage; cmd/mdsmith functions exercised
# only via the subprocess binary will have count 0 here but will
# be supplemented by the e2e profile below.
tail -n +2 unit.cov >> merged.cov
e2e_profile="$GITHUB_WORKSPACE/e2e-cover/e2e_coverage.txt"
if [ ! -f "$e2e_profile" ]; then
echo "e2e_coverage.txt not found — cmd/mdsmith coverage will be missing" >&2
Expand Down
19 changes: 19 additions & 0 deletions cmd/mdsmith/e2e_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -756,3 +756,22 @@ func TestE2E_Check_Stdin_Quiet(t *testing.T) {
assert.NotContains(t, stderr, "MDS006",
"expected no diagnostic output with --quiet stdin, got: %s", stderr)
}

// =============================================================
// fixDiscovered with unfixable diagnostics (non-quiet)
// =============================================================

func TestE2E_Fix_Discovered_UnfixableDiagnostic(t *testing.T) {
dir := t.TempDir()
isolateDir(t, dir)
// trailing-punctuation in heading is unfixable; trailing spaces are fixable.
// After fix, MDS017 remains → formatDiagnostics is called in fixDiscovered.
writeFixture(t, dir, ".mdsmith.yml",
"rules:\n no-trailing-punctuation-in-heading: true\n no-trailing-spaces: true\n")
writeFixture(t, dir, "dirty.md", "# Title!\n\nHello \n")

_, stderr, exitCode := runBinaryInDir(t, dir, "", "fix", "--no-color")
assert.Equal(t, 1, exitCode, "expected exit 1 (unfixable diagnostic), got %d", exitCode)
assert.Contains(t, stderr, "MDS017",
"expected MDS017 diagnostic in stderr, got: %s", stderr)
}
57 changes: 57 additions & 0 deletions cmd/mdsmith/format_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package main

import (
"errors"
"io"
"strings"
"testing"

"github.com/jeduden/mdsmith/internal/lint"

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

// errWriter always returns an error on Write so we can test the failure path.
type errWriter struct{ err error }

func (e *errWriter) Write(_ []byte) (int, error) { return 0, e.err }

func TestFormatDiagnosticsTo_TextSuccess(t *testing.T) {
diags := []lint.Diagnostic{{
File: "foo.md", Line: 1, Column: 1,
RuleID: "MDS001", RuleName: "test-rule",
Severity: lint.Warning, Message: "test message",
}}
var buf strings.Builder
code := formatDiagnosticsTo(&buf, diags, "text", true)
assert.Equal(t, 0, code)
assert.Contains(t, buf.String(), "foo.md")
}

func TestFormatDiagnosticsTo_JSONSuccess(t *testing.T) {
diags := []lint.Diagnostic{{
File: "bar.md", Line: 2, Column: 3,
RuleID: "MDS002", RuleName: "other-rule",
Severity: lint.Warning, Message: "json test",
}}
var buf strings.Builder
code := formatDiagnosticsTo(&buf, diags, "json", true)
assert.Equal(t, 0, code)
assert.Contains(t, buf.String(), "bar.md")
}

func TestFormatDiagnosticsTo_WriteError(t *testing.T) {
diags := []lint.Diagnostic{{
File: "z.md", Line: 1, Column: 1,
RuleID: "MDS001", RuleName: "test-rule",
Severity: lint.Warning, Message: "will fail",
}}
w := &errWriter{err: errors.New("disk full")}
code := formatDiagnosticsTo(w, diags, "text", true)
assert.Equal(t, 2, code)
}

func TestFormatDiagnosticsTo_Empty(t *testing.T) {
code := formatDiagnosticsTo(io.Discard, nil, "text", true)
assert.Equal(t, 0, code)
}
Comment thread
jeduden marked this conversation as resolved.
11 changes: 8 additions & 3 deletions cmd/mdsmith/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -468,23 +468,28 @@ func runInit(args []string) int {
return 0
}

// formatDiagnostics writes diagnostics to stderr using the specified format.
// formatDiagnosticsTo writes diagnostics to w using the specified format.
// Returns a non-zero exit code on write error, or 0 on success.
func formatDiagnostics(diags []lint.Diagnostic, format string, noColor bool) int {
func formatDiagnosticsTo(w io.Writer, diags []lint.Diagnostic, format string, noColor bool) int {
var formatter output.Formatter
switch format {
case "json":
formatter = &output.JSONFormatter{}
default:
formatter = &output.TextFormatter{Color: !noColor}
}
if err := formatter.Format(os.Stderr, diags); err != nil {
if err := formatter.Format(w, diags); err != nil {
fmt.Fprintf(os.Stderr, "mdsmith: error writing output: %v\n", err)
return 2
}
return 0
}

// formatDiagnostics writes diagnostics to stderr using the specified format.
func formatDiagnostics(diags []lint.Diagnostic, format string, noColor bool) int {
return formatDiagnosticsTo(os.Stderr, diags, format, noColor)
}
Comment thread
jeduden marked this conversation as resolved.

// printErrors writes runtime errors to stderr.
func printErrors(errs []error) {
for _, e := range errs {
Expand Down
54 changes: 54 additions & 0 deletions internal/fix/fix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -972,3 +972,57 @@ func TestAtomicWriteFile_StatErrorNotENOENT(t *testing.T) {
err := atomicWriteFile(target, []byte("new"), 0o644)
require.Error(t, err, "should fail when Stat returns non-ENOENT error")
}

// mockNonConvergingRule always reports a diagnostic and appends "X" on every
// Fix call, so content never stabilises and the fixer exhausts all passes.
type mockNonConvergingRule struct {
id string
name string
}

func (r *mockNonConvergingRule) ID() string { return r.id }
func (r *mockNonConvergingRule) Name() string { return r.name }
func (r *mockNonConvergingRule) Category() string { return "test" }

func (r *mockNonConvergingRule) Check(f *lint.File) []lint.Diagnostic {
return []lint.Diagnostic{{
File: f.Path, Line: 1, Column: 1,
RuleID: r.id, RuleName: r.name,
Severity: lint.Warning, Message: "always needs fixing",
}}
}

func (r *mockNonConvergingRule) Fix(f *lint.File) []byte {
return append(append([]byte(nil), f.Source...), 'X')
}

var _ rule.FixableRule = (*mockNonConvergingRule)(nil)

func TestFix_MaxPassesBoundary(t *testing.T) {
// A rule whose Fix always appends "X", so content never converges.
// applyFixPasses must exit after exactly maxPasses (10) iterations.
dir := t.TempDir()
mdFile := filepath.Join(dir, "test.md")
initial := []byte("A\n")
require.NoError(t, os.WriteFile(mdFile, initial, 0o644))

cfg := &config.Config{
Rules: map[string]config.RuleCfg{
"mock-non-converging": {Enabled: true},
},
}
fixer := &Fixer{
Config: cfg,
Rules: []rule.Rule{&mockNonConvergingRule{id: "MDS999", name: "mock-non-converging"}},
}

result := fixer.Fix([]string{mdFile})
require.Empty(t, result.Errors, "unexpected errors: %v", result.Errors)

got, err := os.ReadFile(mdFile)
require.NoError(t, err)

// After 10 passes each appending "X", the file should end with 10 X's.
const maxPasses = 10
assert.Equal(t, string(initial)+strings.Repeat("X", maxPasses), string(got))
}
6 changes: 6 additions & 0 deletions internal/lint/limits_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,9 @@ func TestReadFSFileLimited_AtLimit(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, content, data)
}

func TestReadFSFileLimited_Nonexistent(t *testing.T) {
fsys := fstest.MapFS{}
_, err := lint.ReadFSFileLimited(fsys, "no-such.md", 100)
require.Error(t, err)
}
29 changes: 29 additions & 0 deletions internal/lint/lint_coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package lint
import (
"os"
"path/filepath"
"runtime"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -224,6 +225,34 @@ func TestNewGitignoreMatcher_NestedGitignore(t *testing.T) {
assert.True(t, len(m.rules) >= 2)
}

func TestNewGitignoreMatcher_UnreadableGitignore(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("permission test not reliable on Windows")
}
if os.Getuid() == 0 {
t.Skip("permission test not reliable as root")
}
dir := t.TempDir()
// A valid .gitignore in the root so we have something to match against.
require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("*.log\n"), 0o644))

// A subdirectory with an unreadable .gitignore (chmod 000).
sub := filepath.Join(dir, "sub")
require.NoError(t, os.MkdirAll(sub, 0o755))
bad := filepath.Join(sub, ".gitignore")
require.NoError(t, os.WriteFile(bad, []byte("*.tmp\n"), 0o644))
require.NoError(t, os.Chmod(bad, 0o000))
defer func() { _ = os.Chmod(bad, 0o644) }()

// NewGitignoreMatcher should not panic; it silently skips unreadable files.
m := NewGitignoreMatcher(dir)
require.NotNil(t, m)

// Rules from the readable root .gitignore should still be active.
logFile := filepath.Join(dir, "test.log")
assert.True(t, m.IsIgnored(logFile, false), "*.log rule from root .gitignore should still apply")
}
Comment thread
jeduden marked this conversation as resolved.

func TestNewGitignoreMatcher_NegationPattern(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"),
Expand Down
26 changes: 26 additions & 0 deletions internal/rules/include/rule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package include

import (
"fmt"
"os"
"runtime"
"strings"
"testing"
"testing/fstest"
Expand Down Expand Up @@ -206,6 +208,30 @@ func TestCheck_MissingFile(t *testing.T) {
expectDiagMsg(t, diags, "cannot read include file")
}

func TestCheck_UnreadableFile(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("permission test not reliable on Windows")
}
if os.Getuid() == 0 {
t.Skip("permission test not reliable as root")
}
dir := t.TempDir()
target := "target.md"
targetPath := dir + "/" + target
require.NoError(t, os.WriteFile(targetPath, []byte("# Target\n"), 0o644))
require.NoError(t, os.Chmod(targetPath, 0o000))
defer func() { _ = os.Chmod(targetPath, 0o644) }()

src := "# Doc\n\n<?include\nfile: " + target + "\n?>\nold\n<?/include?>\n"
f, err := lint.NewFile("doc.md", []byte(src))
require.NoError(t, err)
f.FS = os.DirFS(dir)

r := &Rule{}
diags := r.Check(f)
expectDiagMsg(t, diags, "cannot read include file")
}

// =====================================================================
// Validation errors
// =====================================================================
Expand Down
56 changes: 54 additions & 2 deletions internal/rules/requiredstructure/rule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -974,24 +974,60 @@ func TestCueExprForValue_SliceArray(t *testing.T) {
assert.Equal(t, `[1,"hello",true]`, expr)
}

func TestCueExprForValue_Array(t *testing.T) {
expr, err := cueExprForValue([]any{"a", "b"})
require.NoError(t, err)
assert.Equal(t, `["a","b"]`, expr)
}

func TestCueExprForValue_MapStringAny(t *testing.T) {
expr, err := cueExprForValue(map[string]any{"key": "string"})
require.NoError(t, err)
assert.Contains(t, expr, "key")
}

func TestCueExprForValue_String(t *testing.T) {
expr, err := cueExprForValue("string")
require.NoError(t, err)
assert.Equal(t, "string", expr)
}

func TestCueExprForValue_EmptyString(t *testing.T) {
_, err := cueExprForValue("")
require.Error(t, err)
assert.Contains(t, err.Error(), "non-empty")
}

func TestCueExprForValue_WhitespaceString(t *testing.T) {
_, err := cueExprForValue(" ")
require.Error(t, err)
assert.Contains(t, err.Error(), "non-empty")
}

func TestCueExprForValue_Int(t *testing.T) {
expr, err := cueExprForValue(42)
require.NoError(t, err)
assert.Equal(t, "42", expr)
}

func TestCueExprForValue_Bool(t *testing.T) {
expr, err := cueExprForValue(true)
require.NoError(t, err)
assert.Equal(t, "true", expr)
}

func TestCueExprForValue_UnsupportedType(t *testing.T) {
_, err := cueExprForValue(uint(42))
require.Error(t, err)
assert.Contains(t, err.Error(), "unsupported")
}

func TestCueExprForValue_UnsupportedStruct(t *testing.T) {
_, err := cueExprForValue(struct{}{})
require.Error(t, err)
assert.Contains(t, err.Error(), "unsupported schema value type")
}

// =====================================================================
// Phase 4 coverage: extractYAML
// =====================================================================
Expand All @@ -1002,18 +1038,36 @@ func TestExtractYAML_NormalCase(t *testing.T) {
assert.Equal(t, []byte("key: value\n"), result)
}

func TestExtractYAML_Normal(t *testing.T) {
input := []byte("---\ntitle: hello\nauthor: world\n---\n")
got := extractYAML(input)
assert.Equal(t, "title: hello\nauthor: world\n", string(got))
}

func TestExtractYAML_ClosingWithoutNewline(t *testing.T) {
input := []byte("---\nkey: value\n---")
result := extractYAML(input)
assert.Equal(t, []byte("key: value\n"), result)
}

func TestExtractYAML_NoTrailingNewline(t *testing.T) {
input := []byte("---\ntitle: hello\n---")
got := extractYAML(input)
assert.Equal(t, "title: hello\n", string(got))
}

func TestExtractYAML_NoClosingDelimiter(t *testing.T) {
input := []byte("---\nkey: value\n")
result := extractYAML(input)
assert.Nil(t, result)
}

func TestExtractYAML_UnclosedFrontMatter(t *testing.T) {
input := []byte("---\ntitle: hello\n")
got := extractYAML(input)
assert.Nil(t, got, "unclosed front matter should return nil")
}

// =====================================================================
// Phase 4 coverage: writeNodeText via headingText (CodeSpan branch)
// =====================================================================
Expand Down Expand Up @@ -1055,7 +1109,6 @@ func TestExtractPIFileParam_MultiLine(t *testing.T) {
src := "<?include\nfile: other.md\n?>"
f, err := lint.NewFileFromSource("schema.md", []byte(src), true)
require.NoError(t, err)

var pi *lint.ProcessingInstruction
for c := f.AST.FirstChild(); c != nil; c = c.NextSibling() {
if p, ok := c.(*lint.ProcessingInstruction); ok {
Expand All @@ -1064,7 +1117,6 @@ func TestExtractPIFileParam_MultiLine(t *testing.T) {
}
}
require.NotNil(t, pi, "expected ProcessingInstruction in parsed AST")

result, err := extractPIFileParam(pi, []byte(src))
require.NoError(t, err)
assert.Equal(t, "other.md", result)
Expand Down
Loading
Loading