suite retire и проверка словаря в документе для читателя
- suite retire снимает правило, конвенцию или тему: правило остаётся заглушкой с датой и причиной, имя уезжает в раздел выбывших, снятие с непогашенными ссылками отклоняется с перечнем мест - добавлена проверка META-30: короткое описание языка обязано называть все слова словаря, иначе читатель копии толкует их по памяти - retired-раздел манифеста больше не считается объявлением пути: снятый префикс означает, что файл ушёл вместе с ним
This commit is contained in:
@@ -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
|
// 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
|
// the file. An empty string means "no such file": that is how a test drops a
|
||||||
// file the base fixture provides.
|
// file the base fixture provides.
|
||||||
@@ -67,7 +76,7 @@ func base() files {
|
|||||||
return files{
|
return files{
|
||||||
"suite.toml": baseManifest,
|
"suite.toml": baseManifest,
|
||||||
"LANGUAGE.md": "# Язык конвенций\n\nОписание языка.\n",
|
"LANGUAGE.md": "# Язык конвенций\n\nОписание языка.\n",
|
||||||
"READING.md": "# Как читать конвенцию\n\nКоротко.\n",
|
"READING.md": reading,
|
||||||
"conventions/time.md": baseTime,
|
"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"
|
f["conventions/rules.md"] = "---\nprefix: RULE\n---\n\n# Правила\n\n" + versionLine + "\n"
|
||||||
},
|
},
|
||||||
want: "more than one document without a topic",
|
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",
|
name: "document without a topic carries layer keys",
|
||||||
setup: func(f files) {
|
setup: func(f files) {
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ func layered() files {
|
|||||||
return files{
|
return files{
|
||||||
"suite.toml": layeredManifest,
|
"suite.toml": layeredManifest,
|
||||||
"LANGUAGE.md": "# Язык конвенций\n\nОписание языка.\n",
|
"LANGUAGE.md": "# Язык конвенций\n\nОписание языка.\n",
|
||||||
"READING.md": "# Как читать конвенцию\n\nКоротко.\n",
|
"READING.md": reading,
|
||||||
"conventions/arch/time.md": archTime,
|
"conventions/arch/time.md": archTime,
|
||||||
"conventions/lang/go/time.md": goTime,
|
"conventions/lang/go/time.md": goTime,
|
||||||
"conventions/arch/logging.md": archLogging,
|
"conventions/arch/logging.md": archLogging,
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package check
|
package check
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.vakhrushev.me/av/convy/internal/manifest"
|
"git.vakhrushev.me/av/convy/internal/manifest"
|
||||||
"git.vakhrushev.me/av/convy/internal/suite"
|
"git.vakhrushev.me/av/convy/internal/suite"
|
||||||
@@ -15,6 +18,7 @@ func Suite(s *suite.Suite) *Report {
|
|||||||
checkTopicNames(s, rep)
|
checkTopicNames(s, rep)
|
||||||
checkBaseLayers(s, rep)
|
checkBaseLayers(s, rep)
|
||||||
checkSelfGoverning(s, rep)
|
checkSelfGoverning(s, rep)
|
||||||
|
checkReadingVocabulary(s, rep)
|
||||||
|
|
||||||
for _, d := range s.Docs {
|
for _, d := range s.Docs {
|
||||||
checkForm(s, d, rep)
|
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
|
// 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
|
// 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.
|
// second such layer would mean two base texts in one assembled file.
|
||||||
|
|||||||
+4
-1
@@ -63,7 +63,7 @@ func Run(env Env, args []string) ExitCode {
|
|||||||
|
|
||||||
func runSuite(env Env, args []string) ExitCode {
|
func runSuite(env Env, args []string) ExitCode {
|
||||||
if len(args) == 0 {
|
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
|
return Usage
|
||||||
}
|
}
|
||||||
switch args[0] {
|
switch args[0] {
|
||||||
@@ -73,6 +73,8 @@ func runSuite(env Env, args []string) ExitCode {
|
|||||||
return runSuiteInit(env, args[1:])
|
return runSuiteInit(env, args[1:])
|
||||||
case "add":
|
case "add":
|
||||||
return runSuiteAdd(env, args[1:])
|
return runSuiteAdd(env, args[1:])
|
||||||
|
case "retire":
|
||||||
|
return runSuiteRetire(env, args[1:])
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(env.Err, "unknown subcommand %q for convy suite\n", args[0])
|
fmt.Fprintf(env.Err, "unknown subcommand %q for convy suite\n", args[0])
|
||||||
return Usage
|
return Usage
|
||||||
@@ -92,6 +94,7 @@ In a project:
|
|||||||
In a suite:
|
In a suite:
|
||||||
convy suite init start a suite: a directory and a manifest
|
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 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
|
convy suite check suite integrity: prefixes, topics, axes, links, form
|
||||||
|
|
||||||
The commands that change something run in two modes. Bare, they ask for every
|
The commands that change something run in two modes. Bare, they ask for every
|
||||||
|
|||||||
+28
-1
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,6 +30,8 @@ type Field struct {
|
|||||||
Default string
|
Default string
|
||||||
// Optional fields may stay empty.
|
// Optional fields may stay empty.
|
||||||
Optional bool
|
Optional bool
|
||||||
|
// Options, when given, are the only answers accepted.
|
||||||
|
Options []string
|
||||||
// Check validates an answer; it is applied in both modes.
|
// Check validates an answer; it is applied in both modes.
|
||||||
Check func(string) error
|
Check func(string) error
|
||||||
}
|
}
|
||||||
@@ -54,6 +57,11 @@ func (d *dialogue) ask(f Field) (string, error) {
|
|||||||
}
|
}
|
||||||
prompt := f.Ask
|
prompt := f.Ask
|
||||||
switch {
|
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 != "":
|
case f.Default != "":
|
||||||
prompt += fmt.Sprintf(" [%s]", f.Default)
|
prompt += fmt.Sprintf(" [%s]", f.Default)
|
||||||
case f.Optional:
|
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.
|
// validate applies a field's check to a value that is not empty.
|
||||||
func validate(f Field, value string) error {
|
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 nil
|
||||||
}
|
}
|
||||||
return f.Check(value)
|
return f.Check(value)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -64,6 +64,43 @@ func AddEntry(source []byte, table, key, value string) ([]byte, error) {
|
|||||||
return []byte(strings.Join(out, "\n")), nil
|
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.
|
// 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) {
|
func tableKeys(lines []string, from, to int) (keys []string, at []int) {
|
||||||
for i := from; i < to; i++ {
|
for i := from; i < to; i++ {
|
||||||
|
|||||||
@@ -91,13 +91,13 @@ func Load(root string) (*Suite, error) {
|
|||||||
// front matter rather than the location of the file: the suite rearranges its
|
// front matter rather than the location of the file: the suite rearranges its
|
||||||
// taxonomy, while the front matter asserts.
|
// taxonomy, while the front matter asserts.
|
||||||
func (s *Suite) findUnregistered() error {
|
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)
|
declared := make(map[string]bool)
|
||||||
for _, path := range s.Manifest.Prefixes.Live {
|
for _, path := range s.Manifest.Prefixes.Live {
|
||||||
declared[filepath.ToSlash(path)] = true
|
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 {
|
err := filepath.WalkDir(s.Root, func(name string, entry fs.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user