diff --git a/internal/check/paths.go b/internal/check/paths.go index 50640d9..3658b69 100644 --- a/internal/check/paths.go +++ b/internal/check/paths.go @@ -1,5 +1,10 @@ package check +import ( + "regexp" + "strings" +) + // PathsNamedInProse returns the repository-relative paths a text names, in the // order it names them, each once. It is the same reading the record checks do, // exported so that a second reader of this repository's prose is one caller of @@ -13,3 +18,48 @@ package check func PathsNamedInProse(text string) []string { return pathsNamedInProse(text) } + +// linkTarget matches the target of a markdown inline link, which is the part +// between the parentheses. It stops at whitespace and at a parenthesis, so a +// sentence carrying an ordinary parenthetical is not read as a link. +var linkTarget = regexp.MustCompile(`\]\(([^()\s]+)\)`) + +// LinkTargetsWithoutADirectory returns the markdown link targets a text carries +// that name a file beside the document rather than under a directory, in the +// order it names them, each once. A caller joins each one to the directory its +// document sits in and gets a repository-relative path. +// +// This is the reading PathsNamedInProse cannot make and is not widened to make. +// That pattern requires a leading segment naming a directory of this tree, +// which is what stops it reading a capitalised word, a version string or the +// last segment of a URL as a path, and a file at the root has no such segment +// in front of it. Widening the pattern to reach one would refuse honest prose, +// and a check that refuses honest prose is a check somebody switches off. +// +// The parentheses are what carry the intent instead. A name inside a link is +// somebody saying they expect a file to be there; the same name in a sentence +// is a word. So the target of a link is resolved and a bare word is not, and +// the two readings sit side by side rather than one replacing the other. +// +// What it deliberately does not return. A target carrying a directory is left +// to the pattern above, which reads it wherever the document writes it out. A +// target with a scheme in it is somebody else's server and nothing here can say +// whether a file on one exists. A target beginning with a fragment is a place +// inside the same page and names no file at all. +func LinkTargetsWithoutADirectory(text string) []string { + var named []string + seen := make(map[string]bool) + + for _, match := range linkTarget.FindAllStringSubmatch(text, -1) { + target := match[1] + if cut := strings.IndexAny(target, "#?"); cut >= 0 { + target = target[:cut] + } + if target == "" || strings.ContainsAny(target, "/:\\") || seen[target] { + continue + } + seen[target] = true + named = append(named, target) + } + return named +} diff --git a/internal/check/paths_test.go b/internal/check/paths_test.go new file mode 100644 index 0000000..9afef0c --- /dev/null +++ b/internal/check/paths_test.go @@ -0,0 +1,89 @@ +package check + +import ( + "strings" + "testing" +) + +// TestWhatCountsAsALinkToAFileBesideTheDocument exercises the reader directly, +// because the invariants fixtures cannot reach most of it. A fixture is a tree +// and a verdict, so it proves that a link to a missing file is refused and a +// sentence naming the same file is not, and it says nothing about a target +// carrying a scheme, a fragment or a directory. Those arms all produce the same +// outcome as an arm that stopped working, which is silence, so this is what +// notices one going quiet. +func TestWhatCountsAsALinkToAFileBesideTheDocument(t *testing.T) { + tests := []struct { + name string + text string + want []string + }{ + { + name: "an ordinary link", + text: "See [the notice](NOTICE.md) before you start.", + want: []string{"NOTICE.md"}, + }, + { + name: "a name with no extension", + text: "The text is in [DCO](DCO) at the root.", + want: []string{"DCO"}, + }, + { + name: "a fragment on the end", + text: "See [the exit codes](README.md#exit-codes).", + want: []string{"README.md"}, + }, + { + name: "the same target twice", + text: "[one](NOTICE.md) and [again](NOTICE.md)", + want: []string{"NOTICE.md"}, + }, + { + name: "two targets keep the order they were written in", + text: "[first](NOTICE.md) then [second](LICENSE)", + want: []string{"NOTICE.md", "LICENSE"}, + }, + + { + name: "the same name in a sentence rather than in a link", + text: "There is no LICENSE in this tree yet.", + }, + { + name: "a target under a directory is the other reader's", + text: "See [the record](docs/decisions/0002-repository-layout.md).", + }, + { + name: "a target on somebody else's server", + text: "See [the page](https://example.invalid/NOTICE.md).", + }, + { + name: "an address rather than a file", + text: "Write to [the list](mailto:nobody@example.invalid).", + }, + { + name: "a place inside the same page", + text: "See [further down](#what-was-run).", + }, + { + name: "a windows separator is a directory too", + text: `See [the record](docs\notes.md).`, + }, + { + name: "an empty target", + text: "See [nothing]().", + }, + { + name: "a parenthetical in a sentence after a bracket", + text: "The answer was no [and it stayed no] (which was the useful part).", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := LinkTargetsWithoutADirectory(tc.text) + if strings.Join(got, ",") != strings.Join(tc.want, ",") { + t.Errorf("read %v, want %v", got, tc.want) + } + }) + } +} diff --git a/internal/invariants/invariants.go b/internal/invariants/invariants.go index fd7d11d..5d77904 100644 --- a/internal/invariants/invariants.go +++ b/internal/invariants/invariants.go @@ -31,6 +31,7 @@ import ( "fmt" "io/fs" "os" + "path" "path/filepath" "sort" "strings" @@ -460,6 +461,15 @@ func lineOf(text string, offset int) int { // function in the record package, rather than through a second copy of the // pattern. Two copies of a rule drift, and the one that drifts is the copy // nobody is looking at. +// +// A file at the root is reached by the second reading rather than by that one. +// The prose pattern requires a leading segment naming a directory of this tree, +// so LICENSE, README.md, DCO and the rest are outside it wherever a document +// writes them, and this leg was exact on a path one directory deep and blind on +// a path at the root. What separates the two cases is a link: a name inside one +// is somebody saying they expect a file to be there, and a name in a sentence is +// a word. So a link target with no directory in it is joined to the directory +// its own document sits in and resolved, and a bare word is left alone. func pathsLeg(root string, texts []textFile) (Leg, []Refusal) { var documents []textFile for _, file := range texts { @@ -472,7 +482,7 @@ func pathsLeg(root string, texts []textFile) (Leg, []Refusal) { var refusals []Refusal for _, document := range documents { - for _, named := range check.PathsNamedInProse(document.text) { + for _, named := range pathsNamedIn(document) { if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(named))); err == nil { continue } @@ -486,6 +496,39 @@ func pathsLeg(root string, texts []textFile) (Leg, []Refusal) { return leg, refusals } +// pathsNamedIn returns every repository-relative path one document names, by +// both readings, each once. The two readings are joined here rather than +// refused separately so that one property is produced at one site: a document +// that writes a path in a sentence and links it as well is one dead pointer and +// is worth one refusal. +// +// A link target is relative to the document that carries it, which is why the +// join uses the document's own directory and not the root. docs/privacy.md +// links supply-chain.md and means docs/supply-chain.md; resolving that against +// the root would refuse the file that is there and pass the one that is not. +func pathsNamedIn(document textFile) []string { + named := check.PathsNamedInProse(document.text) + + directory := path.Dir(document.relative) + seen := make(map[string]bool, len(named)) + for _, already := range named { + seen[already] = true + } + + for _, target := range check.LinkTargetsWithoutADirectory(document.text) { + beside := target + if directory != "." { + beside = directory + "/" + target + } + if seen[beside] { + continue + } + seen[beside] = true + named = append(named, beside) + } + return named +} + // isOwnDocument says whether a path is one of this repository's own documents. // A file at the root is one; anything under docs/ is one; nothing else is, // because a path named in a comment inside the runner is held by the compiler diff --git a/testdata/invariants/a-document-linking-a-root-file-that-is-not-there/expected b/testdata/invariants/a-document-linking-a-root-file-that-is-not-there/expected new file mode 100644 index 0000000..ce246d5 --- /dev/null +++ b/testdata/invariants/a-document-linking-a-root-file-that-is-not-there/expected @@ -0,0 +1 @@ +text-files 2 diff --git a/testdata/invariants/a-document-linking-a-root-file-that-is-not-there/expected-refusals b/testdata/invariants/a-document-linking-a-root-file-that-is-not-there/expected-refusals new file mode 100644 index 0000000..159931d --- /dev/null +++ b/testdata/invariants/a-document-linking-a-root-file-that-is-not-there/expected-refusals @@ -0,0 +1 @@ +document-names-a-path-that-does-not-resolve diff --git a/testdata/invariants/a-document-linking-a-root-file-that-is-not-there/near-neighbour b/testdata/invariants/a-document-linking-a-root-file-that-is-not-there/near-neighbour new file mode 100644 index 0000000..16847ad --- /dev/null +++ b/testdata/invariants/a-document-linking-a-root-file-that-is-not-there/near-neighbour @@ -0,0 +1 @@ +a-document-linking-a-root-file-that-is-there diff --git a/testdata/invariants/a-document-linking-a-root-file-that-is-not-there/tree/NOTICE.md b/testdata/invariants/a-document-linking-a-root-file-that-is-not-there/tree/NOTICE.md new file mode 100644 index 0000000..e3cfa99 --- /dev/null +++ b/testdata/invariants/a-document-linking-a-root-file-that-is-not-there/tree/NOTICE.md @@ -0,0 +1,5 @@ +# Notice + +This software is developed for lawful use. Operators and users are responsible +for making sure that their deployment and use comply with the laws that apply +to them. diff --git a/testdata/invariants/a-document-linking-a-root-file-that-is-not-there/tree/README.md b/testdata/invariants/a-document-linking-a-root-file-that-is-not-there/tree/README.md new file mode 100644 index 0000000..462f3c9 --- /dev/null +++ b/testdata/invariants/a-document-linking-a-root-file-that-is-not-there/tree/README.md @@ -0,0 +1,4 @@ +# a tree + +See [NOTICE.md](NOTICE.md) for the intended-use notice, and the terms in +[LICENSE](LICENSE) before you reuse any of it. diff --git a/testdata/invariants/a-document-linking-a-root-file-that-is-there/expected b/testdata/invariants/a-document-linking-a-root-file-that-is-there/expected new file mode 100644 index 0000000..733c34f --- /dev/null +++ b/testdata/invariants/a-document-linking-a-root-file-that-is-there/expected @@ -0,0 +1 @@ +text-files 3 diff --git a/testdata/invariants/a-document-linking-a-root-file-that-is-there/expected-refusals b/testdata/invariants/a-document-linking-a-root-file-that-is-there/expected-refusals new file mode 100644 index 0000000..e69de29 diff --git a/testdata/invariants/a-document-linking-a-root-file-that-is-there/tree/LICENSE b/testdata/invariants/a-document-linking-a-root-file-that-is-there/tree/LICENSE new file mode 100644 index 0000000..ba94d74 --- /dev/null +++ b/testdata/invariants/a-document-linking-a-root-file-that-is-there/tree/LICENSE @@ -0,0 +1 @@ +The terms this tree is published under. diff --git a/testdata/invariants/a-document-linking-a-root-file-that-is-there/tree/NOTICE.md b/testdata/invariants/a-document-linking-a-root-file-that-is-there/tree/NOTICE.md new file mode 100644 index 0000000..e3cfa99 --- /dev/null +++ b/testdata/invariants/a-document-linking-a-root-file-that-is-there/tree/NOTICE.md @@ -0,0 +1,5 @@ +# Notice + +This software is developed for lawful use. Operators and users are responsible +for making sure that their deployment and use comply with the laws that apply +to them. diff --git a/testdata/invariants/a-document-linking-a-root-file-that-is-there/tree/README.md b/testdata/invariants/a-document-linking-a-root-file-that-is-there/tree/README.md new file mode 100644 index 0000000..462f3c9 --- /dev/null +++ b/testdata/invariants/a-document-linking-a-root-file-that-is-there/tree/README.md @@ -0,0 +1,4 @@ +# a tree + +See [NOTICE.md](NOTICE.md) for the intended-use notice, and the terms in +[LICENSE](LICENSE) before you reuse any of it. diff --git a/testdata/invariants/a-root-file-name-in-a-sentence-rather-than-a-link/expected b/testdata/invariants/a-root-file-name-in-a-sentence-rather-than-a-link/expected new file mode 100644 index 0000000..ce246d5 --- /dev/null +++ b/testdata/invariants/a-root-file-name-in-a-sentence-rather-than-a-link/expected @@ -0,0 +1 @@ +text-files 2 diff --git a/testdata/invariants/a-root-file-name-in-a-sentence-rather-than-a-link/expected-refusals b/testdata/invariants/a-root-file-name-in-a-sentence-rather-than-a-link/expected-refusals new file mode 100644 index 0000000..e69de29 diff --git a/testdata/invariants/a-root-file-name-in-a-sentence-rather-than-a-link/tree/NOTICE.md b/testdata/invariants/a-root-file-name-in-a-sentence-rather-than-a-link/tree/NOTICE.md new file mode 100644 index 0000000..e3cfa99 --- /dev/null +++ b/testdata/invariants/a-root-file-name-in-a-sentence-rather-than-a-link/tree/NOTICE.md @@ -0,0 +1,5 @@ +# Notice + +This software is developed for lawful use. Operators and users are responsible +for making sure that their deployment and use comply with the laws that apply +to them. diff --git a/testdata/invariants/a-root-file-name-in-a-sentence-rather-than-a-link/tree/README.md b/testdata/invariants/a-root-file-name-in-a-sentence-rather-than-a-link/tree/README.md new file mode 100644 index 0000000..1c6eb63 --- /dev/null +++ b/testdata/invariants/a-root-file-name-in-a-sentence-rather-than-a-link/tree/README.md @@ -0,0 +1,5 @@ +# a tree + +See [NOTICE.md](NOTICE.md) for the intended-use notice. There is no LICENSE in +this tree yet and no CHANGELOG.md either, and this sentence names both of them +without pointing at either. diff --git a/testdata/invariants/no-intended-use-notice/tree/README.md b/testdata/invariants/no-intended-use-notice/tree/README.md index 315a136..2b13dec 100644 --- a/testdata/invariants/no-intended-use-notice/tree/README.md +++ b/testdata/invariants/no-intended-use-notice/tree/README.md @@ -1,3 +1,3 @@ # a tree -See [NOTICE.md](NOTICE.md) for the intended-use notice. +See NOTICE.md for the intended-use notice.