diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f79eb796a..59fcc2b1f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -109,6 +109,7 @@ row: "- [{summary}](../{filename})" - [Built-in Markdown conventions, the rule presets each one applies, and how user config layers on top via deep-merge.](../docs/reference/conventions.md) - [Glob pattern syntax across mdsmith config, directives, and CLI argument expansion, with the supported exclusion semantics for each surface.](../docs/reference/globs.md) - [Look up exact CLI commands, config glob and schema syntax, the built-in conventions, and the section-schema grammar.](../docs/reference/index.md) +- [Each file under `.mdsmith/kinds/` declares one kind. The basename is the kind name; the file body carries the full `KindBody` — schema, rules, `path-pattern:`, `extends:`. Sits alongside inline `kinds.:` in `.mdsmith.yml`.](../docs/reference/kind-files.md) - [Named field-type shortcuts for inline schema frontmatter values — the registered names, the canonical CUE each one resolves to, and example usage.](../docs/reference/schema-types.md) - [Section-schema reference for inline `kinds..schema:` blocks. Covers the `heading:` discriminator, the `regex:` matcher (a Go RE2 body with `\#(digits)` and `\#(fmvar(...))` helpers), the `repeat: {min, max}` cardinality field, and the matching algorithm. `proto.md` files are parsed into the same shape by the schema package, but MDS020's file-schema check still uses its legacy parser; see the proto.md section below for what is and is not migrated.](../docs/reference/section-schema.md) - [mdsmith collects no telemetry, no usage analytics, no error reports, and no identifiers. The CLI and the LSP server make no outbound network calls at runtime.](../docs/reference/telemetry.md) diff --git a/AGENTS.md b/AGENTS.md index 88b2d5bbd..9250e6cc4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -115,6 +115,7 @@ row: "- [{summary}]({filename})" - [Built-in Markdown conventions, the rule presets each one applies, and how user config layers on top via deep-merge.](docs/reference/conventions.md) - [Glob pattern syntax across mdsmith config, directives, and CLI argument expansion, with the supported exclusion semantics for each surface.](docs/reference/globs.md) - [Look up exact CLI commands, config glob and schema syntax, the built-in conventions, and the section-schema grammar.](docs/reference/index.md) +- [Each file under `.mdsmith/kinds/` declares one kind. The basename is the kind name; the file body carries the full `KindBody` — schema, rules, `path-pattern:`, `extends:`. Sits alongside inline `kinds.:` in `.mdsmith.yml`.](docs/reference/kind-files.md) - [Named field-type shortcuts for inline schema frontmatter values — the registered names, the canonical CUE each one resolves to, and example usage.](docs/reference/schema-types.md) - [Section-schema reference for inline `kinds..schema:` blocks. Covers the `heading:` discriminator, the `regex:` matcher (a Go RE2 body with `\#(digits)` and `\#(fmvar(...))` helpers), the `repeat: {min, max}` cardinality field, and the matching algorithm. `proto.md` files are parsed into the same shape by the schema package, but MDS020's file-schema check still uses its legacy parser; see the proto.md section below for what is and is not migrated.](docs/reference/section-schema.md) - [mdsmith collects no telemetry, no usage analytics, no error reports, and no identifiers. The CLI and the LSP server make no outbound network calls at runtime.](docs/reference/telemetry.md) diff --git a/CLAUDE.md b/CLAUDE.md index 0340404b2..3c7efcc14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -101,6 +101,7 @@ row: "- [{summary}]({filename})" - [Built-in Markdown conventions, the rule presets each one applies, and how user config layers on top via deep-merge.](docs/reference/conventions.md) - [Glob pattern syntax across mdsmith config, directives, and CLI argument expansion, with the supported exclusion semantics for each surface.](docs/reference/globs.md) - [Look up exact CLI commands, config glob and schema syntax, the built-in conventions, and the section-schema grammar.](docs/reference/index.md) +- [Each file under `.mdsmith/kinds/` declares one kind. The basename is the kind name; the file body carries the full `KindBody` — schema, rules, `path-pattern:`, `extends:`. Sits alongside inline `kinds.:` in `.mdsmith.yml`.](docs/reference/kind-files.md) - [Named field-type shortcuts for inline schema frontmatter values — the registered names, the canonical CUE each one resolves to, and example usage.](docs/reference/schema-types.md) - [Section-schema reference for inline `kinds..schema:` blocks. Covers the `heading:` discriminator, the `regex:` matcher (a Go RE2 body with `\#(digits)` and `\#(fmvar(...))` helpers), the `repeat: {min, max}` cardinality field, and the matching algorithm. `proto.md` files are parsed into the same shape by the schema package, but MDS020's file-schema check still uses its legacy parser; see the proto.md section below for what is and is not migrated.](docs/reference/section-schema.md) - [mdsmith collects no telemetry, no usage analytics, no error reports, and no identifiers. The CLI and the LSP server make no outbound network calls at runtime.](docs/reference/telemetry.md) diff --git a/PLAN.md b/PLAN.md index 8f1f3ce9f..d74f5fc63 100644 --- a/PLAN.md +++ b/PLAN.md @@ -133,5 +133,6 @@ footer: | | 205 | 🔲 | | [Move extension.ts concerns to wiring.ts](plan/205_arch-fix-extension-ts-srp.md) | | 206 | 🔲 | | [Document cue/ in architecture layering map](plan/206_arch-fix-cue-types-docs.md) | | 207 | 🔲 | sonnet | [LSP fix preview via ChangeAnnotation](plan/207_lsp-fix-preview.md) | -| 208 | 🔲 | opus | [Kind-per-file config under `.mdsmith/kinds/`](plan/208_kind-files.md) | +| 208 | 🔳 | opus | [Kind-per-file config under `.mdsmith/kinds/`](plan/208_kind-files.md) | +| 209 | 🔲 | opus | [Convention-per-file config under `.mdsmith/conventions/`](plan/209_convention-files.md) | diff --git a/docs/development/architecture/cross-system.md b/docs/development/architecture/cross-system.md index d9c083756..f2d274721 100644 --- a/docs/development/architecture/cross-system.md +++ b/docs/development/architecture/cross-system.md @@ -34,6 +34,7 @@ doc" column says. | LSP wire protocol | `internal/lsp` | [CLI reference: `lsp`](../../reference/cli/lsp.md) | VS Code extension, other editors | | CLI flags + exit codes | `cmd/mdsmith` | [CLI reference](../../reference/cli.md) | shell scripts, CI, git hooks | | `.mdsmith.yml` schema | `internal/config` | [Conventions](../../reference/conventions.md) | every project using mdsmith | +| `.mdsmith/kinds/` directory | `internal/config` | [Kind files](../../reference/kind-files.md) | every project using mdsmith | | Generated section markers | `internal/archetype/gensection` | [Generated sections](../../background/concepts/generated-section.md) | every project's Markdown files | | Claude plugin manifest (published) | `editors/claude-code/.claude-plugin/plugin.json` | [Install: Claude plugin](../../guides/install.md) | end users via Claude Code marketplace | | Claude plugin manifest (contributors) | `editors/claude-code-dev/.claude-plugin/plugin.json` | [editors/claude-code-dev/README.md](../../../editors/claude-code-dev/README.md) | mdsmith contributors | diff --git a/docs/guides/file-kinds.md b/docs/guides/file-kinds.md index 8ab499f4e..0baee3d24 100644 --- a/docs/guides/file-kinds.md +++ b/docs/guides/file-kinds.md @@ -230,6 +230,43 @@ effective kinds: - plan (from kind-assignment[2]: glob plan/*.md AND fields-present id) ``` +## Split a kind into its own file + +When a project's `kinds:` block has grown large +enough that one kind's edits dirty the same +`.mdsmith.yml` as every other config change, lift +the kind into a standalone file under +`.mdsmith/kinds/.yaml`. The basename is the +kind's name; the body is identical to the inline +`kinds.:` body. + +```yaml +# .mdsmith/kinds/audit-log.yaml +schema: + frontmatter: + title: 'string & != ""' + "summary?": 'string' + closed: false +rules: + max-file-length: + max: 600 +``` + +The same name in both a file and inline is a +config error naming both sources, so split a kind +in one move rather than leaving both copies +behind. Names stay stable, so `kind-assignment:` +entries that referenced the inline kind keep +working with no edit. + +`mdsmith kinds resolve ` and `mdsmith kinds +show ` print the defining-source path next +to each kind so a mixed configuration stays +auditable. See the +[kind files reference](../reference/kind-files.md) +for the full layout, basename rule, and JSON +shape. + ## Schema inheritance with `extends` A kind can build on another kind's schema via the `extends:` key. diff --git a/docs/reference/index.md b/docs/reference/index.md index 8912c783f..fe4f1bd73 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -36,6 +36,7 @@ row: "- [{summary}]({filename})" - [Print the mdsmith build version and exit.](cli/version.md) - [Built-in Markdown conventions, the rule presets each one applies, and how user config layers on top via deep-merge.](conventions.md) - [Glob pattern syntax across mdsmith config, directives, and CLI argument expansion, with the supported exclusion semantics for each surface.](globs.md) +- [Each file under `.mdsmith/kinds/` declares one kind. The basename is the kind name; the file body carries the full `KindBody` — schema, rules, `path-pattern:`, `extends:`. Sits alongside inline `kinds.:` in `.mdsmith.yml`.](kind-files.md) - [Named field-type shortcuts for inline schema frontmatter values — the registered names, the canonical CUE each one resolves to, and example usage.](schema-types.md) - [Section-schema reference for inline `kinds..schema:` blocks. Covers the `heading:` discriminator, the `regex:` matcher (a Go RE2 body with `\#(digits)` and `\#(fmvar(...))` helpers), the `repeat: {min, max}` cardinality field, and the matching algorithm. `proto.md` files are parsed into the same shape by the schema package, but MDS020's file-schema check still uses its legacy parser; see the proto.md section below for what is and is not migrated.](section-schema.md) - [mdsmith collects no telemetry, no usage analytics, no error reports, and no identifiers. The CLI and the LSP server make no outbound network calls at runtime.](telemetry.md) diff --git a/docs/reference/kind-files.md b/docs/reference/kind-files.md new file mode 100644 index 000000000..a543fc270 --- /dev/null +++ b/docs/reference/kind-files.md @@ -0,0 +1,139 @@ +--- +title: Kind files under `.mdsmith/kinds/` +weight: 25 +summary: >- + Each file under `.mdsmith/kinds/` declares one + kind. The basename is the kind name; the file body + carries the full `KindBody` — schema, rules, + `path-pattern:`, `extends:`. Sits alongside inline + `kinds.:` in `.mdsmith.yml`. +--- +# Kind files under `.mdsmith/kinds/` + +A **kind file** is a YAML file under +`.mdsmith/kinds/` whose basename is the kind's name +and whose body is the full kind definition. One file +per kind, no nesting. The directory sits next to +`.mdsmith.yml` at the workspace root. + +```text +.mdsmith.yml # unchanged +.mdsmith/ + kinds/ + audit-log.yaml + secret-rotation.yaml + architecture-doc.yaml +``` + +Use kind files when the `kinds:` block has grown +large. Each rule edit dirties the same +`.mdsmith.yml` as every other config change. +Splitting kinds into one file each isolates the +history. The read path shortens too: open +`audit-log.yaml` to see the whole `audit-log` +kind. + +## File shape + +The file body matches the inline +`kinds.:` body. It accepts every +[`KindBody`](../guides/file-kinds.md) key: +`extends:`, `path-pattern:`, `categories:`, +`schema:`, `rules:`. A key outside that set is +a config error. + +```yaml +# .mdsmith/kinds/audit-log.yaml +schema: + frontmatter: + title: 'string & != ""' + "summary?": 'string' + audit-from: '=~"^[0-9a-f]{7,40}$"' + filename: "architecture-audit.md" + closed: false + sections: + - heading: null + - heading: + regex: '.+' + repeat: { min: 0 } +rules: + max-file-length: + max: 600 +``` + +## Basename rule + +The kind's name is the basename minus extension. +The basename must match `[a-z][a-z0-9-]*` — lower +case, starting with a letter, with optional +hyphen-separated segments. The rule applies only to +filenames (OS case folding, path safety); inline +`kinds.:` keys stay unvalidated. + +Both `*.yaml` and `*.yml` are scanned. Two kind +files with the same basename across the two +extensions is a config error naming both files. + +Subdirectories under `.mdsmith/kinds/` are +rejected. One kind per file, flat layout. + +## Composition with `.mdsmith.yml` + +`kinds.:` blocks inside `.mdsmith.yml` +remain a first-class source. A project can mix +inline and file-defined kinds freely. + +The same kind name declared in **both** a file and +inline is a config error naming both sources. The +two sources do **not** merge — a merged kind would +defeat the "read one file to know one kind" +property kind files ship. + +`kind-assignment:`, `overrides:`, and `ignore:` are +glob-keyed and stay in `.mdsmith.yml`. A +kind-assignment entry references a kind by name — +inline or file kind — with no extra wiring. + +## Schema sources + +A kind file accepts the same three schema sources +as an inline kind, and they remain mutually +exclusive (acceptance criterion #6 of plan 208): + +- inline `schema:` block +- `rules.required-structure.schema:` path to a + `proto.md` +- legacy `rules.required-structure.inline-schema:` + map + +Setting two on the same kind errors at config load +with both source names. + +A schema shared across kinds is shared via +`extends:` — the inheritance chain works +seamlessly across sources. A file kind may extend +an inline kind and the reverse. The cycle detector +runs on the merged kinds map. + +## Audit + +`mdsmith kinds resolve ` prints the +defining-source path next to each kind it +reports. A mixed resolution shows the path each +kind came from. You can jump straight to the +right file: + +```text +file: docs/audit.md +effective kinds: + - audit-log (from kind-assignment[3]: glob docs/**/*.md) defined-in .mdsmith/kinds/audit-log.yaml +``` + +`mdsmith kinds show ` adds a `defined-in:` +line to the body output, so the same info is +available without going through a target file. + +The JSON shape (`--json`) carries a +`source-path:` key on each kind body and on every +resolved-kind entry so editor integrations can +key off a stable field. diff --git a/internal/config/config.go b/internal/config/config.go index 411dafa54..403e8454b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -155,6 +155,14 @@ type KindBody struct { // sections. A cycle in the extends chain (single- or multi-hop) // is reported by ValidateKinds. See plan 135. Extends string `yaml:"extends,omitempty"` + + // SourcePath is the workspace-absolute path of the file that + // defined this kind — either `.mdsmith.yml` for inline kinds + // or `.mdsmith/kinds/.{yaml,yml}` for file-defined kinds + // (plan 208). Not serialized to YAML; populated by Load. + // Provenance surfaces it as `kinds. ()`; CLI + // resolve/show prints it next to each kind. + SourcePath string `yaml:"-"` } // KindAssignmentEntry assigns one or more kinds to files matching the diff --git a/internal/config/kind_files.go b/internal/config/kind_files.go new file mode 100644 index 000000000..2303c3f44 --- /dev/null +++ b/internal/config/kind_files.go @@ -0,0 +1,159 @@ +package config + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + + "github.com/jeduden/mdsmith/internal/yamlutil" + "gopkg.in/yaml.v3" +) + +// kindFilesDir is the directory under the workspace root that +// holds one YAML file per kind. The basename of each file (minus +// extension) is the kind name. Both `*.yaml` and `*.yml` are +// scanned. Subdirectories under this path are rejected at load +// time — one kind per file, no nesting. See plan 208. +const kindFilesDir = ".mdsmith/kinds" + +// kindFileBasenameRE is the basename pattern a kind file must +// match (the same shape as a YAML map key but anchored for OS +// case-folding and path safety). Inline `kinds.:` keys stay +// unvalidated — this constraint only applies to filenames. +var kindFileBasenameRE = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) + +// discoveredKind pairs a parsed KindBody with the absolute path +// of the file it came from. The path feeds the dual-source +// collision check in Load and the per-kind provenance surface. +type discoveredKind struct { + body KindBody + sourcePath string +} + +// discoverKinds walks `.mdsmith/kinds/*.{yaml,yml}` at the +// workspace root and returns one entry per discovered kind. The +// returned map is keyed by basename (the kind's name). +// +// Errors fired (each names the offending file so the user can +// jump straight to it): +// - basename does not match `[a-z][a-z0-9-]*` +// - a subdirectory exists under `.mdsmith/kinds/` +// - the same basename appears as both `.yaml` and `.yml` +// - the YAML body has a top-level key outside `KindBody` +// +// A missing or empty `.mdsmith/kinds/` directory returns an +// empty map and no error so callers can blindly merge the +// result. +func discoverKinds(workspaceDir string) (map[string]discoveredKind, error) { + root := filepath.Join(workspaceDir, kindFilesDir) + entries, err := os.ReadDir(root) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("reading %s: %w", kindFilesDir, err) + } + + // Sort so error messages and the resulting map iteration + // produce a deterministic order across runs and platforms. + sort.Slice(entries, func(i, j int) bool { + return entries[i].Name() < entries[j].Name() + }) + + result := make(map[string]discoveredKind, len(entries)) + // Track which extension supplied each basename so a later + // `.yml` colliding with an earlier `.yaml` (or vice versa) + // can be reported with both filenames. + seenExt := make(map[string]string, len(entries)) + + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() { + return nil, fmt.Errorf( + "%s: subdirectories are not allowed (found %q)", + kindFilesDir, name) + } + ext := filepath.Ext(name) + if ext != ".yaml" && ext != ".yml" { + continue + } + base := name[:len(name)-len(ext)] + if !kindFileBasenameRE.MatchString(base) { + return nil, fmt.Errorf( + "%s/%s: basename %q must match %s", + kindFilesDir, name, base, kindFileBasenameRE.String()) + } + if prior, ok := seenExt[base]; ok { + return nil, fmt.Errorf( + "%s: kind %q is declared by both %s and %s; "+ + "keep one", + kindFilesDir, base, prior, name) + } + seenExt[base] = name + + path := filepath.Join(root, name) + body, err := parseKindFile(path) + if err != nil { + return nil, err + } + body.SourcePath = path + result[base] = discoveredKind{body: body, sourcePath: path} + } + return result, nil +} + +// mergeKindFiles discovers file-defined kinds under the +// workspace root (parent of cfgPath) and merges them into +// cfg.Kinds. A name colliding between a file kind and an inline +// kind is a config error naming both sources — the two do not +// merge (a merged kind would defeat the "read one file to know +// one kind" property plan 208 ships). Load is the only caller +// and always supplies a non-empty cfgPath, so no defensive +// guard is needed for that. +func mergeKindFiles(cfg *Config, cfgPath string) error { + discovered, err := discoverKinds(filepath.Dir(cfgPath)) + if err != nil { + return err + } + if len(discovered) == 0 { + return nil + } + if cfg.Kinds == nil { + cfg.Kinds = make(map[string]KindBody, len(discovered)) + } + for name, dk := range discovered { + if existing, clash := cfg.Kinds[name]; clash { + return fmt.Errorf( + "kind %q is declared both inline in %s and in %s; "+ + "keep one source", + name, existing.SourcePath, dk.sourcePath) + } + cfg.Kinds[name] = dk.body + } + return nil +} + +// parseKindFile reads one kind file and decodes it into a +// KindBody with strict (KnownFields) decoding so a typo in a +// top-level key surfaces as a config error rather than being +// silently dropped. UnmarshalSafe handles anchor/alias rejection +// before the strict decode runs. +func parseKindFile(path string) (KindBody, error) { + data, err := os.ReadFile(path) //nolint:gosec // path is built from workspace + kindFilesDir + if err != nil { + return KindBody{}, fmt.Errorf("reading %s: %w", path, err) + } + if err := yamlutil.RejectYAMLAliases(data); err != nil { + return KindBody{}, fmt.Errorf("parsing %s: %w", path, err) + } + var body KindBody + dec := yaml.NewDecoder(bytes.NewReader(data)) + dec.KnownFields(true) + if err := dec.Decode(&body); err != nil { + return KindBody{}, fmt.Errorf("parsing %s: %w", path, err) + } + return body, nil +} diff --git a/internal/config/kind_files_test.go b/internal/config/kind_files_test.go new file mode 100644 index 000000000..36178e53e --- /dev/null +++ b/internal/config/kind_files_test.go @@ -0,0 +1,551 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestDiscoverKinds_EmptyWorkspaceReturnsEmpty pins the no-op +// branch: a workspace without `.mdsmith/kinds/` returns an empty +// map and no error, so callers can blindly merge the result. +func TestDiscoverKinds_EmptyWorkspaceReturnsEmpty(t *testing.T) { + dir := t.TempDir() + got, err := discoverKinds(dir) + require.NoError(t, err) + assert.Empty(t, got) +} + +// TestDiscoverKinds_EmptyKindsDirReturnsEmpty pins the case where +// `.mdsmith/kinds/` exists but holds no YAML files (e.g. a +// freshly-created tree before any kind file lands). +func TestDiscoverKinds_EmptyKindsDirReturnsEmpty(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".mdsmith", "kinds"), 0o755)) + got, err := discoverKinds(dir) + require.NoError(t, err) + assert.Empty(t, got) +} + +// TestDiscoverKinds_LoadsFullBody covers the happy path: a single +// kind file whose basename is the kind name, parsed into a full +// KindBody — schema, rules, path-pattern, extends. +func TestDiscoverKinds_LoadsFullBody(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".mdsmith", "kinds"), 0o755)) + body := `schema: + frontmatter: + title: 'string & != ""' + closed: false + sections: + - heading: null +path-pattern: "docs/**/*.md" +rules: + max-file-length: + max: 600 +` + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".mdsmith", "kinds", "audit-log.yaml"), + []byte(body), 0o644)) + + got, err := discoverKinds(dir) + require.NoError(t, err) + require.Contains(t, got, "audit-log") + dk := got["audit-log"] + assert.Equal(t, "docs/**/*.md", dk.body.PathPattern) + assert.Equal(t, 600, dk.body.Rules["max-file-length"].Settings["max"]) + require.NotNil(t, dk.body.Schema) + assert.Contains(t, dk.body.Schema, "frontmatter") + assert.Equal(t, + filepath.Join(dir, ".mdsmith", "kinds", "audit-log.yaml"), + dk.sourcePath) +} + +// TestDiscoverKinds_AcceptsBothExtensions covers the +// `*.yaml` and `*.yml` glob — both are scanned and produce kinds +// keyed by basename. +func TestDiscoverKinds_AcceptsBothExtensions(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".mdsmith", "kinds"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".mdsmith", "kinds", "foo.yaml"), + []byte("rules: {}\n"), 0o644)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".mdsmith", "kinds", "bar.yml"), + []byte("rules: {}\n"), 0o644)) + + got, err := discoverKinds(dir) + require.NoError(t, err) + assert.Contains(t, got, "foo") + assert.Contains(t, got, "bar") +} + +// TestDiscoverKinds_RejectsExtensionCollision pins the +// basename-collision check across `.yaml` and `.yml`. The error +// names both files so the user can pick which to keep. +func TestDiscoverKinds_RejectsExtensionCollision(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".mdsmith", "kinds"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".mdsmith", "kinds", "foo.yaml"), + []byte("rules: {}\n"), 0o644)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".mdsmith", "kinds", "foo.yml"), + []byte("rules: {}\n"), 0o644)) + + _, err := discoverKinds(dir) + require.Error(t, err) + assert.Contains(t, err.Error(), "foo") + assert.Contains(t, err.Error(), "foo.yaml") + assert.Contains(t, err.Error(), "foo.yml") +} + +// TestDiscoverKinds_RejectsSubdirectory pins the +// no-subdirectories rule — a nested file is a config error rather +// than being silently ignored. +func TestDiscoverKinds_RejectsSubdirectory(t *testing.T) { + dir := t.TempDir() + nested := filepath.Join(dir, ".mdsmith", "kinds", "nested") + require.NoError(t, os.MkdirAll(nested, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(nested, "foo.yaml"), []byte("rules: {}\n"), 0o644)) + + _, err := discoverKinds(dir) + require.Error(t, err) + assert.Contains(t, err.Error(), "subdirector") + assert.Contains(t, err.Error(), "nested") +} + +// TestDiscoverKinds_RejectsBadBasename pins the +// `[a-z][a-z0-9-]*` basename rule. Uppercase, leading digit, and +// underscore each fail. +func TestDiscoverKinds_RejectsBadBasename(t *testing.T) { + cases := []string{"Foo.yaml", "1bad.yaml", "bad_name.yaml", "BAD.yaml"} + for _, name := range cases { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".mdsmith", "kinds"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".mdsmith", "kinds", name), + []byte("rules: {}\n"), 0o644)) + + _, err := discoverKinds(dir) + require.Error(t, err) + assert.Contains(t, err.Error(), name) + }) + } +} + +// TestDiscoverKinds_RejectsUnknownKey pins the strict-decoding +// rule — a top-level key outside KindBody errors with the file +// and key name so the user can fix the typo. +func TestDiscoverKinds_RejectsUnknownKey(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".mdsmith", "kinds"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".mdsmith", "kinds", "foo.yaml"), + []byte("not-a-real-key: bar\nrules: {}\n"), 0o644)) + + _, err := discoverKinds(dir) + require.Error(t, err) + assert.Contains(t, err.Error(), "foo.yaml") + assert.Contains(t, err.Error(), "not-a-real-key") +} + +// TestDiscoverKinds_IgnoresNonYAMLFiles pins the +// non-`.yaml`/`.yml` skip branch: a stray `.txt` or `.md` +// alongside the kind files must be silently ignored rather +// than treated as a malformed kind. +func TestDiscoverKinds_IgnoresNonYAMLFiles(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".mdsmith", "kinds"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".mdsmith", "kinds", "foo.yaml"), + []byte("rules: {}\n"), 0o644)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".mdsmith", "kinds", "README.md"), + []byte("Notes\n"), 0o644)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".mdsmith", "kinds", ".keep"), + []byte(""), 0o644)) + + got, err := discoverKinds(dir) + require.NoError(t, err) + require.Contains(t, got, "foo") + assert.Len(t, got, 1, "non-YAML files must not produce extra kinds") +} + +// TestLoad_PropagatesKindFileDiscoveryError pins the +// error-propagation path: when discoverKinds returns an +// error, Load wraps it and aborts rather than continuing with +// a partial kinds map. +func TestLoad_PropagatesKindFileDiscoveryError(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".mdsmith", "kinds"), 0o755)) + // A bad basename triggers a discoverKinds error. + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".mdsmith", "kinds", "BadName.yaml"), + []byte("rules: {}\n"), 0o644)) + cfgPath := filepath.Join(dir, ".mdsmith.yml") + require.NoError(t, os.WriteFile(cfgPath, []byte("rules: {}\n"), 0o644)) + + _, err := Load(cfgPath) + require.Error(t, err) + assert.Contains(t, err.Error(), "loading kind files") + assert.Contains(t, err.Error(), "BadName.yaml") +} + +// TestParseKindFile_PropagatesReadError pins the +// os.ReadFile error branch: a file that cannot be read +// surfaces a "reading " error rather than panicking. +// Skipped on platforms or test users where chmod cannot +// produce an unreadable file (e.g. running as root). +func TestParseKindFile_PropagatesReadError(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "unreadable.yaml") + require.NoError(t, os.WriteFile(path, []byte("rules: {}\n"), 0o644)) + require.NoError(t, os.Chmod(path, 0o000)) + t.Cleanup(func() { _ = os.Chmod(path, 0o644) }) + + if _, err := os.ReadFile(path); err == nil { + t.Skip("test user can read mode-0000 files (likely running as root)") + } + + _, err := parseKindFile(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "reading") + assert.Contains(t, err.Error(), "unreadable.yaml") +} + +// TestDiscoverKinds_RejectsYAMLAnchors pins that the +// anchor/alias guard fires on kind files (defence against +// billion-laughs payloads, parallel to UnmarshalSafe on +// `.mdsmith.yml`). +func TestDiscoverKinds_RejectsYAMLAnchors(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".mdsmith", "kinds"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".mdsmith", "kinds", "foo.yaml"), + []byte("rules: &anchor {}\ncategories: *anchor\n"), 0o644)) + + _, err := discoverKinds(dir) + require.Error(t, err) + assert.Contains(t, err.Error(), "foo.yaml") + assert.Contains(t, err.Error(), "anchors/aliases") +} + +// TestDiscoverKinds_RejectsKindFileWithBadYAML pins the +// decode-error path: a `.mdsmith/kinds/.yaml` whose body +// is not valid YAML surfaces the parse error with the file +// name so the user can jump straight to it. +func TestDiscoverKinds_RejectsKindFileWithBadYAML(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".mdsmith", "kinds"), 0o755)) // not the file + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".mdsmith", "kinds", "foo.yaml"), + []byte("rules: [this is: not valid yaml\n"), 0o644)) + + _, err := discoverKinds(dir) + require.Error(t, err) + assert.Contains(t, err.Error(), "foo.yaml") +} + +// TestDiscoverKinds_RejectsKindsPathIsFile pins the +// non-IsNotExist ReadDir branch. When the workspace contains a +// regular file at `.mdsmith/kinds`, the ReadDir call errors with +// ENOTDIR — the discoverer must propagate that error rather than +// silently returning an empty map (which would mask a misconfigured +// workspace as "no kinds"). +func TestDiscoverKinds_RejectsKindsPathIsFile(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".mdsmith"), 0o755)) + // Plant a regular file where the kinds directory would live. + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".mdsmith", "kinds"), + []byte("not a directory\n"), 0o644)) + + _, err := discoverKinds(dir) + require.Error(t, err) + assert.Contains(t, err.Error(), ".mdsmith/kinds") +} + +// TestLoad_KindFileMergesIntoConfig verifies the end-to-end load +// path: a file kind merges into cfg.Kinds, indexable by basename. +func TestLoad_KindFileMergesIntoConfig(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".mdsmith", "kinds"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".mdsmith", "kinds", "audit-log.yaml"), + []byte("rules:\n max-file-length:\n max: 700\n"), 0o644)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".mdsmith.yml"), + []byte("rules: {}\n"), 0o644)) + + cfg, err := Load(filepath.Join(dir, ".mdsmith.yml")) + require.NoError(t, err) + require.Contains(t, cfg.Kinds, "audit-log") + assert.Equal(t, 700, + cfg.Kinds["audit-log"].Rules["max-file-length"].Settings["max"]) + assert.Equal(t, + filepath.Join(dir, ".mdsmith", "kinds", "audit-log.yaml"), + cfg.Kinds["audit-log"].SourcePath) +} + +// TestLoad_InlineKindCarriesConfigPath ensures inline kinds also +// get a SourcePath — the `.mdsmith.yml` path — so provenance can +// attribute either source uniformly. +func TestLoad_InlineKindCarriesConfigPath(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, ".mdsmith.yml") + require.NoError(t, os.WriteFile(cfgPath, []byte(` +kinds: + plan: + rules: + line-length: + max: 200 +`), 0o644)) + + cfg, err := Load(cfgPath) + require.NoError(t, err) + require.Contains(t, cfg.Kinds, "plan") + assert.Equal(t, cfgPath, cfg.Kinds["plan"].SourcePath) +} + +// TestLoad_KindFileInlineCollision pins the dual-source error. +// The same kind name in both a file and inline must error naming +// both sources so the user can resolve the ambiguity. +func TestLoad_KindFileInlineCollision(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".mdsmith", "kinds"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".mdsmith", "kinds", "plan.yaml"), + []byte("rules:\n line-length:\n max: 200\n"), 0o644)) + cfgPath := filepath.Join(dir, ".mdsmith.yml") + require.NoError(t, os.WriteFile(cfgPath, []byte(` +kinds: + plan: + rules: + line-length: + max: 100 +`), 0o644)) + + _, err := Load(cfgPath) + require.Error(t, err) + assert.Contains(t, err.Error(), "plan") + assert.Contains(t, err.Error(), ".mdsmith.yml") + assert.Contains(t, err.Error(), "plan.yaml") +} + +// TestLoad_KindFileRejectsDualSchemaSources is the file-defined +// parallel of TestKindRejectsDualSchemaSources: inline `schema:` +// plus `rules.required-structure.schema:` (file path) errors at +// load time (acceptance criterion #6, source A + B). +func TestLoad_KindFileRejectsDualSchemaSources(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".mdsmith", "kinds"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".mdsmith", "kinds", "rfc.yaml"), + []byte(`schema: + sections: + - heading: "Overview" +rules: + required-structure: + schema: schemas/rfc.md +`), 0o644)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".mdsmith.yml"), []byte("rules: {}\n"), 0o644)) + + _, err := Load(filepath.Join(dir, ".mdsmith.yml")) + require.Error(t, err) + assert.Contains(t, err.Error(), "rfc") + assert.Contains(t, err.Error(), "schemas/rfc.md") +} + +// TestLoad_KindFileRejectsInlineMapInRules is the file-defined +// parallel of TestKindRejectsInlineMapInRules: inline `schema:` +// plus `rules.required-structure.inline-schema:` errors at load +// time (acceptance criterion #6, source A + C). +func TestLoad_KindFileRejectsInlineMapInRules(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".mdsmith", "kinds"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".mdsmith", "kinds", "rfc.yaml"), + []byte(`schema: + sections: + - heading: "Overview" +rules: + required-structure: + inline-schema: + sections: + - heading: "Other" +`), 0o644)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".mdsmith.yml"), []byte("rules: {}\n"), 0o644)) + + _, err := Load(filepath.Join(dir, ".mdsmith.yml")) + require.Error(t, err) + assert.Contains(t, err.Error(), "rfc") + assert.Contains(t, err.Error(), "inline-schema") +} + +// TestLoad_KindFileRejectsBothSchemaAndInlineUnderRules is the +// file-defined parallel of +// TestKindRejectsBothSchemaAndInlineUnderRules: under a kind's +// rules.required-structure, setting both `schema:` (file) and +// `inline-schema:` (map) errors at load time (acceptance +// criterion #6, source B + C). +func TestLoad_KindFileRejectsBothSchemaAndInlineUnderRules(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".mdsmith", "kinds"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".mdsmith", "kinds", "rfc.yaml"), + []byte(`rules: + required-structure: + schema: schemas/rfc.md + inline-schema: + sections: + - heading: "Overview" +`), 0o644)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".mdsmith.yml"), []byte("rules: {}\n"), 0o644)) + + _, err := Load(filepath.Join(dir, ".mdsmith.yml")) + require.Error(t, err) + assert.Contains(t, err.Error(), "rfc") + assert.Contains(t, err.Error(), "schema:") + assert.Contains(t, err.Error(), "inline-schema:") +} + +// TestLoad_KindFileExtendsInlineKind confirms a file kind may +// extend an inline kind (acceptance criterion #7), including +// cross-source cycle detection. +func TestLoad_KindFileExtendsInlineKind(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".mdsmith", "kinds"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".mdsmith", "kinds", "rfc-ratified.yaml"), + []byte(`extends: rfc-base +schema: + frontmatter: + status: '"ratified"' +`), 0o644)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".mdsmith.yml"), []byte(` +kinds: + rfc-base: + schema: + frontmatter: + id: '=~"^RFC-[0-9]{4}$"' +`), 0o644)) + + cfg, err := Load(filepath.Join(dir, ".mdsmith.yml")) + require.NoError(t, err) + require.Contains(t, cfg.Kinds, "rfc-ratified") + require.Contains(t, cfg.Kinds, "rfc-base") + assert.Equal(t, "rfc-base", cfg.Kinds["rfc-ratified"].Extends) +} + +// TestProvenance_KindSourcePath verifies that the kind layer's +// SourcePath rides through buildLayers into LayerEntry and that +// the resolved kind list reports the same path. Audit consumers +// (`kinds resolve`, JSON output) read these fields to print the +// defining-source path next to each kind (plan 208). +func TestProvenance_KindSourcePath(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".mdsmith", "kinds"), 0o755)) + kindPath := filepath.Join(dir, ".mdsmith", "kinds", "audit-log.yaml") + require.NoError(t, os.WriteFile(kindPath, + []byte("rules:\n max-file-length:\n max: 700\n"), 0o644)) + cfgPath := filepath.Join(dir, ".mdsmith.yml") + require.NoError(t, os.WriteFile(cfgPath, []byte(` +kind-assignment: + - glob: ["docs/**/*.md"] + kinds: [audit-log] +`), 0o644)) + + loaded, err := Load(cfgPath) + require.NoError(t, err) + defaults := &Config{Rules: map[string]RuleCfg{}} + cfg := Merge(defaults, loaded) + + res := ResolveFile(cfg, "docs/x.md", nil, nil) + require.Len(t, res.Kinds, 1) + assert.Equal(t, "audit-log", res.Kinds[0].Name) + assert.Equal(t, kindPath, res.Kinds[0].SourcePath) + + rr, ok := res.Rules["max-file-length"] + require.True(t, ok) + // Find the kinds.audit-log layer entry and confirm its + // SourcePath was threaded through buildRuleResolution. + var found bool + for _, l := range rr.Layers { + if l.Source == "kinds.audit-log" { + assert.True(t, l.Set) + assert.Equal(t, kindPath, l.SourcePath) + found = true + } + } + assert.True(t, found, "kinds.audit-log layer must appear in the chain") +} + +// TestProvenance_InlineKindSourcePath pins the same path +// surfacing for inline kinds — the `.mdsmith.yml` path tags +// every inline kind body so audit output is uniform across +// sources. +func TestProvenance_InlineKindSourcePath(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, ".mdsmith.yml") + require.NoError(t, os.WriteFile(cfgPath, []byte(` +kinds: + plan: + rules: + max-file-length: + max: 500 +kind-assignment: + - glob: ["plan/*.md"] + kinds: [plan] +`), 0o644)) + + loaded, err := Load(cfgPath) + require.NoError(t, err) + defaults := &Config{Rules: map[string]RuleCfg{}} + cfg := Merge(defaults, loaded) + + res := ResolveFile(cfg, "plan/foo.md", nil, nil) + require.Len(t, res.Kinds, 1) + assert.Equal(t, cfgPath, res.Kinds[0].SourcePath) + + rr := res.Rules["max-file-length"] + var found bool + for _, l := range rr.Layers { + if l.Source == "kinds.plan" { + assert.Equal(t, cfgPath, l.SourcePath) + found = true + } + } + assert.True(t, found) +} + +// TestLoad_KindFileExtendsCycleAcrossSources catches a +// cross-source cycle (file -> inline -> file). The existing cycle +// detector must run on the merged map. +func TestLoad_KindFileExtendsCycleAcrossSources(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".mdsmith", "kinds"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".mdsmith", "kinds", "a.yaml"), + []byte("extends: b\n"), 0o644)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".mdsmith.yml"), []byte(` +kinds: + b: + extends: a +`), 0o644)) + + _, err := Load(filepath.Join(dir, ".mdsmith.yml")) + require.Error(t, err) + assert.Contains(t, err.Error(), "cycle") +} diff --git a/internal/config/load.go b/internal/config/load.go index 5beb7e2a1..57b8a16a6 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -61,6 +61,21 @@ func Load(path string) (*Config, error) { detectFilesKeyDeprecations(&cfg) detectMetaCategoryDeprecations(&cfg) + // Tag every inline kind with the loaded config path so + // provenance can attribute kinds uniformly regardless of + // whether they came from `.mdsmith.yml` or a file under + // `.mdsmith/kinds/` (plan 208). The tag runs before + // discoverKinds so a collision diagnostic can quote both + // sources verbatim. + for name, body := range cfg.Kinds { + body.SourcePath = path + cfg.Kinds[name] = body + } + + if err := mergeKindFiles(&cfg, path); err != nil { + return nil, fmt.Errorf("loading kind files: %w", err) + } + if err := ValidateKinds(&cfg); err != nil { return nil, fmt.Errorf("validating config: %w", err) } diff --git a/internal/config/merge.go b/internal/config/merge.go index de1d65733..a9c945121 100644 --- a/internal/config/merge.go +++ b/internal/config/merge.go @@ -163,6 +163,7 @@ func copyKinds(kinds map[string]KindBody) map[string]KindBody { Schema: cloneSettings(body.Schema), PathPattern: body.PathPattern, Extends: body.Extends, + SourcePath: body.SourcePath, } } return result diff --git a/internal/config/provenance.go b/internal/config/provenance.go index 3cf11bf24..dfa20949a 100644 --- a/internal/config/provenance.go +++ b/internal/config/provenance.go @@ -45,20 +45,29 @@ type KindAssignmentSource string // ResolvedKind names a kind in the effective list and how it was assigned. // Selector, when non-empty, describes the selectors that fired for a // kind-assignment match ("glob a,b AND fields-present x"). It is empty -// for kinds declared via front matter. +// for kinds declared via front matter. SourcePath, when set, is the +// file that defined the kind body (plan 208) — either `.mdsmith.yml` +// for inline kinds or `.mdsmith/kinds/.{yaml,yml}` for +// file-defined kinds. type ResolvedKind struct { - Name string - Source KindAssignmentSource - Selector string + Name string + Source KindAssignmentSource + Selector string + SourcePath string } // LayerEntry is one applicable merge layer for a single rule. Source // identifies the layer; Set indicates whether this layer touched the rule; // Value, when Set is true, is the rule's RuleCfg supplied by this layer. +// SourcePath, when set, is the file that defined the layer (plan 208) — +// populated for kind layers so audit output can name the file alongside +// the layer key (`kinds.`). Empty for built-in defaults, the +// convention preset, the user layer, and override layers. type LayerEntry struct { - Source string - Set bool - Value RuleCfg + Source string + Set bool + Value RuleCfg + SourcePath string } // LeafChainEntry records a layer that set a single leaf, with the value @@ -143,10 +152,12 @@ func ResolveFile(cfg *Config, filePath string, fmKinds []string, fmFields map[st // layerInfo captures one applicable merge layer's source and its rule // settings. Layers that are not applicable to the file (non-matching -// overrides) are not included. +// overrides) are not included. SourcePath, when set, names the file +// the layer was loaded from — populated for kind layers only. type layerInfo struct { - Source string - Rules map[string]RuleCfg + Source string + SourcePath string + Rules map[string]RuleCfg } func buildLayers(cfg *Config, filePath string, kinds []ResolvedKind) []layerInfo { @@ -182,8 +193,9 @@ func buildLayers(cfg *Config, filePath string, kinds []ResolvedKind) []layerInfo continue } layers = append(layers, layerInfo{ - Source: "kinds." + k.Name, - Rules: kindLayerRules(k.Name, body, cfg.Kinds), + Source: "kinds." + k.Name, + SourcePath: body.SourcePath, + Rules: kindLayerRules(k.Name, body, cfg.Kinds), }) } for i, o := range cfg.Overrides { @@ -320,7 +332,12 @@ func buildRuleResolution(name string, layers []layerInfo) RuleResolution { v, ok := l.Rules[name] if ok { cp := copyRuleCfg(v) - chain = append(chain, LayerEntry{Source: l.Source, Set: true, Value: cp}) + chain = append(chain, LayerEntry{ + Source: l.Source, + SourcePath: l.SourcePath, + Set: true, + Value: cp, + }) if !seen { final = cp } else { @@ -332,7 +349,11 @@ func buildRuleResolution(name string, layers []layerInfo) RuleResolution { } seen = true } else { - chain = append(chain, LayerEntry{Source: l.Source, Set: false}) + chain = append(chain, LayerEntry{ + Source: l.Source, + SourcePath: l.SourcePath, + Set: false, + }) } } if !seen { @@ -404,7 +425,18 @@ func resolveKindsWithSources(cfg *Config, filePath string, fmKinds []string, fmF return } seen[name] = true - result = append(result, ResolvedKind{Name: name, Source: source, Selector: selector}) + // SourcePath comes from the kind body, not the assignment — + // every assignment route to the same name resolves to the + // same defining file. + var srcPath string + if cfg != nil { + if body, ok := cfg.Kinds[name]; ok { + srcPath = body.SourcePath + } + } + result = append(result, ResolvedKind{ + Name: name, Source: source, Selector: selector, SourcePath: srcPath, + }) } for _, k := range fmKinds { add(k, "front-matter", "") diff --git a/internal/integration/kind_file_contract_test.go b/internal/integration/kind_file_contract_test.go new file mode 100644 index 000000000..813391e20 --- /dev/null +++ b/internal/integration/kind_file_contract_test.go @@ -0,0 +1,176 @@ +package integration + +import ( + "os" + "path/filepath" + "testing" + + "github.com/jeduden/mdsmith/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Contract tests for the `.mdsmith/kinds/` directory surface +// (plan 208). Each case locks one rule the public surface +// promises so the contract survives refactor pressure. Per +// docs/development/architecture/cross-system.md every public +// surface ships with a contract test. + +// kindFileContractFixture stages a workspace with a config file +// and an optional kind file under `.mdsmith/kinds/`. Returns the +// path of the loaded `.mdsmith.yml`. +func kindFileContractFixture( + t *testing.T, configBody string, kindFiles map[string]string, +) string { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".mdsmith.yml"), []byte(configBody), 0o644)) + if len(kindFiles) > 0 { + require.NoError(t, os.MkdirAll( + filepath.Join(dir, ".mdsmith", "kinds"), 0o755)) + for name, body := range kindFiles { + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".mdsmith", "kinds", name), + []byte(body), 0o644)) + } + } + return filepath.Join(dir, ".mdsmith.yml") +} + +// TestKindFileContract_LayoutAndBasename locks the surface +// invariant: kinds live exactly at `.mdsmith/kinds/.yaml` +// or `.yml`, basename matches `[a-z][a-z0-9-]*`, and the kind +// loads into cfg.Kinds keyed by basename. +func TestKindFileContract_LayoutAndBasename(t *testing.T) { + cfgPath := kindFileContractFixture(t, "rules: {}\n", map[string]string{ + "audit-log.yaml": "rules:\n max-file-length:\n max: 600\n", + }) + cfg, err := config.Load(cfgPath) + require.NoError(t, err) + require.Contains(t, cfg.Kinds, "audit-log", + "basename minus extension must be the kind name") + assert.Equal(t, 600, + cfg.Kinds["audit-log"].Rules["max-file-length"].Settings["max"]) +} + +// TestKindFileContract_RejectsBadBasename locks the basename +// rule. A name that contains uppercase, underscore, or a leading +// digit must be rejected with the offending name in the error. +func TestKindFileContract_RejectsBadBasename(t *testing.T) { + cases := []string{"Audit_Log.yaml", "1audit.yaml", "AuditLog.yaml"} + for _, name := range cases { + t.Run(name, func(t *testing.T) { + cfgPath := kindFileContractFixture(t, "rules: {}\n", + map[string]string{name: "rules: {}\n"}) + _, err := config.Load(cfgPath) + require.Error(t, err) + assert.Contains(t, err.Error(), name) + }) + } +} + +// TestKindFileContract_RejectsSubdirectory locks the +// one-kind-per-file rule. A nested file is a config error. +func TestKindFileContract_RejectsSubdirectory(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, ".mdsmith.yml"), []byte("rules: {}\n"), 0o644)) + nested := filepath.Join(dir, ".mdsmith", "kinds", "more") + require.NoError(t, os.MkdirAll(nested, 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(nested, "foo.yaml"), []byte("rules: {}\n"), 0o644)) + + _, err := config.Load(filepath.Join(dir, ".mdsmith.yml")) + require.Error(t, err) + assert.Contains(t, err.Error(), "subdirector") +} + +// TestKindFileContract_RejectsDualSource locks the dual-source +// error. A name in both a file and inline must error naming both +// sources — the contract is "one file describes one kind". +func TestKindFileContract_RejectsDualSource(t *testing.T) { + cfgPath := kindFileContractFixture(t, ` +kinds: + plan: + rules: + line-length: + max: 100 +`, map[string]string{ + "plan.yaml": "rules:\n line-length:\n max: 200\n", + }) + _, err := config.Load(cfgPath) + require.Error(t, err) + assert.Contains(t, err.Error(), "plan") + assert.Contains(t, err.Error(), ".mdsmith.yml") + assert.Contains(t, err.Error(), "plan.yaml") +} + +// TestKindFileContract_RejectsUnknownTopLevelKey locks the +// "no extra top-level keys" rule. A typo in a top-level key +// must error naming both the key and the file. +func TestKindFileContract_RejectsUnknownTopLevelKey(t *testing.T) { + cfgPath := kindFileContractFixture(t, "rules: {}\n", map[string]string{ + "plan.yaml": "rule: {}\n", + }) + _, err := config.Load(cfgPath) + require.Error(t, err) + assert.Contains(t, err.Error(), "plan.yaml") + assert.Contains(t, err.Error(), "rule") +} + +// TestKindFileContract_RejectsExtensionCollision locks the +// `.yaml`/`.yml` collision rule. Both filenames must surface. +func TestKindFileContract_RejectsExtensionCollision(t *testing.T) { + cfgPath := kindFileContractFixture(t, "rules: {}\n", map[string]string{ + "plan.yaml": "rules: {}\n", + "plan.yml": "rules: {}\n", + }) + _, err := config.Load(cfgPath) + require.Error(t, err) + assert.Contains(t, err.Error(), "plan.yaml") + assert.Contains(t, err.Error(), "plan.yml") +} + +// TestKindFileContract_AcceptsBothExtensions locks the fact +// that `.yaml` and `.yml` are both scanned. The contract +// reserves both filenames so projects that already settled on +// one don't have to migrate. +func TestKindFileContract_AcceptsBothExtensions(t *testing.T) { + cfgPath := kindFileContractFixture(t, "rules: {}\n", map[string]string{ + "foo.yaml": "rules: {}\n", + "bar.yml": "rules: {}\n", + }) + cfg, err := config.Load(cfgPath) + require.NoError(t, err) + assert.Contains(t, cfg.Kinds, "foo") + assert.Contains(t, cfg.Kinds, "bar") +} + +// TestKindFileContract_SourcePathPopulated locks the +// provenance contract: every kind body — file or inline — has +// its SourcePath populated so audit consumers can attribute it +// to a defining file. +func TestKindFileContract_SourcePathPopulated(t *testing.T) { + cfgPath := kindFileContractFixture(t, ` +kinds: + inline-kind: + rules: + line-length: + max: 100 +`, map[string]string{ + "file-kind.yaml": "rules:\n line-length:\n max: 200\n", + }) + cfg, err := config.Load(cfgPath) + require.NoError(t, err) + + require.Contains(t, cfg.Kinds, "inline-kind") + assert.Equal(t, cfgPath, cfg.Kinds["inline-kind"].SourcePath, + "inline kind body must carry the .mdsmith.yml path") + + require.Contains(t, cfg.Kinds, "file-kind") + assert.Equal(t, + filepath.Join(filepath.Dir(cfgPath), ".mdsmith", "kinds", "file-kind.yaml"), + cfg.Kinds["file-kind"].SourcePath, + "file kind body must carry its own path") +} diff --git a/internal/integration/kind_file_equivalence_test.go b/internal/integration/kind_file_equivalence_test.go new file mode 100644 index 000000000..a88abf548 --- /dev/null +++ b/internal/integration/kind_file_equivalence_test.go @@ -0,0 +1,118 @@ +package integration + +import ( + "os" + "path/filepath" + "sort" + "testing" + + "github.com/jeduden/mdsmith/internal/config" + "github.com/jeduden/mdsmith/internal/engine" + "github.com/jeduden/mdsmith/internal/lint" + "github.com/jeduden/mdsmith/internal/rule" + "github.com/stretchr/testify/require" +) + +// TestKindFile_DiagnosticsMatchInline pins plan 208's +// acceptance criterion #1: a file-defined kind produces +// byte-equal diagnostics to the equivalent inline kind. +// Two parallel workspaces share the same Markdown body. One +// declares the kind inline; the other declares it via +// `.mdsmith/kinds/short.yaml`. The two diagnostic sets must +// match line-by-line so a user migrating from inline to file +// sees no behavior shift (LSP: substitutable). +func TestKindFile_DiagnosticsMatchInline(t *testing.T) { + body := "# Heading\n\nThis line exceeds the configured maximum length for the short kind on purpose.\n" + + inlineDir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(inlineDir, ".mdsmith.yml"), + []byte(` +kinds: + short: + rules: + line-length: + max: 30 +kind-assignment: + - glob: ["doc.md"] + kinds: [short] +`), 0o644)) + require.NoError(t, os.WriteFile( + filepath.Join(inlineDir, "doc.md"), []byte(body), 0o644)) + + fileDir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(fileDir, ".mdsmith.yml"), + []byte(` +kind-assignment: + - glob: ["doc.md"] + kinds: [short] +`), 0o644)) + require.NoError(t, os.MkdirAll( + filepath.Join(fileDir, ".mdsmith", "kinds"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(fileDir, ".mdsmith", "kinds", "short.yaml"), + []byte("rules:\n line-length:\n max: 30\n"), 0o644)) + require.NoError(t, os.WriteFile( + filepath.Join(fileDir, "doc.md"), []byte(body), 0o644)) + + inlineDiags := runCheckOnDoc(t, inlineDir) + fileDiags := runCheckOnDoc(t, fileDir) + + require.Equal(t, len(inlineDiags), len(fileDiags), + "file-defined kind must emit the same number of diagnostics as inline") + for i := range inlineDiags { + require.Equal(t, inlineDiags[i], fileDiags[i], + "diagnostic %d must match between sources", i) + } +} + +// diagKey captures the comparable bits of a diagnostic so two +// runs can be compared without the engine pulling in file paths +// or unstable ordering. Rule + line + message is enough to pin +// the substitutability contract. +type diagKey struct { + rule string + line int + message string +} + +func runCheckOnDoc(t *testing.T, workspaceDir string) []diagKey { + t.Helper() + defaults := config.Defaults() + loaded, err := config.Load(filepath.Join(workspaceDir, ".mdsmith.yml")) + require.NoError(t, err) + cfg := config.Merge(defaults, loaded) + + runner := &engine.Runner{ + Config: cfg, + Rules: rule.All(), + StripFrontMatter: true, + RootDir: workspaceDir, + } + result := runner.Run([]string{filepath.Join(workspaceDir, "doc.md")}) + + out := make([]diagKey, 0, len(result.Diagnostics)) + for _, d := range result.Diagnostics { + out = append(out, diagKey{ + rule: d.RuleID, + line: d.Line, + message: d.Message, + }) + } + sort.Slice(out, func(i, j int) bool { + if out[i].rule != out[j].rule { + return out[i].rule < out[j].rule + } + if out[i].line != out[j].line { + return out[i].line < out[j].line + } + return out[i].message < out[j].message + }) + return out +} + +// Ensure the lint package import stays in use even if the +// engine path changes — keeps the test file robust to incidental +// import-pruning refactors. +var _ = lint.NewFile diff --git a/internal/kindsout/kindsout.go b/internal/kindsout/kindsout.go index d8b94246e..bfe904f17 100644 --- a/internal/kindsout/kindsout.go +++ b/internal/kindsout/kindsout.go @@ -31,6 +31,10 @@ type BodyJSON struct { Extends string `json:"extends,omitempty"` ExtendsChain []string `json:"extends-chain,omitempty"` EffectiveFrontmatter []FrontmatterLeafJSON `json:"effective-frontmatter,omitempty"` + // SourcePath, when set, is the file that defined the kind body + // (`.mdsmith.yml` for inline kinds, `.mdsmith/kinds/.{yaml,yml}` + // for file kinds; plan 208). + SourcePath string `json:"source-path,omitempty"` } // FrontmatterLeafJSON describes one effective frontmatter key after @@ -74,6 +78,7 @@ func MakeBodyJSON(name string, body config.KindBody, kinds map[string]config.Kin Categories: body.Categories, PathPattern: body.PathPattern, Extends: body.Extends, + SourcePath: body.SourcePath, } if kinds == nil || body.Extends == "" { return out @@ -172,11 +177,14 @@ func RuleCfgValue(rc config.RuleCfg) any { // ResolvedKindJSON names a kind in the effective list and how it was // assigned ("front-matter" or "kind-assignment[]"). Selector, when // non-empty, describes the selectors that fired on a kind-assignment -// match ("glob a,b AND fields-present x"). +// match ("glob a,b AND fields-present x"). SourcePath, when set, is +// the file that defined the kind body (`.mdsmith.yml` for inline +// kinds, `.mdsmith/kinds/.{yaml,yml}` for file kinds; plan 208). type ResolvedKindJSON struct { - Name string `json:"name"` - Source string `json:"source"` - Selector string `json:"selector,omitempty"` + Name string `json:"name"` + Source string `json:"source"` + Selector string `json:"selector,omitempty"` + SourcePath string `json:"source-path,omitempty"` } // LeafJSON is one effective leaf with its winning source and the chain @@ -236,7 +244,10 @@ func FileResolution(res *config.FileResolution) FileResolutionJSON { } for _, k := range res.Kinds { out.Kinds = append(out.Kinds, ResolvedKindJSON{ - Name: k.Name, Source: string(k.Source), Selector: k.Selector, + Name: k.Name, + Source: string(k.Source), + Selector: k.Selector, + SourcePath: k.SourcePath, }) } for name, rr := range res.Rules { @@ -306,6 +317,12 @@ func WriteBodyText( if _, err := fmt.Fprintf(w, "%s:\n", sanitizeControl(name)); err != nil { return err } + if body.SourcePath != "" { + if _, err := fmt.Fprintf(w, " defined-in: %s\n", + sanitizeControl(body.SourcePath)); err != nil { + return err + } + } if err := writeExtendsHeader(w, name, body, kinds); err != nil { return err } @@ -419,8 +436,12 @@ func WriteFileResolutionText(w io.Writer, res *config.FileResolution) error { if k.Selector != "" { src = src + ": " + sanitizeControl(k.Selector) } - if _, err := fmt.Fprintf(w, " - %s (from %s)\n", - sanitizeControl(k.Name), src); err != nil { + suffix := "" + if k.SourcePath != "" { + suffix = " defined-in " + sanitizeControl(k.SourcePath) + } + if _, err := fmt.Fprintf(w, " - %s (from %s)%s\n", + sanitizeControl(k.Name), src, suffix); err != nil { return err } } diff --git a/internal/kindsout/kindsout_test.go b/internal/kindsout/kindsout_test.go index 3b8b24dc4..e1bd5357e 100644 --- a/internal/kindsout/kindsout_test.go +++ b/internal/kindsout/kindsout_test.go @@ -357,6 +357,19 @@ func TestWriteBodyText_EmptyPlaceholderWriteError(t *testing.T) { require.Error(t, err) } +// TestWriteBodyText_DefinedInWriteError pins the error path +// on the SourcePath `defined-in:` line — surfaces a write +// error rather than swallowing it. +func TestWriteBodyText_DefinedInWriteError(t *testing.T) { + w := &failingWriter{err: errors.New("nope"), after: 1} + body := config.KindBody{ + SourcePath: "/repo/.mdsmith/kinds/foo.yaml", + } + err := WriteBodyText(w, "foo", body, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "nope") +} + // makeFileResolution builds a minimal FileResolution with one rule and // two layers so writers exercise both the kinds and rules branches. func makeFileResolution(t *testing.T) *config.FileResolution { @@ -389,6 +402,83 @@ func TestWriteFileResolutionText_Full(t *testing.T) { assert.Contains(t, out, "(from kinds.short)") } +// TestWriteFileResolutionText_RendersSourcePath pins plan 208's +// CLI surface: a file resolution carrying a SourcePath on each +// kind prints `defined-in ` after the assignment metadata. +// The format is " (from )defined-in " +// — the path lives outside the `from (...)` parens so the +// existing `(from ...)` substring assertions in the e2e tests +// keep matching. +func TestWriteFileResolutionText_RendersSourcePath(t *testing.T) { + cfg := &config.Config{ + Rules: map[string]config.RuleCfg{ + "line-length": {Enabled: true, Settings: map[string]any{"max": 30}}, + }, + Kinds: map[string]config.KindBody{ + "audit-log": { + Rules: map[string]config.RuleCfg{ + "line-length": {Enabled: true, Settings: map[string]any{"max": 30}}, + }, + SourcePath: "/repo/.mdsmith/kinds/audit-log.yaml", + }, + }, + KindAssignment: []config.KindAssignmentEntry{ + {Glob: []string{"x.md"}, Kinds: []string{"audit-log"}}, + }, + } + res := config.ResolveFile(cfg, "x.md", nil, nil) + var buf bytes.Buffer + require.NoError(t, WriteFileResolutionText(&buf, res)) + out := buf.String() + assert.Contains(t, out, + "audit-log (from kind-assignment[0]: glob x.md) defined-in /repo/.mdsmith/kinds/audit-log.yaml") +} + +// TestFileResolutionJSON_IncludesSourcePath pins the +// `source-path` field in JSON output. LSP and audit tooling +// keys off this stable field rather than parsing text. +func TestFileResolutionJSON_IncludesSourcePath(t *testing.T) { + cfg := &config.Config{ + Rules: map[string]config.RuleCfg{"line-length": {Enabled: true}}, + Kinds: map[string]config.KindBody{ + "audit-log": {SourcePath: "/repo/.mdsmith/kinds/audit-log.yaml"}, + }, + KindAssignment: []config.KindAssignmentEntry{ + {Glob: []string{"x.md"}, Kinds: []string{"audit-log"}}, + }, + } + res := config.ResolveFile(cfg, "x.md", nil, nil) + out := FileResolution(res) + require.Len(t, out.Kinds, 1) + assert.Equal(t, "/repo/.mdsmith/kinds/audit-log.yaml", + out.Kinds[0].SourcePath) +} + +// TestMakeBodyJSON_IncludesSourcePath pins the new `source-path` +// JSON key on the body output for `kinds list` / `kinds show`. +func TestMakeBodyJSON_IncludesSourcePath(t *testing.T) { + body := config.KindBody{ + Rules: map[string]config.RuleCfg{"line-length": {Enabled: true}}, + SourcePath: "/repo/.mdsmith/kinds/audit-log.yaml", + } + out := MakeBodyJSON("audit-log", body, nil) + assert.Equal(t, "/repo/.mdsmith/kinds/audit-log.yaml", out.SourcePath) +} + +// TestWriteBodyText_RendersDefinedIn pins the text surface for +// `kinds show` / `kinds list`: the source path appears on a +// `defined-in:` line right under the kind name. +func TestWriteBodyText_RendersDefinedIn(t *testing.T) { + body := config.KindBody{ + Rules: map[string]config.RuleCfg{"line-length": {Enabled: true}}, + SourcePath: "/repo/.mdsmith/kinds/audit-log.yaml", + } + var buf bytes.Buffer + require.NoError(t, WriteBodyText(&buf, "audit-log", body, nil)) + assert.Contains(t, buf.String(), + "defined-in: /repo/.mdsmith/kinds/audit-log.yaml") +} + func TestWriteFileResolutionText_NoKinds(t *testing.T) { cfg := &config.Config{ Rules: map[string]config.RuleCfg{"line-length": {Enabled: true}}, diff --git a/plan/208_kind-files.md b/plan/208_kind-files.md index 27247e283..51e06b514 100644 --- a/plan/208_kind-files.md +++ b/plan/208_kind-files.md @@ -1,7 +1,7 @@ --- id: 208 title: Kind-per-file config under `.mdsmith/kinds/` -status: "🔲" +status: "🔳" model: opus depends-on: [146, 135] summary: >- @@ -262,12 +262,12 @@ schema parsing lives in `internal/schema`. [file-kinds.md](../docs/guides/file-kinds.md) with a "split a kind into its own file" recipe. -10. **Repo migration**: move every kind - from `.mdsmith.yml` to - `.mdsmith/kinds/*.yaml`. Names stay - stable so `kind-assignment:` entries - need no rewrite. Requires explicit - user consent per CLAUDE.md. +10. **Repo migration**: deferred. The + pinned `mdsmith` version this repo + lints itself with does not yet + support `.mdsmith/kinds/`. Migration + is scheduled for after the next + release bumps the pinned version. 11. **Follow-up plan**: open a plan file (not implemented here) for `.mdsmith/conventions/.yaml` so @@ -275,40 +275,40 @@ schema parsing lives in `internal/schema`. ## Acceptance Criteria -- [ ] A kind at `.mdsmith/kinds/foo.yaml` +- [x] A kind at `.mdsmith/kinds/foo.yaml` with the same body as inline `kinds.foo:` emits byte-equal diagnostics. (LSP: substitutable.) -- [ ] A kind declared both in a file and +- [x] A kind declared both in a file and inline errors naming both sources. -- [ ] Two kind files with the same +- [x] Two kind files with the same basename across `.yaml`/`.yml` error naming both. -- [ ] A basename failing `[a-z][a-z0-9-]*` +- [x] A basename failing `[a-z][a-z0-9-]*` or a file in a subdir of `.mdsmith/kinds/` errors; inline kind names stay unvalidated. -- [ ] A kind file with a key outside +- [x] A kind file with a key outside `KindBody` errors naming key and file. -- [ ] The three pairwise schema-source +- [x] The three pairwise schema-source checks in `validateKindSchemaSources` fire on a file-defined kind the same way they fire on inline. -- [ ] A file-defined kind may `extends:` an +- [x] A file-defined kind may `extends:` an inline kind and the reverse; a cycle is detected. -- [ ] A `kind-assignment:` entry resolves +- [x] A `kind-assignment:` entry resolves a file-defined kind by name with no extra wiring. -- [ ] `mdsmith kinds resolve ` prints +- [x] `mdsmith kinds resolve ` prints the defining-source path per kind. -- [ ] `cross-system.md` boundaries table +- [x] `cross-system.md` boundaries table lists `.mdsmith/kinds/` with `docs/reference/kind-files.md`. -- [ ] Every new function in +- [x] Every new function in `internal/config` ships with its dedicated unit test. (Test pyramid.) -- [ ] All tests pass: `go test ./...` -- [ ] `go tool golangci-lint run` clean. -- [ ] `mdsmith check .` passes. +- [x] All tests pass: `go test ./...` +- [x] `go tool golangci-lint run` clean. +- [x] `mdsmith check .` passes. diff --git a/plan/209_convention-files.md b/plan/209_convention-files.md new file mode 100644 index 000000000..e01a7312f --- /dev/null +++ b/plan/209_convention-files.md @@ -0,0 +1,137 @@ +--- +id: 209 +title: Convention-per-file config under `.mdsmith/conventions/` +status: "🔲" +model: opus +depends-on: [208, 113] +summary: >- + Reserve `.mdsmith/conventions/.yaml` as the + per-file home for user-defined conventions, mirroring + the kind-files layout from plan 208. Each file holds + one convention bundle (flavor + rules) keyed by + basename. +--- +# Convention-per-file config under `.mdsmith/conventions/` + +## Goal + +Lift each user convention out of `.mdsmith.yml`'s +`conventions:` block into a standalone YAML file under +`.mdsmith/conventions/.yaml`. The basename is +the convention's name. The body is the full +`UserConvention` shape (flavor + rules). One file +describes everything about one convention. + +## Background + +Plan 113 added user-defined convention bundles under +the top-level `conventions:` key in `.mdsmith.yml`. +Plan 208 split kinds into `.mdsmith/kinds/.yaml` +to isolate each kind's history. The same argument +applies to conventions: a project that defines its own +style preset shouldn't dirty `.mdsmith.yml` on every +rule edit. + +The `.mdsmith/` tree already reserved +`conventions/` as a follow-up slot in plan 208's +design. This plan fills that slot. + +## Non-Goals + +- Removing inline `conventions.:` from + `.mdsmith.yml`. Inline stays as a first-class + source (parallel to plan 208's stance on inline + kinds). +- Built-in conventions (`portable`, `github`, + `plain`). These stay compiled into the binary. +- Externalising the top-level `convention:` selector. + That key remains in `.mdsmith.yml`. + +## Design + +### Directory layout + +```text +.mdsmith.yml # unchanged +.mdsmith/ + kinds/ # plan 208 + audit-log.yaml + conventions/ + portable-strict.yaml # one full convention + long-form-docs.yaml +``` + +### Convention file shape + +The body matches today's inline +`conventions.:` shape — flavor + rules. + +```yaml +# .mdsmith/conventions/portable-strict.yaml +flavor: commonmark +rules: + line-length: + max: 72 + no-bare-urls: true +``` + +The convention's name is the basename minus +extension. The basename must match +`[a-z][a-z0-9-]*` (same rule as kind files). One +convention per file. Subdirectories are rejected. + +A name colliding between a file convention and an +inline convention is a config error naming both +sources. A name colliding with a built-in +(`portable`, `github`, `plain`) is also a config +error. + +## Tasks + +1. **`internal/config`**: add + `discoverConventions(workspaceDir string)` + modelled on `discoverKinds`. Unit test per + rejection case. +2. **`internal/config`**: extend `Load` to merge + file conventions into `cfg.Conventions` and + error on name collisions (with inline or with + built-ins). +3. **Provenance**: extend convention-layer source + reporting so a user convention's defining file + path surfaces in `kinds resolve` / `--explain` + the same way kinds do. +4. **CLI**: extend any `conventions`-related + output to print the defining-source path next + to each convention it reports. +5. **Contract test** under + `internal/integration/` mirroring plan 208's + kind-file contract test. +6. **Docs**: add + `docs/reference/convention-files.md`. Add a + row to the cross-system boundaries table. + Extend the relevant convention guide with a + "split a convention into its own file" + recipe. +7. **Repo migration**: out of scope here unless + this repo defines a user convention worth + splitting. + +## Acceptance Criteria + +- [ ] A convention at + `.mdsmith/conventions/foo.yaml` with the + same body as inline `conventions.foo:` emits + byte-equal effective rules. +- [ ] A convention declared both in a file and + inline errors naming both sources. +- [ ] A convention basename failing + `[a-z][a-z0-9-]*` or a file in a subdir + errors. +- [ ] A name collision with a built-in convention + errors. +- [ ] `mdsmith kinds resolve ` prints the + defining-source path on the convention + layer when one is active. +- [ ] All tests pass: `go test ./...` +- [ ] `go tool golangci-lint run` reports no issues +- [ ] `mdsmith check .` passes.