suite retire и проверка словаря в документе для читателя

- suite retire снимает правило, конвенцию или тему: правило остаётся заглушкой
  с датой и причиной, имя уезжает в раздел выбывших, снятие с непогашенными
  ссылками отклоняется с перечнем мест
- добавлена проверка META-30: короткое описание языка обязано называть все
  слова словаря, иначе читатель копии толкует их по памяти
- retired-раздел манифеста больше не считается объявлением пути: снятый
  префикс означает, что файл ушёл вместе с ним
This commit is contained in:
av
2026-07-27 10:51:46 +03:00
parent b29b5b5e6f
commit 5321fba89d
9 changed files with 753 additions and 7 deletions
+16 -1
View File
@@ -58,6 +58,15 @@ prefix: TIME
**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.
`
// reading stands for the short account of the language, the one document about
// it that travels into a copy. It has to name every word of the vocabulary:
// that is the whole of META-30.
const reading = `# Как читать конвенцию
Ключевые слова: ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ.
Метки: ПОЧЕМУ, ПРИМЕРЫ, МЕХАНИЗИРОВАНО, СНЯТО.
`
// files is the content of a suite: a path from the root mapped to the text of
// the file. An empty string means "no such file": that is how a test drops a
// file the base fixture provides.
@@ -67,7 +76,7 @@ func base() files {
return files{
"suite.toml": baseManifest,
"LANGUAGE.md": "# Язык конвенций\n\nОписание языка.\n",
"READING.md": "# Как читать конвенцию\n\nКоротко.\n",
"READING.md": reading,
"conventions/time.md": baseTime,
}
}
@@ -374,6 +383,12 @@ func TestChecks(t *testing.T) {
f["conventions/rules.md"] = "---\nprefix: RULE\n---\n\n# Правила\n\n" + versionLine + "\n"
},
want: "more than one document without a topic",
}, {
name: "the short account of the language lost a word of the vocabulary",
setup: func(f files) {
f["READING.md"] = strings.Replace(reading, ", ДОПУСКАЕТСЯ", "", 1)
},
want: "does not name ДОПУСКАЕТСЯ",
}, {
name: "document without a topic carries layer keys",
setup: func(f files) {
+1 -1
View File
@@ -95,7 +95,7 @@ func layered() files {
return files{
"suite.toml": layeredManifest,
"LANGUAGE.md": "# Язык конвенций\n\nОписание языка.\n",
"READING.md": "# Как читать конвенцию\n\nКоротко.\n",
"READING.md": reading,
"conventions/arch/time.md": archTime,
"conventions/lang/go/time.md": goTime,
"conventions/arch/logging.md": archLogging,
+40
View File
@@ -1,8 +1,11 @@
package check
import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/suite"
@@ -15,6 +18,7 @@ func Suite(s *suite.Suite) *Report {
checkTopicNames(s, rep)
checkBaseLayers(s, rep)
checkSelfGoverning(s, rep)
checkReadingVocabulary(s, rep)
for _, d := range s.Docs {
checkForm(s, d, rep)
@@ -148,6 +152,42 @@ func checkSelfGoverning(s *suite.Suite, rep *Report) {
}
}
// checkReadingVocabulary checks that the short account of the language names
// every word of the vocabulary (META-30).
//
// The full account stays with the author of the suite, while the rules are
// applied by the reader of a copy — a person or an agent in a foreign
// repository who holds the short one and nothing else. Let the two drift apart
// and that reader starts reading the words by an older version: ДОПУСКАЕТСЯ
// turns back into an everyday "you may", a departure from ДОЛЖЕН stops
// demanding a record. Exactly what the words were introduced for fails, and it
// fails in silence.
func checkReadingVocabulary(s *suite.Suite, rep *Report) {
name := s.Manifest.Language.Reading
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.
return
}
body, err := os.ReadFile(filepath.Join(s.Root, filepath.FromSlash(name)))
if err != nil {
// The missing file is reported by the manifest check already.
return
}
var missing []string
for _, word := range s.Vocab.Words() {
if !containsWord(string(body), word) {
missing = append(missing, word)
}
}
if len(missing) > 0 {
rep.Errorf(Form, name, 0,
"the short account of the language does not name %s: it is the only key to the text of a rule a reader of a copy holds, and a word missing from it is a word read by whatever version the reader remembers",
strings.Join(missing, ", "))
}
}
// checkBaseLayers checks that a topic holds no more than one layer without axis
// keys. The base layer is the only one of its kind: it reaches every copy, and a
// second such layer would mean two base texts in one assembled file.
+4 -1
View File
@@ -63,7 +63,7 @@ func Run(env Env, args []string) ExitCode {
func runSuite(env Env, args []string) ExitCode {
if len(args) == 0 {
fmt.Fprintln(env.Err, "convy suite: a subcommand is required — init, add or check")
fmt.Fprintln(env.Err, "convy suite: a subcommand is required — init, add, retire or check")
return Usage
}
switch args[0] {
@@ -73,6 +73,8 @@ func runSuite(env Env, args []string) ExitCode {
return runSuiteInit(env, args[1:])
case "add":
return runSuiteAdd(env, args[1:])
case "retire":
return runSuiteRetire(env, args[1:])
default:
fmt.Fprintf(env.Err, "unknown subcommand %q for convy suite\n", args[0])
return Usage
@@ -92,6 +94,7 @@ In a project:
In a suite:
convy suite init start a suite: a directory and a manifest
convy suite add add a convention: a file, a topic and a prefix
convy suite retire retire a rule, a convention or a topic — never reusing it
convy suite check suite integrity: prefixes, topics, axes, links, form
The commands that change something run in two modes. Bare, they ask for every
+28 -1
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"slices"
"strings"
)
@@ -29,6 +30,8 @@ type Field struct {
Default string
// Optional fields may stay empty.
Optional bool
// Options, when given, are the only answers accepted.
Options []string
// Check validates an answer; it is applied in both modes.
Check func(string) error
}
@@ -54,6 +57,11 @@ func (d *dialogue) ask(f Field) (string, error) {
}
prompt := f.Ask
switch {
case len(f.Options) > 0:
prompt += fmt.Sprintf(" (%s)", strings.Join(f.Options, " / "))
if f.Default != "" {
prompt += fmt.Sprintf(" [%s]", f.Default)
}
case f.Default != "":
prompt += fmt.Sprintf(" [%s]", f.Default)
case f.Optional:
@@ -81,9 +89,28 @@ func (d *dialogue) ask(f Field) (string, error) {
}
}
// confirm asks a yes-or-no question before something irreversible, defaulting
// to no: a retirement that went through because the reader pressed enter is the
// one mistake the model cannot undo.
func (d *dialogue) confirm(question string) (bool, error) {
fmt.Fprintf(d.out, "\n%s [y/N]: ", question)
line, err := d.in.ReadString('\n')
answer := strings.ToLower(strings.TrimSpace(line))
if answer == "" && err != nil {
return false, errStop
}
return answer == "y" || answer == "yes", nil
}
// validate applies a field's check to a value that is not empty.
func validate(f Field, value string) error {
if value == "" || f.Check == nil {
if value == "" {
return nil
}
if len(f.Options) > 0 && !slices.Contains(f.Options, value) {
return fmt.Errorf("one of: %s", strings.Join(f.Options, ", "))
}
if f.Check == nil {
return nil
}
return f.Check(value)
+228
View File
@@ -0,0 +1,228 @@
package cli_test
import (
"os"
"path/filepath"
"strings"
"testing"
"git.vakhrushev.me/av/convy/internal/cli"
)
// retirable builds a suite with two topics, three conventions and a couple of
// rules, which is the least that lets every case of retirement be told apart.
func retirable(t *testing.T) string {
t.Helper()
root := filepath.Join(t.TempDir(), "suite")
run(t, ".", "", false, "suite", "init", "--path", root, "--lang", "ru")
run(t, root, "", false, "suite", "add", "--topic", "time", "--about", "время", "--prefix", "TIME", "--title", "Время")
run(t, root, "", false, "suite", "add", "--topic", "time", "--prefix", "GTIM", "--lang", "go", "--title", "Время на Go")
run(t, root, "", false, "suite", "add", "--topic", "logging", "--about", "логирование", "--prefix", "SLOG", "--title", "Логирование")
appendRules(t, root, "conventions/time.md", `
### TIME-1. Момент записывается в UTC
**ДОЛЖЕН.** Момент времени записывается с суффиксом Z.
**ПОЧЕМУ.** Без явного смещения не видно, в какой зоне запись сделана.
### TIME-2. Ширина строки фиксируется
**ДОЛЖЕН.** Внутри одной колонки длина строки времени одна.
**ПОЧЕМУ.** Лексикографическая сортировка совпадает с хронологией только среди
строк одинаковой длины.
`)
appendRules(t, root, "conventions/logging.md", `
### SLOG-1. Уровень выбирается по адресату
**ДОЛЖЕН.** Уровень отвечает на вопрос «кому сообщение».
**ПОЧЕМУ.** Адресат — единственный воспроизводимый признак.
`)
return root
}
func appendRules(t *testing.T, root, rel, rules string) {
t.Helper()
name := filepath.Join(root, filepath.FromSlash(rel))
body, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(name, append(body, []byte("\n## Правила\n"+rules)...), 0o644); err != nil {
t.Fatal(err)
}
}
func TestRetireRuleLeavesAStub(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "retire",
"--rule", "TIME-2", "--reason", "ширина следует из TIME-1 и отдельного правила не требует",
"--date", "2026-07-27")
if code != cli.OK {
t.Fatalf("retiring a rule returned %d: %s", code, out)
}
body := read(t, root, "conventions/time.md")
if !strings.Contains(body, "### TIME-2. Ширина строки фиксируется") {
t.Errorf("the heading and the number did not survive:\n%s", body)
}
if !strings.Contains(body, "**СНЯТО 2026-07-27.** ширина следует из TIME-1") {
t.Errorf("the stub is not there or is worded wrong:\n%s", body)
}
if strings.Contains(body, "Лексикографическая сортировка") {
t.Errorf("the rationale of the retired rule survived:\n%s", body)
}
if strings.Contains(body, "**ДОЛЖЕН.** Внутри одной колонки") {
t.Errorf("the norm of the retired rule survived:\n%s", body)
}
// The numbering stays contiguous and the whole suite still checks clean —
// which is the point of a stub over a deletion.
checkClean(t, root)
}
func TestRetireRuleRefusesWhatItCannotDo(t *testing.T) {
root := retirable(t)
run(t, root, "", false, "suite", "retire", "--rule", "TIME-2", "--reason", "уже снято", "--date", "2026-07-27")
cases := []struct{ name, id, want string }{
{"a rule already retired", "TIME-2", "retired already"},
{"a number no rule carries", "TIME-9", "holds no rule numbered 9"},
{"a prefix the suite does not declare", "ZZZZ-1", "no live prefix ZZZZ"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
code, out := run(t, root, "", false, "suite", "retire", "--rule", tc.id, "--reason", "почему-то")
if code == cli.OK {
t.Fatalf("the command went through:\n%s", out)
}
if !strings.Contains(out, tc.want) {
t.Errorf("the refusal does not say %q:\n%s", tc.want, out)
}
})
}
}
// A file takes its rules with it, and nothing stands in for a file the way a
// stub stands in for a rule. So a convention still pointed at cannot go.
func TestRetirePrefixRefusesWhileStillPointedAt(t *testing.T) {
root := retirable(t)
name := filepath.Join(root, "conventions", "logging.md")
body, _ := os.ReadFile(name)
if err := os.WriteFile(name, append(body,
[]byte("\nВремя в записи следует TIME-1.\n")...), 0o644); err != nil {
t.Fatal(err)
}
code, out := run(t, root, "", false, "suite", "retire", "--prefix", "TIME", "--reason", "тема закрыта")
if code == cli.OK {
t.Fatalf("a convention still pointed at was retired:\n%s", out)
}
for _, want := range []string{"still pointed at", "conventions/logging.md", "TIME-1"} {
if !strings.Contains(out, want) {
t.Errorf("the refusal does not say %q:\n%s", want, out)
}
}
if _, err := os.Stat(filepath.Join(root, "conventions", "time.md")); err != nil {
t.Error("the file was removed despite the refusal")
}
}
func TestRetirePrefixMovesTheNameAndTakesTheFile(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "retire",
"--prefix", "SLOG", "--reason", "тема свёрнута", "--date", "2026-07-27")
if code != cli.OK {
t.Fatalf("retiring a convention returned %d: %s", code, out)
}
if _, err := os.Stat(filepath.Join(root, "conventions", "logging.md")); !os.IsNotExist(err) {
t.Error("the file stayed behind")
}
toml := read(t, root, "suite.toml")
if strings.Contains(toml, `SLOG = "conventions/logging.md"`) {
t.Errorf("the prefix stayed in the live half:\n%s", toml)
}
if !strings.Contains(toml, `SLOG = "2026-07-27, was conventions/logging.md: тема свёрнута"`) {
t.Errorf("the retired half does not carry the date, the path and the reason:\n%s", toml)
}
if !strings.Contains(out, "no layer left") {
t.Errorf("the report does not point out that the topic is now empty:\n%s", out)
}
}
// A topic lives as long as one layer of it does, so the name cannot go first.
func TestRetireTopicWaitsForItsLayers(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "retire", "--topic", "time", "--reason", "не нужна")
if code == cli.OK {
t.Fatalf("a topic with layers was retired:\n%s", out)
}
for _, want := range []string{"still holds layers", "conventions/time.md", "conventions/lang/go/time.md"} {
if !strings.Contains(out, want) {
t.Errorf("the refusal does not say %q:\n%s", want, out)
}
}
run(t, root, "", false, "suite", "retire", "--prefix", "GTIM", "--reason", "слой снят", "--date", "2026-07-27")
run(t, root, "", false, "suite", "retire", "--prefix", "TIME", "--reason", "слой снят", "--date", "2026-07-27")
code, out = run(t, root, "", false, "suite", "retire",
"--topic", "time", "--reason", "решение переехало в logging", "--date", "2026-07-27")
if code != cli.OK {
t.Fatalf("retiring an empty topic returned %d: %s", code, out)
}
toml := read(t, root, "suite.toml")
if strings.Contains(toml, `time = "время"`) {
t.Errorf("the topic stayed in the live half:\n%s", toml)
}
if !strings.Contains(toml, `time = "2026-07-27: решение переехало в logging"`) {
t.Errorf("the retired half does not carry the date and the reason:\n%s", toml)
}
checkClean(t, root)
}
func TestRetireNeedsExactlyOneTarget(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "", false, "suite", "retire",
"--rule", "TIME-1", "--topic", "time", "--reason", "и то и другое")
if code == cli.OK {
t.Fatalf("two targets at once went through:\n%s", out)
}
if !strings.Contains(out, "exactly one") {
t.Errorf("the refusal does not say why:\n%s", out)
}
}
// Retirement is irreversible, so the dialogue asks before writing and takes
// silence for no.
func TestRetireInteractiveConfirms(t *testing.T) {
root := retirable(t)
code, out := run(t, root, "rule\nTIME-2\nбольше не нужно\n\n", true, "suite", "retire")
if code == cli.OK {
t.Fatalf("an unconfirmed retirement went through:\n%s", out)
}
if !strings.Contains(out, "nothing was written") {
t.Errorf("the report does not say that nothing happened:\n%s", out)
}
if strings.Contains(read(t, root, "conventions/time.md"), "**СНЯТО ") {
t.Error("the file was written despite no confirmation")
}
code, out = run(t, root, "rule\nTIME-2\nбольше не нужно\ny\n", true, "suite", "retire")
if code != cli.OK {
t.Fatalf("a confirmed retirement returned %d: %s", code, out)
}
if !strings.Contains(read(t, root, "conventions/time.md"), "**СНЯТО ") {
t.Error("a confirmed retirement wrote nothing")
}
}
+396
View File
@@ -0,0 +1,396 @@
package cli
import (
"flag"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"git.vakhrushev.me/av/convy/internal/doc"
"git.vakhrushev.me/av/convy/internal/lang"
"git.vakhrushev.me/av/convy/internal/manifest"
"git.vakhrushev.me/av/convy/internal/suite"
)
// Retirement is the one operation in the model whose mistakes cannot be undone.
// A text rewritten badly gets rewritten again; a prefix handed out twice is a
// reference from a foreign repository that now points at a different statement,
// and that is discovered by its content rather than by any check.
//
// So the command does the bookkeeping the discipline otherwise leans on
// attention for: it writes the date, it words the stub out of the suite's own
// vocabulary, it moves the name into the retired half instead of deleting it,
// and it refuses whatever would leave a reference pointing at nothing.
var ruleIDRe = regexp.MustCompile(`^([A-Z]{4})-(\d+)$`)
func runSuiteRetire(env Env, args []string) ExitCode {
fs := flag.NewFlagSet("convy suite retire", flag.ContinueOnError)
fs.SetOutput(env.Err)
root := fs.String("root", "", "root of the suite; by default it is looked up upwards")
rule := fs.String("rule", "", "identifier of a rule to retire, PREFIX-N")
prefix := fs.String("prefix", "", "prefix of a convention to retire together with its file")
topic := fs.String("topic", "", "name of a topic to retire")
reason := fs.String("reason", "", "why it is being retired")
date := fs.String("date", "", "date of retirement; today by default")
if err := fs.Parse(args); err != nil {
return Usage
}
dir, code := suiteRoot(env, *root)
if code != OK {
return code
}
s, err := suite.Load(dir)
if err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
when := *date
if when == "" {
when = time.Now().Format("2006-01-02")
}
given := map[string]string{"rule": *rule, "prefix": *prefix, "topic": *topic, "reason": *reason}
talk := newDialogue(env)
if len(args) == 0 {
if !env.Interactive {
fmt.Fprintln(env.Err, "convy suite retire without arguments asks questions, and there is no terminal to ask on; pass one of --rule, --prefix, --topic together with --reason")
return Usage
}
if err := askRetire(env, talk, s, given); err != nil {
return Usage
}
} else if err := resolve(retireFields(s), given); err != nil {
fmt.Fprintln(env.Err, err)
return Usage
}
targets := 0
for _, key := range []string{"rule", "prefix", "topic"} {
if given[key] != "" {
targets++
}
}
if targets != 1 {
fmt.Fprintln(env.Err, "name exactly one of --rule, --prefix, --topic: the three retire different things and undo differently")
return Usage
}
// The dialogue is built once and carried through: a second one over the
// same input would find it drained, because the first reads ahead.
var d *dialogue
if env.Interactive {
d = talk
}
switch {
case given["rule"] != "":
return retireRule(env, d, s, given, when)
case given["prefix"] != "":
return retirePrefix(env, d, s, given, when)
default:
return retireTopic(env, d, s, given, when)
}
}
func retireFields(s *suite.Suite) []Field {
return []Field{{
Flag: "rule",
Ask: "Rule",
Optional: true,
Check: func(v string) error {
if !ruleIDRe.MatchString(v) {
return fmt.Errorf("a rule is named by its identifier, PREFIX-N")
}
return nil
},
}, {
Flag: "prefix",
Ask: "Prefix",
Optional: true,
Check: func(v string) error {
if _, ok := s.Manifest.PathOf(v); !ok {
return fmt.Errorf("the suite declares no live prefix %s", v)
}
return nil
},
}, {
Flag: "topic",
Ask: "Topic",
Optional: true,
Check: func(v string) error {
if !s.Manifest.TopicLive(v) {
return fmt.Errorf("the suite declares no live topic %q", v)
}
return nil
},
}, {
Flag: "reason",
Ask: "Reason",
Hint: "Why it is going. The reason outlives the thing itself — it is what a reader finds in place of what they were looking for.",
}}
}
// askRetire walks the dialogue: what kind of thing, which one, and why.
func askRetire(env Env, d *dialogue, s *suite.Suite, given map[string]string) error {
fields := retireFields(s)
kind, err := d.ask(Field{
Ask: "What is being retired",
Hint: "A rule keeps its number and turns into a stub; a convention takes its file with it; a topic goes once no layer of it is left.",
Options: []string{"rule", "convention", "topic"},
Default: "rule",
})
if err != nil {
fmt.Fprintln(env.Err, "\ninterrupted, nothing was written")
return err
}
flagOf := map[string]string{"rule": "rule", "convention": "prefix", "topic": "topic"}[kind]
for _, f := range fields {
if f.Flag != flagOf && f.Flag != "reason" {
continue
}
f.Optional = false
answer, err := d.ask(f)
if err != nil {
fmt.Fprintln(env.Err, "\ninterrupted, nothing was written")
return err
}
given[f.Flag] = answer
}
return nil
}
// retireRule replaces the norm and the rationale with a stub, keeping the
// heading and the number. The number stays taken forever, so a reference from a
// foreign repository lands on the explanation instead of on nothing.
func retireRule(env Env, d *dialogue, s *suite.Suite, given map[string]string, when string) ExitCode {
m := ruleIDRe.FindStringSubmatch(given["rule"])
num, _ := strconv.Atoi(m[2])
target, ok := s.ByPrefix[m[1]]
if !ok {
fmt.Fprintf(env.Err, "the suite declares no live prefix %s\n", m[1])
return Usage
}
var found *doc.Rule
for i := range target.Rules {
if target.Rules[i].Num == num {
found = &target.Rules[i]
}
}
if found == nil {
fmt.Fprintf(env.Err, "%s holds no rule numbered %d\n", target.Path, num)
return Usage
}
if _, already := found.Block(lang.Retired); already {
fmt.Fprintf(env.Err, "%s is retired already\n", found.ID())
return Usage
}
if d != nil {
ok, err := d.confirm(fmt.Sprintf(
"%s in %s loses its norm and its rationale, keeping the number. Go on?", found.ID(), target.Path))
if err != nil || !ok {
fmt.Fprintln(env.Out, "nothing was written")
return Usage
}
}
name := filepath.Join(s.Root, filepath.FromSlash(target.Path))
body, err := os.ReadFile(name)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
lines := strings.Split(string(body), "\n")
stub := fmt.Sprintf("**%s %s.** %s", s.Vocab.MarkWord(lang.Retired), when, given["reason"])
replacement := append([]string{""}, wrap(stub, 78)...)
replacement = append(replacement, "")
out := make([]string, 0, len(lines))
out = append(out, lines[:found.Line]...)
out = append(out, replacement...)
if found.End < len(lines) {
out = append(out, lines[found.End:]...)
}
if err := os.WriteFile(name, []byte(strings.Join(out, "\n")), 0o644); err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
fmt.Fprintf(env.Out, "\n%s is retired in %s\n", found.ID(), target.Path)
fmt.Fprintf(env.Out, "the number stays taken, and a reference to it now lands on the reason\n")
return OK
}
// retirePrefix retires a convention together with its file. The rules of that
// file cease to exist, so the command refuses while anything still points at
// them: a stub can stand in for a rule, but nothing stands in for a file.
func retirePrefix(env Env, d *dialogue, s *suite.Suite, given map[string]string, when string) ExitCode {
prefix := given["prefix"]
target, ok := s.ByPrefix[prefix]
if !ok {
fmt.Fprintf(env.Err, "the suite declares no live prefix %s\n", prefix)
return Usage
}
if pointing := referencesTo(s, prefix, target); len(pointing) > 0 {
fmt.Fprintf(env.Err, "%s is still pointed at, so retiring it would leave references resolving to nothing:\n", prefix)
for _, where := range pointing {
fmt.Fprintf(env.Err, " %s\n", where)
}
fmt.Fprintln(env.Err, "move or retire those rules first")
return Usage
}
if d != nil {
ok, err := d.confirm(fmt.Sprintf(
"%s goes, and %s is deleted. The prefix is never reissued. Go on?", target.Path, target.Path))
if err != nil || !ok {
fmt.Fprintln(env.Out, "nothing was written")
return Usage
}
}
source, err := os.ReadFile(s.Manifest.Path)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
source, err = manifest.RemoveEntry(source, "prefixes.live", prefix)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
note := fmt.Sprintf("%s, was %s: %s", when, target.Path, given["reason"])
source, err = manifest.AddEntry(source, "prefixes.retired", prefix, note)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
if err := os.WriteFile(s.Manifest.Path, source, 0o644); err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
if err := os.Remove(filepath.Join(s.Root, filepath.FromSlash(target.Path))); err != nil {
fmt.Fprintf(env.Err, "the manifest was updated, but the file was not removed: %s\n", err)
return Failed
}
fmt.Fprintf(env.Out, "\n%s is retired and %s is gone\n", prefix, target.Path)
if left := s.Layers(target.Front.Topic); len(left) == 1 {
fmt.Fprintf(env.Out, "the topic %q has no layer left; retire the topic too if it is done\n", target.Front.Topic)
}
return OK
}
// retireTopic sends a topic name to the retired half. A topic lives as long as
// one layer of it does, so the command refuses while any layer is left.
func retireTopic(env Env, d *dialogue, s *suite.Suite, given map[string]string, when string) ExitCode {
topic := given["topic"]
if layers := s.Layers(topic); len(layers) > 0 {
fmt.Fprintf(env.Err, "the topic %q still holds layers, and a topic lives as long as one of them does:\n", topic)
for _, d := range layers {
fmt.Fprintf(env.Err, " %s\n", d.Path)
}
fmt.Fprintln(env.Err, "retire those conventions first")
return Usage
}
if d != nil {
ok, err := d.confirm(fmt.Sprintf(
"the name %q goes to the retired half and is never handed to another topic. Go on?", topic))
if err != nil || !ok {
fmt.Fprintln(env.Out, "nothing was written")
return Usage
}
}
source, err := os.ReadFile(s.Manifest.Path)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
source, err = manifest.RemoveEntry(source, "topics.live", topic)
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
source, err = manifest.AddEntry(source, "topics.retired", topic, when+": "+given["reason"])
if err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
if err := os.WriteFile(s.Manifest.Path, source, 0o644); err != nil {
fmt.Fprintln(env.Err, err)
return Failed
}
fmt.Fprintf(env.Out, "\nthe topic %q is retired\n", topic)
return OK
}
// referencesTo finds where the rules of a prefix are pointed at from outside
// the file that owns them.
func referencesTo(s *suite.Suite, prefix string, own *doc.Document) []string {
var out []string
for _, d := range s.Docs {
if d == own {
continue
}
for _, ref := range refsOf(d, prefix) {
out = append(out, fmt.Sprintf("%s:%d %s", d.Path, ref.line, ref.text))
}
}
return out
}
type refAt struct {
line int
text string
}
var anyRefRe = regexp.MustCompile(`\b[A-Z]{4}-\d+(?:\.\d+)?`)
func refsOf(d *doc.Document, prefix string) []refAt {
var out []refAt
for n := d.Body; n <= d.Len(); n++ {
if d.Fenced(n) {
continue
}
for _, text := range anyRefRe.FindAllString(doc.StripInline(d.Line(n)), -1) {
if strings.HasPrefix(text, prefix+"-") {
out = append(out, refAt{line: n, text: text})
}
}
}
return out
}
// wrap breaks a paragraph at a width, the way the text around it is written by
// hand. A stub that runs off in one long line reads as machine-made and gets
// reflowed by the next person to touch the file.
func wrap(text string, width int) []string {
words := strings.Fields(text)
if len(words) == 0 {
return []string{""}
}
lines := []string{words[0]}
for _, w := range words[1:] {
last := len(lines) - 1
if len([]rune(lines[last]))+1+len([]rune(w)) <= width {
lines[last] += " " + w
continue
}
lines = append(lines, w)
}
return lines
}
+37
View File
@@ -64,6 +64,43 @@ func AddEntry(source []byte, table, key, value string) ([]byte, error) {
return []byte(strings.Join(out, "\n")), nil
}
// RemoveEntry drops a key from a table, leaving everything around it alone.
// Together with AddEntry it moves an entry from the live half of a section to
// the retired one, which is the only way a name ever leaves the live half.
func RemoveEntry(source []byte, table, key string) ([]byte, error) {
lines := strings.Split(string(source), "\n")
start := -1
for i, line := range lines {
if m := tableRe.FindStringSubmatch(line); m != nil && m[1] == table {
start = i
break
}
}
if start < 0 {
return nil, fmt.Errorf("the manifest holds no table [%s]", table)
}
end := len(lines)
for i := start + 1; i < len(lines); i++ {
if tableRe.MatchString(lines[i]) {
end = i
break
}
}
keys, at := tableKeys(lines, start+1, end)
for i, existing := range keys {
if existing != key {
continue
}
out := make([]string, 0, len(lines)-1)
out = append(out, lines[:at[i]]...)
out = append(out, lines[at[i]+1:]...)
return []byte(strings.Join(out, "\n")), nil
}
return nil, fmt.Errorf("the table [%s] holds no key %s", table, key)
}
// tableKeys collects the keys of a table together with the line each sits on.
func tableKeys(lines []string, from, to int) (keys []string, at []int) {
for i := from; i < to; i++ {
+3 -3
View File
@@ -91,13 +91,13 @@ func Load(root string) (*Suite, error) {
// front matter rather than the location of the file: the suite rearranges its
// taxonomy, while the front matter asserts.
func (s *Suite) findUnregistered() error {
// Only live prefixes declare a path. A retired entry records why a prefix
// left and when, not where a file lies — retiring a prefix means the file
// went with it, so one left behind is undeclared and has to say so.
declared := make(map[string]bool)
for _, path := range s.Manifest.Prefixes.Live {
declared[filepath.ToSlash(path)] = true
}
for _, path := range s.Manifest.Prefixes.Retired {
declared[filepath.ToSlash(path)] = true
}
err := filepath.WalkDir(s.Root, func(name string, entry fs.DirEntry, err error) error {
if err != nil {