Skip to content

Commit dce8a1d

Browse files
committed
Drive schema-source translation via rule.SettingsTranslator
Addresses repo-owner review on merge.go: the config merge layer no longer special-cases the "required-structure" rule name. - New rule.SettingsTranslator interface (mirrors the existing rule.ListMerger pattern). requiredstructure.Rule implements TranslateLayerSettings; the schema:/inline-schema: -> schema-sources collapse logic (plus its deep-clone helper) moves out of internal/config into the rule that owns those setting semantics. - merge.go applies translation generically via a rule.ByName-driven translateLayerSettings helper, exactly like deepmerge.go already resolves rule.ListMerger. provenance.go mirrors it with translateLayerRules so kinds resolve / --explain stay consistent with the engine. - Removed the now-unused translateSchemaSource / extractSchemaSourceFromSettings / translateSchemaSourcesInRules / needsSchemaSourceTranslation from internal/config. Tests reworked to target the new generic surface plus the rule's translator directly; full suite green, lint clean, 100% line and branch coverage on the moved/added code.
1 parent 28abcaa commit dce8a1d

6 files changed

Lines changed: 373 additions & 140 deletions

File tree

internal/config/merge.go

Lines changed: 27 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package config
22

3+
import "github.com/jeduden/mdsmith/internal/rule"
4+
35
// Merge merges a loaded config on top of defaults. The loaded config's rules
46
// override the defaults; any rule not mentioned in loaded keeps its default
57
// value. Ignore and Overrides come from the loaded config only.
@@ -339,21 +341,20 @@ func effectiveRules(cfg *Config, filePath string, kinds []string) map[string]Rul
339341
// the convention's `Enabled: true` and silently disable the rule
340342
// the user just asked the convention to enable.
341343
//
342-
// required-structure's `schema:` and `inline-schema:` settings get
343-
// a separate accumulation pass: each layer that declares either
344-
// source contributes one entry to a `schema-sources` list rather
345-
// than scalar-replacing the previous layer. The rule receives the
346-
// list and composes the sources at Check time (plan 156).
344+
// Some rules rewrite a layer's settings before deep-merge by
345+
// implementing rule.SettingsTranslator (required-structure
346+
// collapses `schema:` / `inline-schema:` into an append-mode
347+
// `schema-sources` list so multiple kinds compose, plan 156).
348+
// translateLayerSettings consults the rule registry, so this
349+
// merge code carries no rule-name special cases.
347350
result := make(map[string]RuleCfg, len(cfg.Rules))
348351
for k, v := range cfg.Rules {
349352
if !cfg.ExplicitRules[k] {
350-
result[k] = copyRuleCfg(translateSchemaSource(v))
353+
result[k] = copyRuleCfg(translateLayerSettings(k, v))
351354
}
352355
}
353356
apply := func(name string, layer RuleCfg) {
354-
if name == "required-structure" {
355-
layer = translateSchemaSource(layer)
356-
}
357+
layer = translateLayerSettings(name, layer)
357358
if existing, ok := result[name]; ok {
358359
result[name] = mergeRuleCfg(name, existing, layer)
359360
return
@@ -393,61 +394,34 @@ func effectiveRules(cfg *Config, filePath string, kinds []string) map[string]Rul
393394
return result
394395
}
395396

396-
// translateSchemaSource converts a layer's required-structure
397-
// `schema:` (file path) or `inline-schema:` (map) settings into a
398-
// single-entry `schema-sources` list and strips the legacy keys.
399-
// Layers that pass through deep-merge thus accumulate sources via the
400-
// rule's MergeAppend declaration on `schema-sources` instead of
401-
// scalar-replacing.
397+
// translateLayerSettings applies a rule's rule.SettingsTranslator
398+
// (when it implements one) to a single config layer's settings
399+
// before deep-merge. Rules without the interface — or rules not
400+
// registered in this binary — pass through unchanged, matching the
401+
// existing rule.ByName-driven ListMerger lookup in deepmerge.go.
402402
//
403-
// Empty values (`schema: ""`, `inline-schema: {}`) are stripped
404-
// without contributing a source. This keeps defaults' empty
405-
// placeholder for `schema:` from polluting the composed list.
406-
func translateSchemaSource(rc RuleCfg) RuleCfg {
403+
// The merge code calls this for every layer keyed only by rule
404+
// name, so it carries no rule-specific behaviour itself: a rule
405+
// that needs its settings rewritten (e.g. required-structure
406+
// collapsing `schema:`/`inline-schema:` into `schema-sources`)
407+
// declares that by implementing the interface.
408+
func translateLayerSettings(ruleName string, rc RuleCfg) RuleCfg {
407409
if rc.Settings == nil {
408410
return rc
409411
}
410-
source, hadKey := extractSchemaSourceFromSettings(rc.Settings)
411-
if !hadKey {
412+
r := rule.ByName(ruleName)
413+
if r == nil {
412414
return rc
413415
}
414-
newSettings := cloneSettings(rc.Settings)
415-
delete(newSettings, "schema")
416-
delete(newSettings, "inline-schema")
417-
if source != nil {
418-
existing, _ := newSettings["schema-sources"].([]any)
419-
newSettings["schema-sources"] = append(existing, source)
416+
t, ok := r.(rule.SettingsTranslator)
417+
if !ok {
418+
return rc
420419
}
421420
out := rc
422-
out.Settings = newSettings
421+
out.Settings = t.TranslateLayerSettings(rc.Settings)
423422
return out
424423
}
425424

426-
// extractSchemaSourceFromSettings inspects a settings map for a
427-
// schema source declaration. Returns (source, true) when either key
428-
// is present (even if the value is empty / no-op) so the caller can
429-
// strip the legacy keys; returns (nil, false) when no schema key
430-
// appears at all.
431-
func extractSchemaSourceFromSettings(s map[string]any) (any, bool) {
432-
hadKey := false
433-
if v, ok := s["schema"]; ok {
434-
hadKey = true
435-
if path, ok := v.(string); ok && path != "" {
436-
return map[string]any{"file": path}, true
437-
}
438-
}
439-
if v, ok := s["inline-schema"]; ok {
440-
hadKey = true
441-
if m, ok := v.(map[string]any); ok && len(m) > 0 {
442-
return map[string]any{"inline": cloneSettings(m)}, true
443-
}
444-
}
445-
if !hadKey {
446-
return nil, false
447-
}
448-
return nil, true
449-
}
450-
451425
// applyPathPattern appends a {kind, pattern} entry to the
452426
// `path-patterns` list setting on required-structure, creating the
453427
// rule entry if missing. Each kind in the file's effective kind list

internal/config/provenance.go

Lines changed: 14 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ func buildLayers(cfg *Config, filePath string, kinds []ResolvedKind) []layerInfo
157157
if len(defaults) > 0 {
158158
layers = append(layers, layerInfo{
159159
Source: layerSourceDefault,
160-
Rules: translateSchemaSourcesInRules(defaults),
160+
Rules: translateLayerRules(defaults),
161161
})
162162
}
163163
if cfg.Convention != "" && len(cfg.ConventionPreset) > 0 {
@@ -167,13 +167,13 @@ func buildLayers(cfg *Config, filePath string, kinds []ResolvedKind) []layerInfo
167167
}
168168
layers = append(layers, layerInfo{
169169
Source: source,
170-
Rules: translateSchemaSourcesInRules(cfg.ConventionPreset),
170+
Rules: translateLayerRules(cfg.ConventionPreset),
171171
})
172172
}
173173
if len(user) > 0 {
174174
layers = append(layers, layerInfo{
175175
Source: layerSourceUser,
176-
Rules: translateSchemaSourcesInRules(user),
176+
Rules: translateLayerRules(user),
177177
})
178178
}
179179
for _, k := range kinds {
@@ -190,7 +190,7 @@ func buildLayers(cfg *Config, filePath string, kinds []ResolvedKind) []layerInfo
190190
if matchesAny(o.Patterns(), filePath) {
191191
layers = append(layers, layerInfo{
192192
Source: fmt.Sprintf("overrides[%d]", i),
193-
Rules: translateSchemaSourcesInRules(o.Rules),
193+
Rules: translateLayerRules(o.Rules),
194194
})
195195
}
196196
}
@@ -211,14 +211,11 @@ func buildLayers(cfg *Config, filePath string, kinds []ResolvedKind) []layerInfo
211211
// diverges from the rule config the engine actually applied.
212212
func kindLayerRules(kindName string, body KindBody) map[string]RuleCfg {
213213
if len(body.Schema) == 0 && body.PathPattern == "" {
214-
return translateSchemaSourcesInRules(body.Rules)
214+
return translateLayerRules(body.Rules)
215215
}
216216
out := make(map[string]RuleCfg, len(body.Rules)+1)
217217
for k, v := range body.Rules {
218-
if k == "required-structure" {
219-
v = translateSchemaSource(v)
220-
}
221-
out[k] = v
218+
out[k] = translateLayerSettings(k, v)
222219
}
223220
rs := out["required-structure"]
224221
rs.Enabled = true
@@ -241,45 +238,20 @@ func kindLayerRules(kindName string, body KindBody) map[string]RuleCfg {
241238
return out
242239
}
243240

244-
// translateSchemaSourcesInRules walks a rules map looking for a
245-
// required-structure entry whose Settings still carry the legacy
246-
// `schema:` / `inline-schema:` keys; when found, the entry is
247-
// rewritten to use `schema-sources:` so the provenance layer chain
248-
// shows the same setting key the engine merges on. Other rules are
249-
// returned untouched (the caller's map is shared when no
250-
// translation is needed).
251-
func translateSchemaSourcesInRules(rules map[string]RuleCfg) map[string]RuleCfg {
252-
rs, ok := rules["required-structure"]
253-
if !ok {
254-
return rules
255-
}
256-
if !needsSchemaSourceTranslation(rs) {
257-
return rules
258-
}
241+
// translateLayerRules applies each rule's rule.SettingsTranslator
242+
// (via translateLayerSettings) across a whole rules map so the
243+
// provenance layer chain shows the same setting keys the engine
244+
// merges on. Rules without a translator pass through unchanged.
245+
// Provenance is not a hot path, so this always returns a fresh
246+
// map rather than threading an allocation-free fast path.
247+
func translateLayerRules(rules map[string]RuleCfg) map[string]RuleCfg {
259248
out := make(map[string]RuleCfg, len(rules))
260249
for k, v := range rules {
261-
out[k] = v
250+
out[k] = translateLayerSettings(k, v)
262251
}
263-
out["required-structure"] = translateSchemaSource(rs)
264252
return out
265253
}
266254

267-
// needsSchemaSourceTranslation reports whether rc.Settings still
268-
// carries the legacy single-source keys that translateSchemaSource
269-
// would rewrite.
270-
func needsSchemaSourceTranslation(rc RuleCfg) bool {
271-
if rc.Settings == nil {
272-
return false
273-
}
274-
if _, ok := rc.Settings["schema"]; ok {
275-
return true
276-
}
277-
if _, ok := rc.Settings["inline-schema"]; ok {
278-
return true
279-
}
280-
return false
281-
}
282-
283255
// splitRulesByExplicit divides cfg.Rules into two maps using
284256
// cfg.ExplicitRules as the discriminator: defaults (rules the user
285257
// did not explicitly set) and user (rules with an entry in

internal/config/schema_kinds_test.go

Lines changed: 57 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -274,27 +274,47 @@ func TestEffectiveKinds_CfgDelegatesToResolver(t *testing.T) {
274274
assert.Equal(t, []string{"plan"}, got)
275275
}
276276

277-
// TestExtractSchemaSourceFromSettings_NonStringSchemaKey covers
278-
// the `path, ok := v.(string)` false branch: when `schema:` is
279-
// set to a non-string value, the extractor still flags hadKey
280-
// but produces no source.
281-
func TestExtractSchemaSourceFromSettings_NonStringSchemaKey(t *testing.T) {
282-
src, hadKey := extractSchemaSourceFromSettings(map[string]any{
283-
"schema": 42,
277+
// TestTranslateLayerSettings_NonStringSchemaKey covers a layer
278+
// whose `schema:` is a non-string value: the rule's translator
279+
// still strips the legacy key but adds no schema-sources entry.
280+
func TestTranslateLayerSettings_NonStringSchemaKey(t *testing.T) {
281+
out := translateLayerSettings("required-structure", RuleCfg{
282+
Enabled: true,
283+
Settings: map[string]any{"schema": 42},
284284
})
285-
assert.Nil(t, src)
286-
assert.True(t, hadKey)
285+
assert.NotContains(t, out.Settings, "schema")
286+
assert.NotContains(t, out.Settings, "schema-sources")
287287
}
288288

289-
// TestExtractSchemaSourceFromSettings_NonMapInlineKey covers the
290-
// `m, ok := v.(map[string]any)` false branch: a non-map
291-
// `inline-schema` value is treated as empty.
292-
func TestExtractSchemaSourceFromSettings_NonMapInlineKey(t *testing.T) {
293-
src, hadKey := extractSchemaSourceFromSettings(map[string]any{
294-
"inline-schema": "not-a-map",
289+
// TestTranslateLayerSettings_NonMapInlineKey covers a non-map
290+
// `inline-schema` value: treated as empty, key stripped, no
291+
// source added.
292+
func TestTranslateLayerSettings_NonMapInlineKey(t *testing.T) {
293+
out := translateLayerSettings("required-structure", RuleCfg{
294+
Enabled: true,
295+
Settings: map[string]any{"inline-schema": "not-a-map"},
295296
})
296-
assert.Nil(t, src)
297-
assert.True(t, hadKey)
297+
assert.NotContains(t, out.Settings, "inline-schema")
298+
assert.NotContains(t, out.Settings, "schema-sources")
299+
}
300+
301+
// TestTranslateLayerSettings_UnknownRulePassthrough covers the
302+
// rule-not-registered / no-translator branches: a rule with no
303+
// SettingsTranslator (or an unknown name) returns its settings
304+
// untouched.
305+
func TestTranslateLayerSettings_UnknownRulePassthrough(t *testing.T) {
306+
in := RuleCfg{Enabled: true, Settings: map[string]any{"schema": "x.md"}}
307+
out := translateLayerSettings("definitely-not-a-rule", in)
308+
assert.Equal(t, "x.md", out.Settings["schema"],
309+
"unknown rule must not have its settings rewritten")
310+
}
311+
312+
// TestTranslateLayerSettings_NilSettingsPassthrough covers the
313+
// rc.Settings == nil early return.
314+
func TestTranslateLayerSettings_NilSettingsPassthrough(t *testing.T) {
315+
out := translateLayerSettings("required-structure",
316+
RuleCfg{Enabled: true})
317+
assert.Nil(t, out.Settings)
298318
}
299319

300320
// TestKindLayerRules_InlineSchemaWithoutPathPattern covers the
@@ -320,12 +340,13 @@ func TestKindLayerRules_InlineSchemaWithoutPathPattern(t *testing.T) {
320340
assert.Contains(t, sources[0].(map[string]any), "inline")
321341
}
322342

323-
// TestTranslateSchemaSource_StripsLegacyKeyWithoutAddingSource
324-
// covers the `source != nil` false branch in translateSchemaSource:
325-
// a layer with `schema: ""` (default placeholder) is stripped of
326-
// the legacy key without contributing a schema-sources entry.
327-
func TestTranslateSchemaSource_StripsLegacyKeyWithoutAddingSource(t *testing.T) {
328-
out := translateSchemaSource(RuleCfg{
343+
// TestTranslateLayerSettings_StripsLegacyKeyWithoutAddingSource
344+
// covers the empty-value path: a layer with `schema: ""` (the
345+
// rule's DefaultSettings placeholder) is stripped of the legacy
346+
// key without contributing a schema-sources entry, and unrelated
347+
// settings survive.
348+
func TestTranslateLayerSettings_StripsLegacyKeyWithoutAddingSource(t *testing.T) {
349+
out := translateLayerSettings("required-structure", RuleCfg{
329350
Enabled: true,
330351
Settings: map[string]any{
331352
"schema": "",
@@ -340,26 +361,20 @@ func TestTranslateSchemaSource_StripsLegacyKeyWithoutAddingSource(t *testing.T)
340361
"unrelated settings must survive translation")
341362
}
342363

343-
// TestExtractSchemaSourceFromSettings_EmptyValuesStripped covers the
344-
// `hadKey` branch in extractSchemaSourceFromSettings: a settings map
345-
// that contains a schema key but with an empty value (empty string
346-
// or empty inline map) returns (nil, true) so callers strip the
347-
// key without adding a source entry.
348-
func TestExtractSchemaSourceFromSettings_EmptyValuesStripped(t *testing.T) {
349-
src, hadKey := extractSchemaSourceFromSettings(map[string]any{
350-
"schema": "",
351-
"inline-schema": map[string]any{},
364+
// TestTranslateLayerSettings_BothEmptyValuesStripped covers a
365+
// layer carrying both legacy keys with empty values: both keys
366+
// are stripped, no source is added.
367+
func TestTranslateLayerSettings_BothEmptyValuesStripped(t *testing.T) {
368+
out := translateLayerSettings("required-structure", RuleCfg{
369+
Enabled: true,
370+
Settings: map[string]any{
371+
"schema": "",
372+
"inline-schema": map[string]any{},
373+
},
352374
})
353-
assert.Nil(t, src)
354-
assert.True(t, hadKey)
355-
}
356-
357-
// TestNeedsSchemaSourceTranslation_NilSettings covers the
358-
// rc.Settings == nil early-return branch in
359-
// needsSchemaSourceTranslation.
360-
func TestNeedsSchemaSourceTranslation_NilSettings(t *testing.T) {
361-
rc := RuleCfg{Enabled: true, Settings: nil}
362-
assert.False(t, needsSchemaSourceTranslation(rc))
375+
assert.NotContains(t, out.Settings, "schema")
376+
assert.NotContains(t, out.Settings, "inline-schema")
377+
assert.NotContains(t, out.Settings, "schema-sources")
363378
}
364379

365380
// TestEmptyInlineSchemaIsNoOp ensures the merge layer doesn't add a

internal/rule/rule.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,25 @@ type ListMerger interface {
4949
SettingMergeMode(key string) MergeMode
5050
}
5151

52+
// SettingsTranslator is implemented by Configurable rules that
53+
// rewrite one config layer's settings map before the deep-merge
54+
// runs. The config merge layer calls TranslateLayerSettings on
55+
// every layer that configures the rule, so merge logic stays free
56+
// of rule-name special cases.
57+
//
58+
// required-structure implements this to collapse the user-facing
59+
// `schema:` / `inline-schema:` keys into an append-mode
60+
// `schema-sources` list, letting multiple kinds compose their
61+
// schemas instead of overwriting (plan 156).
62+
type SettingsTranslator interface {
63+
// TranslateLayerSettings returns the settings the merge layer
64+
// should use for one layer. Implementations must treat the
65+
// input as read-only and return a new map when they change
66+
// anything; returning the input unchanged signals "no
67+
// translation applies".
68+
TranslateLayerSettings(settings map[string]any) map[string]any
69+
}
70+
5271
// ConfigTarget is implemented by rules that validate the project
5372
// config file (.mdsmith.yml) rather than individual Markdown files.
5473
// The engine runner runs these rules once against a synthetic lint.File

0 commit comments

Comments
 (0)