From 5321fba89d0f34a80a9a249dfe042459fa5a0a67 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Mon, 27 Jul 2026 10:51:46 +0300 Subject: [PATCH] =?UTF-8?q?suite=20retire=20=D0=B8=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20=D1=81=D0=BB=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=80=D1=8F=20=D0=B2=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B5=20=D0=B4=D0=BB=D1=8F=20=D1=87=D0=B8=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - suite retire снимает правило, конвенцию или тему: правило остаётся заглушкой с датой и причиной, имя уезжает в раздел выбывших, снятие с непогашенными ссылками отклоняется с перечнем мест - добавлена проверка META-30: короткое описание языка обязано называть все слова словаря, иначе читатель копии толкует их по памяти - retired-раздел манифеста больше не считается объявлением пути: снятый префикс означает, что файл ушёл вместе с ним --- internal/check/check_test.go | 17 +- internal/check/layers_test.go | 2 +- internal/check/suite.go | 40 ++++ internal/cli/cli.go | 5 +- internal/cli/prompt.go | 29 ++- internal/cli/retire_test.go | 228 ++++++++++++++++++++ internal/cli/suiteretire.go | 396 ++++++++++++++++++++++++++++++++++ internal/manifest/edit.go | 37 ++++ internal/suite/suite.go | 6 +- 9 files changed, 753 insertions(+), 7 deletions(-) create mode 100644 internal/cli/retire_test.go create mode 100644 internal/cli/suiteretire.go diff --git a/internal/check/check_test.go b/internal/check/check_test.go index 9fa7cbd..f263163 100644 --- a/internal/check/check_test.go +++ b/internal/check/check_test.go @@ -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) { diff --git a/internal/check/layers_test.go b/internal/check/layers_test.go index da35ce7..7ba0e63 100644 --- a/internal/check/layers_test.go +++ b/internal/check/layers_test.go @@ -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, diff --git a/internal/check/suite.go b/internal/check/suite.go index 883a186..9026ab7 100644 --- a/internal/check/suite.go +++ b/internal/check/suite.go @@ -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. diff --git a/internal/cli/cli.go b/internal/cli/cli.go index d251a8c..91c86cd 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -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 diff --git a/internal/cli/prompt.go b/internal/cli/prompt.go index 8b05211..74c0012 100644 --- a/internal/cli/prompt.go +++ b/internal/cli/prompt.go @@ -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) diff --git a/internal/cli/retire_test.go b/internal/cli/retire_test.go new file mode 100644 index 0000000..d2ec9b9 --- /dev/null +++ b/internal/cli/retire_test.go @@ -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") + } +} diff --git a/internal/cli/suiteretire.go b/internal/cli/suiteretire.go new file mode 100644 index 0000000..6379478 --- /dev/null +++ b/internal/cli/suiteretire.go @@ -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 +} diff --git a/internal/manifest/edit.go b/internal/manifest/edit.go index 5333b37..688714d 100644 --- a/internal/manifest/edit.go +++ b/internal/manifest/edit.go @@ -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++ { diff --git a/internal/suite/suite.go b/internal/suite/suite.go index 4ce9904..8e76b1b 100644 --- a/internal/suite/suite.go +++ b/internal/suite/suite.go @@ -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 {