diff --git a/internal/check/layers_test.go b/internal/check/layers_test.go index b534b22..da35ce7 100644 --- a/internal/check/layers_test.go +++ b/internal/check/layers_test.go @@ -160,7 +160,22 @@ func TestLayeredChecks(t *testing.T) { "**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.", "**ДОЛЖЕН.** Момент времени записывается с суффиксом Z, как требует GTIM-1.", 1) }, - want: "a layer of its own topic but not the base one", + want: "points at a layer of this topic that is not the base one", + }, { + name: "rationale of the base layer references a layer above it", + setup: func(f files) { + f["conventions/arch/time.md"] = strings.Replace(archTime, + "**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.", + "**ПОЧЕМУ.** Без явного смещения не видно зоны; в Go это выражает GTIM-1.", 1) + }, + want: "points at a layer of this topic that is not the base one", + }, { + name: "a section outside any rule references a layer above", + setup: func(f files) { + f["conventions/arch/time.md"] = archTime + + "\n## Связано\n\nРеализация на Go — GTIM-1.\n" + }, + want: "points at a layer of this topic that is not the base one", }} for _, tc := range cases { @@ -220,7 +235,7 @@ func TestNoFalsePositives(t *testing.T) { name: "a stub of a retired rule with a date and a reason", setup: func(f files) { f["conventions/arch/time.md"] = archTime + - "\n### TIME-2. Ширина строки фиксируется\n\n**СНЯТО 2026-07-26.** Правило переехало в GTIM-1.\n" + "\n### TIME-2. Ширина строки фиксируется\n\n**СНЯТО 2026-07-26.** Ширина следует из TIME-1 и отдельного правила не требует.\n" }, }, { name: "a scenario block in the suite's own vocabulary", @@ -235,6 +250,26 @@ func TestNoFalsePositives(t *testing.T) { "**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.", "**ПОЧЕМУ.** Условие `WHERE a AND b OR c` сортировку не спасает.", 1) }, + }, { + name: "a rationale references a foreign topic, which META-20 allows", + setup: func(f files) { + f["conventions/lang/go/time.md"] = strings.Replace(goTime, + "**ПОЧЕМУ.** Единая точка даёт гарантированный UTC.", + "**ПОЧЕМУ.** Единая точка даёт гарантированный UTC; тот же довод стоит за SLOG-1.", 1) + }, + }, { + name: "a section outside any rule references a foreign topic", + setup: func(f files) { + f["conventions/lang/go/time.md"] = goTime + + "\n## Связано\n\nКонвенция logging, правило SLOG-1.\n" + }, + }, { + name: "a layer references the base layer of its topic outside a norm", + setup: func(f files) { + f["conventions/lang/go/time.md"] = strings.Replace(goTime, + "**ПОЧЕМУ.** Единая точка даёт гарантированный UTC.", + "**ПОЧЕМУ.** Единая точка даёт гарантированный UTC, чего и требует TIME-1.", 1) + }, }, { name: "the single document without a topic is the one the suite governs itself by", setup: func(f files) { diff --git a/internal/check/spread.go b/internal/check/spread.go index fa8045a..581ac3b 100644 --- a/internal/check/spread.go +++ b/internal/check/spread.go @@ -19,7 +19,8 @@ func checkSpread(s *suite.Suite, d *doc.Document, rep *Report) { checkExtends(s, d, rep) checkMechanized(s, d, rep) checkCanonPaths(s, d, rep) - checkForeignTopicPrefix(s, d, rep) + checkForeignTopicInNorm(s, d, rep) + checkOwnTopicLayerRefs(s, d, rep) } // checkTopic reconciles the topic from the front matter with the manifest @@ -151,13 +152,16 @@ func checkCanonPaths(s *suite.Suite, d *doc.Document, rep *Report) { } } -// checkForeignTopicPrefix looks for the prefix of a foreign topic inside a norm +// checkForeignTopicInNorm looks for the prefix of a foreign topic inside a norm // block (META-20). The norm of a rule must be executable holding this one file: // a repository subscribes to an arbitrary subset of the conventions, and it has -// no dependency graph by construction. The prefix of the base layer of its own -// topic is allowed there (META-24) — an assembled file starts with that layer -// whatever language and stack were chosen. -func checkForeignTopicPrefix(s *suite.Suite, d *doc.Document, rep *Report) { +// no dependency graph by construction. +// +// Outside a norm such a reference is lawful and stays unchecked. A rationale +// that loses its addressee degrades honestly — the cross-check goes, the +// meaning stays — while a norm missing a neighbouring file becomes unenforceable +// in silence. +func checkForeignTopicInNorm(s *suite.Suite, d *doc.Document, rep *Report) { own := s.Prefix(d) for _, r := range d.Rules { for _, norm := range r.Norms() { @@ -166,21 +170,44 @@ func checkForeignTopicPrefix(s *suite.Suite, d *doc.Document, rep *Report) { continue } target, ok := s.ByPrefix[ref.Prefix] - if !ok { + if !ok || target.Front.Topic == d.Front.Topic { continue } - if target.Front.Topic != d.Front.Topic { - rep.Errorf(Spread, d.Path, ref.Line, - "the norm of %s refers to %s from the foreign topic %q: only the rationale looks outward", - r.ID(), ref.Text, target.Front.Topic) - continue - } - if target.Front.Axis() { - rep.Errorf(Spread, d.Path, ref.Line, - "the norm of %s refers to %s, a layer of its own topic but not the base one: that layer reaches the copy through the manifest, so there is no guarantee it stands nearby", - r.ID(), ref.Text) - } + rep.Errorf(Spread, d.Path, ref.Line, + "the norm of %s refers to %s from the foreign topic %q: only the rationale looks outward", + r.ID(), ref.Text, target.Front.Topic) } } } } + +// checkOwnTopicLayerRefs holds the direction of references inside one topic: +// only the base layer may be pointed at, and from anywhere in the document +// rather than from the norm alone. +// +// Guaranteed to stand in the copy is exactly one layer of a topic — the base +// one; it goes in whatever language and stack were chosen (META-24). The order +// of assembly, base then language then stack, is the order of concatenation and +// not a chain of dependency: a consumer is free to take stack=htmx with +// lang=python, so a stack layer has no claim on a language layer either. +// +// The reason reaches past dangling links. The base layer is the text that has +// to hold for every language and every stack; a rationale of its that needs a +// rule of the Go layer to explain itself is the specific leaking into the +// shared, and the reasoning belongs in that layer instead. The base layer is +// therefore left no way to point at a layer above it at all — by design. +func checkOwnTopicLayerRefs(s *suite.Suite, d *doc.Document, rep *Report) { + own := s.Prefix(d) + for _, ref := range refsIn(d, d.Body, d.Len()) { + if ref.Prefix == own || strings.HasPrefix(ref.Prefix, "X") { + continue + } + target, ok := s.ByPrefix[ref.Prefix] + if !ok || target.Front.Topic != d.Front.Topic || !target.Front.Axis() { + continue + } + rep.Errorf(Spread, d.Path, ref.Line, + "the reference %s points at a layer of this topic that is not the base one: only the base layer is guaranteed to stand in a copy, and a rule that needs this one belongs in that layer", + ref.Text) + } +}