Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions internal/check/paths.go
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
}
89 changes: 89 additions & 0 deletions internal/check/paths_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
45 changes: 44 additions & 1 deletion internal/invariants/invariants.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"sort"
"strings"
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
text-files 2
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
document-names-a-path-that-does-not-resolve
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
a-document-linking-a-root-file-that-is-there
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
text-files 3
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The terms this tree is published under.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
text-files 2
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion testdata/invariants/no-intended-use-notice/tree/README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# a tree

See [NOTICE.md](NOTICE.md) for the intended-use notice.
See NOTICE.md for the intended-use notice.
Loading