Skip to content

Commit 02fc9f8

Browse files
author
merge-queue-bot
committed
Merge PR #760: fix(cli): skip non-Markdown files named explicitly on check/fix
2 parents 47ace27 + a6ed778 commit 02fc9f8

9 files changed

Lines changed: 278 additions & 3 deletions

File tree

cmd/mdsmith/check.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ func parseCheckFlags(args []string) (checkCLIOpts, []string, bool, int) {
113113
func checkFiles(fileArgs []string, opts checkCLIOpts) int {
114114
cfg, cfgPath, logger, files, maxBytes, code := loadAndResolve(
115115
fileArgs, opts.configPath, opts.verbose, opts.walk, opts.maxInputSize,
116+
nonMarkdownSkipWarner(os.Stderr, opts.format, opts.quiet),
116117
)
117118
if code >= 0 {
118119
return code
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package main_test
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
// gitattributesFixture is a .gitattributes body whose `#` comment lines
13+
// would be parsed as ATX headings if linted as Markdown: they sit with
14+
// no surrounding blank lines (blank-line-around-headings would insert
15+
// them) and end in punctuation (MDS017). If the explicit-path guard
16+
// regresses, `fix` rewrites this content and the byte-equality
17+
// assertion fails.
18+
const gitattributesFixture = `# BEGIN mdsmith merge-driver
19+
*.md merge=mdsmith
20+
*.markdown merge=mdsmith
21+
# END mdsmith merge-driver
22+
# Re-enable git 3-way merge for code under the trees mdsmith marks -merge.
23+
# mdsmith derives those -merge lines from the config ignore patterns.
24+
`
25+
26+
// TestFix_ExplicitNonMarkdownFileUnchanged is the regression test for
27+
// issue #759: passing a non-Markdown file explicitly to `mdsmith fix`
28+
// must not rewrite it. The directory walk already skips such files; an
29+
// explicit path must behave the same.
30+
func TestFix_ExplicitNonMarkdownFileUnchanged(t *testing.T) {
31+
dir := t.TempDir()
32+
isolateDir(t, dir)
33+
attrPath := filepath.Join(dir, ".gitattributes")
34+
require.NoError(t, os.WriteFile(attrPath, []byte(gitattributesFixture), 0o644))
35+
36+
_, stderr, exitCode := runBinaryInDir(t, dir, "", "fix", ".gitattributes")
37+
assert.Equal(t, 0, exitCode, "fix on a non-Markdown file should succeed with nothing to do")
38+
assert.Contains(t, stderr, `skipping ".gitattributes"`,
39+
"fix should warn that the explicitly named non-Markdown file was skipped, not do nothing silently")
40+
41+
got, err := os.ReadFile(attrPath)
42+
require.NoError(t, err)
43+
assert.Equal(t, gitattributesFixture, string(got),
44+
"fix must leave a non-Markdown file byte-for-byte unchanged")
45+
}
46+
47+
// TestCheck_ExplicitNonMarkdownFileClean is the check-side counterpart:
48+
// a non-Markdown file named explicitly is skipped (no Markdown content
49+
// diagnostics, clean exit) but reported with a warning rather than a
50+
// silent no-op.
51+
func TestCheck_ExplicitNonMarkdownFileClean(t *testing.T) {
52+
dir := t.TempDir()
53+
isolateDir(t, dir)
54+
attrPath := filepath.Join(dir, ".gitattributes")
55+
require.NoError(t, os.WriteFile(attrPath, []byte(gitattributesFixture), 0o644))
56+
57+
stdout, stderr, exitCode := runBinaryInDir(t, dir, "", "check", ".gitattributes")
58+
assert.Equal(t, 0, exitCode,
59+
"check on a non-Markdown file should exit clean; stdout=%q stderr=%q", stdout, stderr)
60+
assert.NotContains(t, stderr, "MDS", "no Markdown diagnostics should be reported")
61+
assert.Contains(t, stderr, `skipping ".gitattributes"`,
62+
"check should warn that the explicitly named non-Markdown file was skipped")
63+
}
64+
65+
// TestCheck_NonMarkdownWarningSuppressed verifies the skip warning is a
66+
// text-format, non-quiet affordance only: it must not appear under
67+
// --quiet (non-error output) or --format json (the JSON document shares
68+
// the stderr stream and a prose line would corrupt it).
69+
func TestCheck_NonMarkdownWarningSuppressed(t *testing.T) {
70+
dir := t.TempDir()
71+
isolateDir(t, dir)
72+
attrPath := filepath.Join(dir, ".gitattributes")
73+
require.NoError(t, os.WriteFile(attrPath, []byte(gitattributesFixture), 0o644))
74+
75+
for _, tc := range []struct {
76+
name string
77+
args []string
78+
}{
79+
{"quiet", []string{"check", "--quiet", ".gitattributes"}},
80+
{"json", []string{"check", "-f", "json", ".gitattributes"}},
81+
} {
82+
t.Run(tc.name, func(t *testing.T) {
83+
stdout, stderr, exitCode := runBinaryInDir(t, dir, "", tc.args...)
84+
assert.Equal(t, 0, exitCode, "should still exit clean; stdout=%q stderr=%q", stdout, stderr)
85+
assert.NotContains(t, stderr, "skipping",
86+
"the human skip warning must be suppressed in %s mode", tc.name)
87+
})
88+
}
89+
}
90+
91+
// TestCheck_MixedExplicitPathsLintsOnlyMarkdown pins that when both a
92+
// Markdown file and a non-Markdown file are named explicitly, only the
93+
// Markdown one is linted: the non-Markdown file is dropped, but the
94+
// Markdown file's genuine diagnostic still surfaces.
95+
func TestCheck_MixedExplicitPathsLintsOnlyMarkdown(t *testing.T) {
96+
dir := t.TempDir()
97+
isolateDir(t, dir)
98+
attrPath := filepath.Join(dir, ".gitattributes")
99+
mdPath := filepath.Join(dir, "bad.md")
100+
require.NoError(t, os.WriteFile(attrPath, []byte(gitattributesFixture), 0o644))
101+
// A heading ending in a period is MDS017, a default-enabled rule
102+
// (the issue confirms it fires); used only to prove the Markdown
103+
// file was actually linted.
104+
require.NoError(t, os.WriteFile(mdPath, []byte("# Heading.\n"), 0o644))
105+
106+
stdout, stderr, exitCode := runBinaryInDir(t, dir, "", "check", ".gitattributes", "bad.md")
107+
assert.Equal(t, 1, exitCode,
108+
"the Markdown file's diagnostic should still fail the run; stdout=%q stderr=%q", stdout, stderr)
109+
assert.Contains(t, stderr, "bad.md", "the Markdown file should be the one reported")
110+
// The non-Markdown file may appear in the skip warning
111+
// (`skipping ".gitattributes"`), but must never appear as a linted
112+
// diagnostic — those are anchored as `<path>:<line>:<col>`.
113+
assert.NotContains(t, stderr, ".gitattributes:",
114+
"the non-Markdown file must not be linted (no diagnostic anchored to it)")
115+
}

cmd/mdsmith/fix.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,7 @@ type fixCLIOpts struct {
226226
func fixFiles(fileArgs []string, opts fixCLIOpts) int {
227227
cfg, cfgPath, logger, files, maxBytes, code := loadAndResolve(
228228
fileArgs, opts.configPath, opts.verbose, opts.walk, opts.maxInputSize,
229+
nonMarkdownSkipWarner(os.Stderr, opts.format, opts.quiet),
229230
)
230231
if code >= 0 {
231232
return code

cmd/mdsmith/main.go

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"os"
88
"path/filepath"
99
"runtime/debug"
10+
"strings"
1011

1112
flag "github.com/spf13/pflag"
1213

@@ -15,6 +16,7 @@ import (
1516
"github.com/jeduden/mdsmith/internal/discovery"
1617
"github.com/jeduden/mdsmith/internal/lint"
1718
vlog "github.com/jeduden/mdsmith/internal/log"
19+
"github.com/jeduden/mdsmith/internal/mdpath"
1820
"github.com/jeduden/mdsmith/internal/output"
1921
"github.com/jeduden/mdsmith/internal/profiling"
2022
"github.com/jeduden/mdsmith/internal/query"
@@ -385,7 +387,7 @@ func printRunStatsTo(w io.Writer, format string, quiet bool, stats runStats) {
385387
func loadAndResolve(
386388
fileArgs []string, configPath string,
387389
verbose bool, walk walkCLI,
388-
maxInputSize string,
390+
maxInputSize string, onSkipNonMarkdown func(string),
389391
) (*config.Config, string, *vlog.Logger, []string, int64, int) {
390392
logger := &vlog.Logger{Enabled: verbose, W: os.Stderr}
391393

@@ -399,6 +401,7 @@ func loadAndResolve(
399401
}
400402

401403
opts := resolveOpts(cfg, walk)
404+
opts.OnSkipNonMarkdown = onSkipNonMarkdown
402405
files, err := lint.ResolveFilesWithOpts(fileArgs, opts)
403406
if err != nil {
404407
fmt.Fprintf(os.Stderr, "mdsmith: %v\n", err)
@@ -417,6 +420,34 @@ func loadAndResolve(
417420
return cfg, cfgPath, logger, files, maxBytes, -1
418421
}
419422

423+
// nonMarkdownSkipWarner returns the OnSkipNonMarkdown hook for check and
424+
// fix: a function that writes one stderr line per explicitly named
425+
// non-Markdown file that was skipped, so `mdsmith fix .gitattributes` is
426+
// a visible no-op instead of a silent one (issue #759).
427+
//
428+
// It returns nil — disabling the notification entirely — when the run is
429+
// --quiet (a skipped file is non-error output) or the format is not
430+
// text. check and fix emit their diagnostics, including `--format json`
431+
// and `--format sarif`, on stderr; a prose warning on the same stream
432+
// would corrupt that structured output, so the human notice is limited
433+
// to the text format. Repeated names are de-duplicated so a doubled
434+
// argument does not double the warning.
435+
func nonMarkdownSkipWarner(w io.Writer, format string, quiet bool) func(string) {
436+
if quiet || format != "text" {
437+
return nil
438+
}
439+
exts := strings.Join(mdpath.Extensions(), ", ")
440+
seen := make(map[string]struct{})
441+
return func(path string) {
442+
if _, dup := seen[path]; dup {
443+
return
444+
}
445+
seen[path] = struct{}{}
446+
// Write error swallowed: see printErrorsTo rationale.
447+
_, _ = fmt.Fprintf(w, "mdsmith: skipping %q: not a Markdown file (expected %s)\n", path, exts)
448+
}
449+
}
450+
420451
// splitStdinArg separates a "-" argument (stdin) from file arguments.
421452
// Returns true if "-" was found and the remaining file arguments.
422453
func splitStdinArg(args []string) (hasStdin bool, fileArgs []string) {

cmd/mdsmith/main_unit_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1630,3 +1630,38 @@ func TestReportFixResultTo_LargeDiagWriteErrorReturns2(t *testing.T) {
16301630
code := reportFixResultTo(opts, result, &vlog.Logger{}, &alwaysErrorWriter{})
16311631
assert.Equal(t, 2, code)
16321632
}
1633+
1634+
func TestNonMarkdownSkipWarner_TextEmitsAndDedupes(t *testing.T) {
1635+
var buf bytes.Buffer
1636+
warn := nonMarkdownSkipWarner(&buf, "text", false)
1637+
require.NotNil(t, warn, "text, non-quiet should produce an active warner")
1638+
1639+
warn(".gitattributes")
1640+
warn(".gitattributes") // duplicate: must not warn twice
1641+
warn("Makefile")
1642+
1643+
out := buf.String()
1644+
assert.Equal(t, 1, strings.Count(out, `skipping ".gitattributes"`),
1645+
"a repeated path must warn only once")
1646+
assert.Contains(t, out, `skipping "Makefile"`)
1647+
// The recognized extensions are surfaced so a user with a
1648+
// near-miss name (e.g. notes.mdown) understands why it was skipped.
1649+
assert.Contains(t, out, ".md, .markdown")
1650+
}
1651+
1652+
func TestNonMarkdownSkipWarner_SuppressedForQuietAndNonText(t *testing.T) {
1653+
for _, tc := range []struct {
1654+
name string
1655+
format string
1656+
quiet bool
1657+
}{
1658+
{"quiet", "text", true},
1659+
{"json", "json", false},
1660+
{"sarif", "sarif", false},
1661+
} {
1662+
t.Run(tc.name, func(t *testing.T) {
1663+
assert.Nil(t, nonMarkdownSkipWarner(io.Discard, tc.format, tc.quiet),
1664+
"warner must be disabled to avoid non-error output / corrupting structured formats")
1665+
})
1666+
}
1667+
}

docs/reference/cli/check.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ read from stdin. With no file arguments, files are
1919
discovered from `.mdsmith.yml` `files:` patterns
2020
(default: `**/*.md`, `**/*.markdown`).
2121

22+
Only Markdown files are linted. A non-Markdown path
23+
(such as `.gitattributes`) is skipped whether the walk
24+
reaches it or you name it explicitly. Naming one
25+
explicitly prints a `skipping …: not a Markdown file`
26+
warning on stderr, so the skip is never a silent no-op;
27+
`--quiet` and the `json`/`sarif` formats suppress it.
28+
2229
## Flags
2330

2431
| Flag | Default | Description |

docs/reference/cli/fix.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ Files can be paths, directories (walked recursively for
1616
rejected — files must be writable. With no file arguments,
1717
files are discovered from `.mdsmith.yml` `files:` patterns.
1818

19+
Only Markdown files are fixed. A non-Markdown path (such
20+
as `.gitattributes`) is skipped whether the walk reaches
21+
it or you name it explicitly, so `fix` never rewrites it.
22+
Naming one explicitly prints a `skipping …: not a
23+
Markdown file` warning on stderr; `--quiet` and the
24+
`json`/`sarif` formats suppress it.
25+
1926
## Flags
2027

2128
| Flag | Default | Description |

internal/lint/files.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,16 @@ type ResolveOpts struct {
8989
// root) as well as FIFOs, devices, and sockets (reading them
9090
// during linting could block or fail unexpectedly).
9191
FollowSymlinks bool
92+
93+
// OnSkipNonMarkdown, when non-nil, is called with each
94+
// explicitly named path dropped because it is not Markdown, so a
95+
// caller can warn instead of leaving `mdsmith fix .gitattributes`
96+
// a silent no-op (issue #759). It fires ONLY for a directly named
97+
// file: entries filtered out during a directory walk or glob
98+
// expansion are skipped silently, because the user named the
99+
// directory or pattern, not the individual file. The argument is
100+
// the path exactly as it was passed.
101+
OnSkipNonMarkdown func(path string)
92102
}
93103

94104
// DefaultResolveOpts returns options with defaults applied.
@@ -211,7 +221,24 @@ func resolveArg(arg string, opts ResolveOpts, addFile func(string)) error {
211221
return nil
212222
}
213223

214-
// Explicitly named files are never filtered by gitignore.
224+
// Skip files that are not Markdown even when named explicitly, so
225+
// an explicit path matches the walk (walkDir) and glob (resolveGlob)
226+
// branches, both of which gate on isMarkdown. Without this, `mdsmith
227+
// check .gitattributes` lints a git-config file as Markdown and
228+
// `mdsmith fix` rewrites it — including the merge-driver block
229+
// mdsmith generates there (issue #759). The extension is the only
230+
// signal available at resolve time; content sniffing would be both
231+
// slower and ambiguous. Unlike the walk and glob paths, a directly
232+
// named file gets an OnSkipNonMarkdown notification so the caller
233+
// can warn rather than silently doing nothing.
234+
if !isMarkdown(arg) {
235+
if opts.OnSkipNonMarkdown != nil {
236+
opts.OnSkipNonMarkdown(arg)
237+
}
238+
return nil
239+
}
240+
241+
// Explicitly named Markdown files are never filtered by gitignore.
215242
addFile(arg)
216243
return nil
217244
}

internal/lint/files_test.go

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,61 @@ func TestResolveFiles_NonMarkdownFile(t *testing.T) {
2929
txtFile := filepath.Join(dir, "test.txt")
3030
require.NoError(t, os.WriteFile(txtFile, []byte("hello"), 0o644))
3131

32-
// Non-markdown files are still returned when given explicitly as args.
32+
// A non-Markdown file named explicitly is skipped, matching the
33+
// directory walk and glob expansion — mdsmith cannot meaningfully
34+
// lint a file that is not Markdown, and fixing one rewrites content
35+
// it never should touch (issue #759, e.g. .gitattributes).
3336
files, err := ResolveFiles([]string{txtFile})
3437
require.NoError(t, err)
38+
require.Empty(t, files)
39+
}
40+
41+
// TestResolveFiles_MixedMarkdownAndNonMarkdown pins that an explicit
42+
// argument list keeps its Markdown members and drops the rest, so a
43+
// caller that names both a real doc and a config file (or shell-globs a
44+
// wider set) still lints exactly the Markdown ones. Regression guard for
45+
// issue #759.
46+
func TestResolveFiles_MixedMarkdownAndNonMarkdown(t *testing.T) {
47+
dir := t.TempDir()
48+
mdFile := filepath.Join(dir, "doc.md")
49+
txtFile := filepath.Join(dir, "notes.txt")
50+
attrFile := filepath.Join(dir, ".gitattributes")
51+
require.NoError(t, os.WriteFile(mdFile, []byte("# Hello"), 0o644))
52+
require.NoError(t, os.WriteFile(txtFile, []byte("hello"), 0o644))
53+
require.NoError(t, os.WriteFile(attrFile, []byte("*.md merge=mdsmith\n"), 0o644))
54+
55+
files, err := ResolveFiles([]string{mdFile, txtFile, attrFile})
56+
require.NoError(t, err)
3557
require.Len(t, files, 1)
58+
assert.Equal(t, mdFile, files[0])
59+
}
60+
61+
// TestResolveFiles_OnSkipNonMarkdown_ExplicitOnly verifies the
62+
// OnSkipNonMarkdown hook fires once per explicitly named non-Markdown
63+
// file (so the CLI can warn instead of a silent no-op), but never for a
64+
// Markdown file, and never for non-Markdown entries filtered out by a
65+
// directory walk — the walk skips those by design and the user did not
66+
// name them. Guards the warning wiring for issue #759.
67+
func TestResolveFiles_OnSkipNonMarkdown_ExplicitOnly(t *testing.T) {
68+
dir := t.TempDir()
69+
mdFile := filepath.Join(dir, "doc.md")
70+
txtFile := filepath.Join(dir, "notes.txt")
71+
require.NoError(t, os.WriteFile(mdFile, []byte("# Hello"), 0o644))
72+
require.NoError(t, os.WriteFile(txtFile, []byte("hello"), 0o644))
73+
// A non-Markdown file inside a walked directory must NOT trigger
74+
// the hook: the walk drops it silently and the user named the dir,
75+
// not the file.
76+
require.NoError(t, os.WriteFile(filepath.Join(dir, "walked.txt"), []byte("x"), 0o644))
77+
78+
var skipped []string
79+
opts := DefaultResolveOpts()
80+
opts.OnSkipNonMarkdown = func(p string) { skipped = append(skipped, p) }
81+
82+
files, err := ResolveFilesWithOpts([]string{mdFile, txtFile, dir}, opts)
83+
require.NoError(t, err)
84+
require.Equal(t, []string{mdFile}, files)
85+
assert.Equal(t, []string{txtFile}, skipped,
86+
"only the explicitly named non-Markdown file should be reported")
3687
}
3788

3889
func TestResolveFiles_Directory(t *testing.T) {

0 commit comments

Comments
 (0)