проектные команды и ссылки на источник

- заведён internal/source: уровни ссылаются друг на друга путём на диске
  или git-репозиторием, ревизия закрепляется хвостом #ref; клон делается
  заново и удаляется, кэша нет
- добавлены init, add, pull, list, check в проекте — манифест
  .conventions.toml, сборка копий по разу на компонент, маркер локальной
  части, READING.md рядом
- проверки формы развязаны с набором: принимают lang.Vocabulary, а язык
  копии узнаётся по строке о версии — манифеста рядом с ней нет
This commit is contained in:
av
2026-07-27 20:42:18 +03:00
parent 4615de6e86
commit 23d88c4048
27 changed files with 3009 additions and 57 deletions
+181
View File
@@ -0,0 +1,181 @@
package check
import (
"sort"
"strings"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/lang"
)
// Checking a copy is not checking a suite with parts left out. A copy has no
// manifest next to it, no prefix of its own and no path back to where it came
// from — it declares its language by the version line and its origin by one
// key, and that is everything a consuming repository holds.
//
// So what is checked here is what a copy answers for on its own: the form of a
// rule, which is the same form the suite writes, and the two things only a copy
// has — the marker of the local part, and the rule that whatever is written
// below it takes a prefix on X.
// CopyMarker is the boundary between what the suite wrote and what the
// repository wrote. It is passed in rather than known here: the marker belongs
// to the model of assembly, and the checks merely respect it.
const CopyMarker = "<!-- conv:local -->"
// Copy checks one assembled convention.
func Copy(d *doc.Document, rep *Report) {
if !checkOrigin(d, rep) {
return
}
v, ok := recognize(d, rep)
if !ok {
return
}
d.Blocks(v)
marker := markerLine(d)
checkMarker(d, marker, rep)
checkCopyHeadings(d, marker, rep)
checkHeadingHierarchy(d, rep)
for _, prefix := range prefixes(d) {
checkNumbering(d, prefix, rep)
}
checkRules(v, d, rep)
versionFrom, versionTo := checkVersionLine(v, d, rep)
checkModalsOutside(v, d, versionFrom, versionTo, rep)
checkForeignVocabulary(v, d, rep)
checkForeignConnectives(v, d, rep)
checkCopyLinks(d, rep)
}
// checkOrigin checks the front matter of a copy: one key, the name of the
// topic. The keys of a suite file have no business here — a copy is flat and
// carries no axis, and a stray extends would point at a path the repository
// does not have.
func checkOrigin(d *doc.Document, rep *Report) bool {
if !d.Front.Present || d.Front.Origin == "" {
rep.Errorf(Spread, d.Path, 1, "the file carries no origin key and is not a copy")
return false
}
for _, key := range []string{"topic", "prefix", "lang", "stack", "extends"} {
if at, ok := d.Front.At[key]; ok {
rep.Errorf(Spread, d.Path, at,
"the front matter of a copy carries the key %q of a suite file: a copy declares its topic by origin and nothing else", key)
}
}
for _, key := range d.Front.Unknown {
rep.Warnf(Spread, d.Path, d.Front.At[key], "front matter key %q is unknown to the tool", key)
}
return true
}
// recognize works out which vocabulary the copy is written in.
func recognize(d *doc.Document, rep *Report) (lang.Vocabulary, bool) {
from, to := d.Preamble()
var text []string
for _, p := range d.Paragraphs(from, to) {
text = append(text, p.Text())
}
v, ok := lang.Recognize(strings.Join(text, "\n"))
if !ok {
start, _ := d.Preamble()
rep.Errorf(Form, d.Path, start,
"the introductory prose holds no language version line, and it is the only thing that says which words of this file are normative")
return lang.Vocabulary{}, false
}
return v, true
}
// markerLine finds the line the local marker stands on, or zero.
func markerLine(d *doc.Document) int {
for n := 1; n <= d.Len(); n++ {
if strings.TrimSpace(d.Line(n)) == CopyMarker {
return n
}
}
return 0
}
func checkMarker(d *doc.Document, marker int, rep *Report) {
if marker == 0 {
rep.Errorf(Spread, d.Path, d.Len(),
"the copy carries no %s marker: there is nowhere to write a derogation, and a reassembly would overwrite whatever was written instead", CopyMarker)
return
}
for n := marker + 1; n <= d.Len(); n++ {
if strings.TrimSpace(d.Line(n)) == CopyMarker {
rep.Errorf(Spread, d.Path, n,
"the copy carries a second %s marker: the marker is one, and everything below the first belongs to the repository", CopyMarker)
return
}
}
}
// checkCopyHeadings checks what a rule heading of a copy answers for. The level
// is not among it: layers below the first become sections of the document when
// assembled, and their rules step down with them.
func checkCopyHeadings(d *doc.Document, marker int, rep *Report) {
for _, r := range d.Rules {
if r.Malformed != "" {
rep.Errorf(Form, d.Path, r.Line, "%s: %s", r.ID(), r.Malformed)
}
local := marker > 0 && r.Line > marker
switch {
case local && !strings.HasPrefix(r.Prefix, "X"):
rep.Errorf(Spread, d.Path, r.Line,
"rule %s stands below the marker and takes a prefix the suite could hand out: a rule of the repository takes a prefix on X", r.ID())
case !local && strings.HasPrefix(r.Prefix, "X"):
rep.Errorf(Spread, d.Path, r.Line,
"rule %s is a rule of the repository standing above the marker: a reassembly would wipe it", r.ID())
}
}
}
// prefixes lists the prefixes the rules of a copy use, in a stable order. There
// is more than one: a copy gathers the layers of a topic, and a layer brings its
// own prefix along.
func prefixes(d *doc.Document) []string {
seen := make(map[string]bool)
var out []string
for _, r := range d.Rules {
if !seen[r.Prefix] {
seen[r.Prefix] = true
out = append(out, r.Prefix)
}
}
sort.Strings(out)
return out
}
// checkCopyLinks resolves the references a copy can resolve: the ones to a
// prefix the file itself holds. A reference to another topic is left alone — the
// repository may well not be subscribed to it, and that is legitimate (META-20).
func checkCopyLinks(d *doc.Document, rep *Report) {
own := make(map[string]map[int]bool)
for _, r := range d.Rules {
if own[r.Prefix] == nil {
own[r.Prefix] = make(map[int]bool)
}
own[r.Prefix][r.Num] = true
}
for _, ref := range refsIn(d, d.Body, d.Len()) {
nums, mine := own[ref.Prefix]
if !mine || nums[ref.Num] {
continue
}
rep.Errorf(Links, d.Path, ref.Line,
"reference %s points at a rule this file does not hold, while it does hold the rules of %s",
ref.Text, ref.Prefix)
}
}
// Copies checks every copy handed to it.
func Copies(docs []*doc.Document) *Report {
rep := &Report{}
for _, d := range docs {
Copy(d, rep)
}
return rep
}
+26 -23
View File
@@ -20,11 +20,11 @@ func checkForm(s *suite.Suite, d *doc.Document, rep *Report) {
checkHeadings(d, prefix, rep)
checkHeadingHierarchy(d, rep)
checkNumbering(d, prefix, rep)
checkRules(s, d, rep)
versionFrom, versionTo := checkVersionLine(s, d, rep)
checkModalsOutside(s, d, versionFrom, versionTo, rep)
checkForeignVocabulary(s, d, rep)
checkForeignConnectives(s, d, rep)
checkRules(s.Vocab, d, rep)
versionFrom, versionTo := checkVersionLine(s.Vocab, d, rep)
checkModalsOutside(s.Vocab, d, versionFrom, versionTo, rep)
checkForeignVocabulary(s.Vocab, d, rep)
checkForeignConnectives(s.Vocab, d, rep)
}
// checkHeadingHierarchy checks the ladder of headings: one title, and no level
@@ -62,8 +62,8 @@ func checkHeadingHierarchy(d *doc.Document, rep *Report) {
// vocabulary. Only the start of a line counts: that is where a scenario block
// puts them, while mid-sentence AND and OR belong to SQL far more often than to
// a mixture of vocabularies.
func checkForeignConnectives(s *suite.Suite, d *doc.Document, rep *Report) {
foreign := lang.ForeignConnectives(s.Manifest.Language.Version, s.Manifest.Language.Lang)
func checkForeignConnectives(v lang.Vocabulary, d *doc.Document, rep *Report) {
foreign := lang.ForeignConnectives(v.Version, v.Code)
if len(foreign) == 0 {
return
}
@@ -80,8 +80,8 @@ func checkForeignConnectives(s *suite.Suite, d *doc.Document, rep *Report) {
continue
}
rep.Errorf(Form, d.Path, n,
"the scenario block opens with %s from the %q vocabulary, while the suite declares %q",
w, foreign[w], s.Manifest.Language.Lang)
"the scenario block opens with %s from the %q vocabulary, while the document is written in %q",
w, foreign[w], v.Code)
break
}
return true
@@ -142,10 +142,14 @@ func checkHeadings(d *doc.Document, prefix string, rep *Report) {
// why there is never one, and a retired rule stays as a stub.
func checkNumbering(d *doc.Document, prefix string, rep *Report) {
seen := make(map[int][]int)
first := 0
for _, r := range d.Rules {
if r.Prefix != prefix {
continue
}
if first == 0 {
first = r.Line
}
seen[r.Num] = append(seen[r.Num], r.Line)
}
if len(seen) == 0 {
@@ -173,7 +177,7 @@ func checkNumbering(d *doc.Document, prefix string, rep *Report) {
}
}
if len(gaps) > 0 {
rep.Errorf(Form, d.Path, d.Rules[0].Line,
rep.Errorf(Form, d.Path, first,
"numbering is not contiguous: the highest number is %d, missing %s — a retired rule stays as a stub instead of disappearing",
highest, joinInts(gaps))
}
@@ -184,8 +188,7 @@ var dateRe = regexp.MustCompile(`\d{4}-\d{2}-\d{2}`)
// checkRules checks what a rule is made of: either a norm with a rationale, or
// the stub of a retired one. Neither the norm nor the rationale is ever deleted
// (META-8, META-10).
func checkRules(s *suite.Suite, d *doc.Document, rep *Report) {
v := s.Vocab
func checkRules(v lang.Vocabulary, d *doc.Document, rep *Report) {
for _, r := range d.Rules {
if retired, ok := r.Block(lang.Retired); ok {
checkRetired(d, r, retired, rep)
@@ -255,15 +258,15 @@ func checkRetired(d *doc.Document, r doc.Rule, retired doc.Block, rep *Report) {
// The line lists the key words of the suite and carries the rule of capitals
// itself — which makes it the only place outside rules where modal words are
// lawful.
func checkVersionLine(s *suite.Suite, d *doc.Document, rep *Report) (from, to int) {
p, ok := versionParagraph(s, d)
func checkVersionLine(v lang.Vocabulary, d *doc.Document, rep *Report) (from, to int) {
p, ok := versionParagraph(v, d)
if !ok {
start, _ := d.Preamble()
rep.Errorf(Form, d.Path, start,
"the introductory prose holds no language version line: it lists the key words of the suite, and without it a convention in a foreign repository loses the key to its own text")
return 0, 0
}
version := strconv.Itoa(s.Manifest.Language.Version)
version := strconv.Itoa(v.Version)
if !containsNumber(p.Text(), version) {
rep.Errorf(Form, d.Path, p.Start,
"the language version line does not name version %s declared by the suite manifest", version)
@@ -275,9 +278,9 @@ func checkVersionLine(s *suite.Suite, d *doc.Document, rep *Report) (from, to in
// the language version line: the one listing every key word of the suite. It
// reports nothing — checkVersionLine speaks about its absence, and speaking
// twice helps no one.
func versionParagraph(s *suite.Suite, d *doc.Document) (doc.Paragraph, bool) {
func versionParagraph(v lang.Vocabulary, d *doc.Document) (doc.Paragraph, bool) {
from, to := d.Preamble()
words := s.Vocab.Words()
words := v.Words()
for _, p := range d.Paragraphs(from, to) {
if containsAll(p.Text(), words) {
return p, true
@@ -289,8 +292,8 @@ func versionParagraph(s *suite.Suite, d *doc.Document) (doc.Paragraph, bool) {
// checkModalsOutside looks for capitalized modal words outside rule areas. An
// area runs from the heading of a rule to the next heading; everything else is
// prose, and prose is never a norm.
func checkModalsOutside(s *suite.Suite, d *doc.Document, versionFrom, versionTo int, rep *Report) {
words := modalWords(s.Vocab)
func checkModalsOutside(v lang.Vocabulary, d *doc.Document, versionFrom, versionTo int, rep *Report) {
words := modalWords(v)
d.Prose(func(n int, text string) bool {
if d.InRule(n) || n >= versionFrom && n <= versionTo {
return true
@@ -310,8 +313,8 @@ func checkModalsOutside(s *suite.Suite, d *doc.Document, versionFrom, versionTo
// checkForeignVocabulary looks for words of another vocabulary of the same
// language version. There is one vocabulary per suite: two ways of writing the
// same requirement double every check.
func checkForeignVocabulary(s *suite.Suite, d *doc.Document, rep *Report) {
foreign := lang.Foreign(s.Manifest.Language.Version, s.Manifest.Language.Lang)
func checkForeignVocabulary(v lang.Vocabulary, d *doc.Document, rep *Report) {
foreign := lang.Foreign(v.Version, v.Code)
if len(foreign) == 0 {
return
}
@@ -325,8 +328,8 @@ func checkForeignVocabulary(s *suite.Suite, d *doc.Document, rep *Report) {
for _, w := range words {
if containsWord(text, w) {
rep.Errorf(Form, d.Path, n,
"the word %s belongs to the %q vocabulary, while the suite declares %q",
w, foreign[w], s.Manifest.Language.Lang)
"the word %s belongs to the %q vocabulary, while the document is written in %q",
w, foreign[w], v.Code)
}
}
return true
+1 -1
View File
@@ -155,7 +155,7 @@ func checkMechanized(s *suite.Suite, d *doc.Document, rep *Report) {
if word == "" {
return
}
version, hasVersion := versionParagraph(s, d)
version, hasVersion := versionParagraph(s.Vocab, d)
d.Prose(func(n int, text string) bool {
if hasVersion && n >= version.Start && n <= version.End {
return true
+31 -3
View File
@@ -8,6 +8,7 @@ import (
"strings"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/source"
"git.vakhrushev.me/av/convy/internal/suite"
)
@@ -15,6 +16,7 @@ import (
func Suite(s *suite.Suite) *Report {
rep := &Report{}
checkManifest(s, rep)
checkLanguageSource(s, rep)
checkTopicNames(s, rep)
checkBaseLayers(s, rep)
checkSelfGoverning(s, rep)
@@ -39,9 +41,15 @@ func checkManifest(s *suite.Suite, rep *Report) {
for _, key := range m.Undecoded {
rep.Warnf(Manifest, manifest.Name, 0, "the key %s is unknown to the tool", key)
}
for _, name := range []string{m.Language.Description, m.Language.Reading} {
if name != "" && !s.Exists(name) {
rep.Errorf(Manifest, manifest.Name, 0, "the [language] section declares the document %q, and the file is missing", name)
// The documents about the language are named relative to the level they
// belong to. While that level is the suite, the files have to be here; once
// the language lives apart, they are out of reach of a check that runs on
// every edit and must not touch the network.
if m.Language.Source == "" {
for _, name := range []string{m.Language.Description, m.Language.Reading} {
if name != "" && !s.Exists(name) {
rep.Errorf(Manifest, manifest.Name, 0, "the [language] section declares the document %q, and the file is missing", name)
}
}
}
@@ -164,6 +172,12 @@ func checkSelfGoverning(s *suite.Suite, rep *Report) {
// fails in silence.
func checkReadingVocabulary(s *suite.Suite, rep *Report) {
name := s.Manifest.Language.Reading
if s.Manifest.Language.Source != "" {
// The account lies at a level of its own, and reaching it costs a
// fetch. Checking the integrity of a suite runs on every edit, so it
// stays local; the reference itself is checked instead.
return
}
if name == "" {
// A suite that has not written the document yet; `suite init` says so
// among the next steps, and repeating it on every run is noise.
@@ -218,3 +232,17 @@ func sortedKeys[V any](m map[string]V) []string {
sort.Strings(keys)
return keys
}
// checkLanguageSource checks the reference to the level of the language. What
// it names cannot be reached without a fetch, and a check of a suite does not
// fetch — but a reference that means nothing is caught here rather than at the
// first assembly in a foreign repository.
func checkLanguageSource(s *suite.Suite, rep *Report) {
raw := s.Manifest.Language.Source
if raw == "" {
return
}
if _, err := source.Parse(raw); err != nil {
rep.Errorf(Manifest, manifest.Name, 0, "[language] source: %s", err)
}
}