From 2639376a04a9771cf4d7fe0d99c08db676cd5b9f Mon Sep 17 00:00:00 2001 From: Nils Lehnen <30603423+iderex@users.noreply.github.com> Date: Sat, 22 Aug 2026 06:03:39 +0200 Subject: [PATCH] Read a root file a document links, which the paths leg could not see Closes #162. The invariants leg holds this repository's own documents to the paths they name, and it could not see a file at the root. pathInProse requires a leading segment naming a directory of this tree, so LICENSE, README.md, NOTICE.md, DCO, go.mod and the dotfiles were never read as paths at all, wherever a document wrote them. The leg says at itself that it holds documents to the paths they name and said nothing about the paths it could not see, so a green run over README.md read as every pointer in it resolving, and it was not that. That is the shape of a removal already on this branch's history. README.md linked LICENSE, both left the default branch in one commit, and had only the link survived nothing here would have said so. What separates a path from a word at the root is the parentheses. A name inside a link is somebody saying they expect a file to be there; the same name in a sentence is a word, and a pattern general enough to read A-FILE.md as a path also reads a capitalised word, a version string and the last segment of a URL as one. So the leg gains a second reading rather than a widened pattern: 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 to the reading that was already there. Joining to the document's own directory rather than to the root is what the tree requires: docs/privacy.md links supply-chain.md and means docs/supply-chain.md. Both readings feed one refusal site, so a document that writes a path in a sentence and links it as well is one dead pointer and is refused once. Proved by deleting it. With the link reading removed and everything else in place: go test ./internal/invariants -count=1 --- FAIL: TestCases/a-document-linking-a-root-file-that-is-not-there invariants_test.go:159: expected refusal not produced: document-names-a-path-that-does-not-resolve Its near neighbour is the same tree with the file added, and the third case is the same names in a sentence rather than in a link. Putting the parentheses around one of those names is what turns that case red, so the two differ by the link alone. One fixture had to change to keep proving what it proves. no-intended-use-notice is a tree with no NOTICE.md whose readme pointed at it, and that readme now names the notice without linking it. The tree it described trips two rules once this one can see it, which is correct behaviour and not something a single-property fixture can carry. The readme half of the notice leg asks that the readme names the notice rather than links it, so the case is unchanged in what it exercises. What this does not do. A link target carrying a directory is left to the pattern that was already there, which reads such a path wherever a document writes it out rather than resolving it against the document's own directory. A target with a scheme in it is somebody else's server and nothing here can say whether a file on one exists. Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com> --- internal/check/paths.go | 50 +++++++++++ internal/check/paths_test.go | 89 +++++++++++++++++++ internal/invariants/invariants.go | 45 +++++++++- .../expected | 1 + .../expected-refusals | 1 + .../near-neighbour | 1 + .../tree/NOTICE.md | 5 ++ .../tree/README.md | 4 + .../expected | 1 + .../expected-refusals | 0 .../tree/LICENSE | 1 + .../tree/NOTICE.md | 5 ++ .../tree/README.md | 4 + .../expected | 1 + .../expected-refusals | 0 .../tree/NOTICE.md | 5 ++ .../tree/README.md | 5 ++ .../no-intended-use-notice/tree/README.md | 2 +- 18 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 internal/check/paths_test.go create mode 100644 testdata/invariants/a-document-linking-a-root-file-that-is-not-there/expected create mode 100644 testdata/invariants/a-document-linking-a-root-file-that-is-not-there/expected-refusals create mode 100644 testdata/invariants/a-document-linking-a-root-file-that-is-not-there/near-neighbour create mode 100644 testdata/invariants/a-document-linking-a-root-file-that-is-not-there/tree/NOTICE.md create mode 100644 testdata/invariants/a-document-linking-a-root-file-that-is-not-there/tree/README.md create mode 100644 testdata/invariants/a-document-linking-a-root-file-that-is-there/expected create mode 100644 testdata/invariants/a-document-linking-a-root-file-that-is-there/expected-refusals create mode 100644 testdata/invariants/a-document-linking-a-root-file-that-is-there/tree/LICENSE create mode 100644 testdata/invariants/a-document-linking-a-root-file-that-is-there/tree/NOTICE.md create mode 100644 testdata/invariants/a-document-linking-a-root-file-that-is-there/tree/README.md create mode 100644 testdata/invariants/a-root-file-name-in-a-sentence-rather-than-a-link/expected create mode 100644 testdata/invariants/a-root-file-name-in-a-sentence-rather-than-a-link/expected-refusals create mode 100644 testdata/invariants/a-root-file-name-in-a-sentence-rather-than-a-link/tree/NOTICE.md create mode 100644 testdata/invariants/a-root-file-name-in-a-sentence-rather-than-a-link/tree/README.md 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.