Files
convy/internal/check/spread.go
T
av 600aba5ee3 закрыты известные остатки
- документ самоуправления объявляется ключом governance, а не угадывается
  по «он один и без ключей оси»: конвенция, потерявшая topic, была от него
  неотличима и тихо теряла все проверки об отъезде к потребителю
- проверка путей канона больше не ловит README.md и READING.md — эти два
  имени значат что-то и на стороне потребителя
- lang.Recognize требует совпадения и слов, и номера версии; директории
  компонентов сверяются на вложенность, а не только на равенство
- у обеих проверок появился --json, а convy sync называет ссылки на темы,
  которых компонент не взял
2026-07-28 10:16:27 +03:00

264 lines
9.4 KiB
Go

package check
import (
"path"
"regexp"
"sort"
"strings"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/lang"
"git.vakhrushev.me/av/convy/internal/project"
"git.vakhrushev.me/av/convy/internal/suite"
)
// checkSpread checks what bears on a document travelling to a consumer. It
// applies to convention files only: the document the suite governs itself by
// travels nowhere, and a canon path inside it is lawful.
func checkSpread(s *suite.Suite, d *doc.Document, rep *Report) {
checkTopic(s, d, rep)
checkAxis(d, rep)
checkExtends(s, d, rep)
checkMechanized(s, d, rep)
checkCanonPaths(s, d, rep)
checkForeignTopicInNorm(s, d, rep)
checkOwnTopicLayerRefs(s, d, rep)
}
// checkTopic reconciles the topic from the front matter with the manifest
// (META-28, META-29).
func checkTopic(s *suite.Suite, d *doc.Document, rep *Report) {
topic := d.Front.Topic
at := d.Front.At["topic"]
switch {
case s.Manifest.TopicRetired(topic):
rep.Errorf(Spread, d.Path, at,
"topic %q is listed among the retired ones: a retired name is never handed to another topic", topic)
case !s.Manifest.TopicLive(topic):
rep.Errorf(Spread, d.Path, at,
"topic %q is not declared in the suite manifest", topic)
}
}
// checkAxis reconciles the declared axis with the path of the file. An axis is
// declared in the front matter rather than derived from the path (META-38); but
// once axis directories are in use, a divergence means the file moved and the
// front matter did not.
func checkAxis(d *doc.Document, rep *Report) {
parts := strings.Split(path.Dir(d.Path), "/")
for i := 0; i+1 < len(parts); i++ {
var declared, key string
switch parts[i] {
case "lang":
declared, key = d.Front.Lang, "lang"
case "stack":
declared, key = d.Front.Stack, "stack"
default:
continue
}
if declared == parts[i+1] {
continue
}
at := d.Front.At[key]
if at == 0 {
at = d.Front.At["prefix"]
}
rep.Errorf(Spread, d.Path, at,
"the path puts the file on axis %s=%s, while the front matter declares %s=%q", key, parts[i+1], key, declared)
}
}
// checkExtends verifies that the declared base exists and belongs to the same
// topic. The key documents the tie between layers for a human — and
// documentation that lies is worse than none.
func checkExtends(s *suite.Suite, d *doc.Document, rep *Report) {
if d.Front.Extends == "" {
return
}
at := d.Front.At["extends"]
target, others := resolveExtends(s, d, d.Front.Extends)
if len(others) > 1 {
rep.Errorf(Spread, d.Path, at,
"extends points at %q, and the suite holds several files it could mean (%v): give the path from the root of the suite",
d.Front.Extends, others)
return
}
if target == nil {
rep.Errorf(Spread, d.Path, at,
"extends points at %q, and the suite holds no such file", d.Front.Extends)
return
}
if target.Front.Topic != d.Front.Topic {
rep.Errorf(Spread, d.Path, at,
"extends points at %q with topic %q, while the file carries topic %q: the layers of one topic declare one name",
d.Front.Extends, target.Front.Topic, d.Front.Topic)
}
if target.Front.Axis() {
rep.Errorf(Spread, d.Path, at,
"extends points at %q, which is not a base layer: it declares an axis", d.Front.Extends)
}
}
// resolveExtends looks up the document at the path written in extends.
//
// The path may be given from the root of the suite or from the directory of
// conventions, so an exact match is tried first and a tail match second. A tail
// match can fit several files at once — two layers of one topic often share a
// file name — so every candidate is returned and the caller reports the
// ambiguity instead of picking by the order documents happen to be loaded in.
// The document doing the extending is never its own base.
func resolveExtends(s *suite.Suite, from *doc.Document, ref string) (*doc.Document, []string) {
ref = path.Clean(strings.TrimPrefix(ref, "./"))
var candidates []*doc.Document
for _, d := range s.Docs {
if d == from {
continue
}
if d.Path == ref {
return d, []string{d.Path}
}
if strings.HasSuffix(d.Path, "/"+ref) {
candidates = append(candidates, d)
}
}
paths := make([]string, len(candidates))
for i, d := range candidates {
paths[i] = d.Path
}
sort.Strings(paths)
if len(candidates) == 1 {
return candidates[0], paths
}
return nil, paths
}
// namesSuiteFile reports whether a candidate written in the text names a file
// the suite holds. Unlike an extends key it needs no single answer: a path that
// fits several files of the canon is a path all the same.
func namesSuiteFile(s *suite.Suite, candidate string) bool {
// Two names mean something on the consumer's side as well, and a
// convention naming either of them is talking about the copy rather than
// about the suite: README.md belongs to the consuming repository, and
// READING.md is the guide that travels next to the copies.
switch path.Base(candidate) {
case "README.md", project.ReadingName:
return false
}
if s.Exists(candidate) {
return true
}
for _, d := range s.Docs {
if d.Path == candidate || strings.HasSuffix(d.Path, "/"+candidate) {
return true
}
}
return false
}
// checkMechanized looks for the mark of mechanization in the text of a
// convention. Whether a norm is mechanized is a property of a repository rather
// than of the suite, so the place of the mark is the local part of the copy
// (META-7).
func checkMechanized(s *suite.Suite, d *doc.Document, rep *Report) {
word := s.Vocab.MarkWord(lang.Mechanized)
if word == "" {
return
}
version, hasVersion := versionParagraph(s.Vocab, d)
d.Prose(func(n int, text string) bool {
if hasVersion && n >= version.Start && n <= version.End {
return true
}
if containsWord(text, word) {
rep.Errorf(Spread, d.Path, n,
"the %s mark stands in the text of a convention: its place is the note of mechanization in the local part of the copy", word)
}
return true
})
}
// mdPathRe catches what looks like a path to a file of the suite.
var mdPathRe = regexp.MustCompile(`[\w./-]+\.md`)
// checkCanonPaths looks for the path of a canon file in the text of a
// convention (META-21). In a consumer's repository a convention lies assembled,
// the layers of one topic are sections of one file, and the path
// `lang/go/logging.md` does not exist there: a reference to it dies on assembly,
// and dies in silence — the text stays coherent.
//
// Inline code is not cut out here: a path in backticks is still a path.
func checkCanonPaths(s *suite.Suite, d *doc.Document, rep *Report) {
for n := d.Body; n <= d.Len(); n++ {
if d.Fenced(n) {
continue
}
for _, candidate := range mdPathRe.FindAllString(d.Line(n), -1) {
if !namesSuiteFile(s, candidate) {
continue
}
rep.Errorf(Spread, d.Path, n,
"the text holds the canon file path %q: refer by the name of a topic or the identifier of a rule", candidate)
}
}
}
// 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.
//
// 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() {
for _, ref := range refsIn(d, norm.Start, norm.End) {
if ref.Prefix == own || strings.HasPrefix(ref.Prefix, "X") {
continue
}
target, ok := s.ByPrefix[ref.Prefix]
if !ok || target.Front.Topic == d.Front.Topic {
continue
}
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)
}
}