From 9f44ca1ec0c066a3685d3e77af3080b313f362b0 Mon Sep 17 00:00:00 2001 From: Akeem Jenkins Date: Wed, 2 Sep 2026 23:21:56 -0600 Subject: [PATCH 1/2] fix: usage errors for stray flags, named parse failures, duplicate sources, and spec-aligned broken links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four bug reports against v0.4.0, each with a regression test written before the fix. - index, list, graph, show, backlinks, search and init now reject any dash-prefixed argument as a usage error (exit 4, code 400) instead of treating it as a bundle path and failing as I/O (#31). - A markdown file that is not a concept now fails as a validation error that names the file and the reason, with a hint pointing at OKF §11, instead of an anonymous "load bundle " I/O error. The loader returns a typed ParseError, I/O and internal envelopes fold the wrapped cause into the message, hidden .md files are skipped like hidden directories, and index loads the bundle before writing so it cannot half-index and then miscount (#27). - Two new warning rules: okf/sources/id-duplicate for a repeated sources[].id and okf/sources/footnote-duplicate for a footnote defined twice. Both were invisible to the join rule by construction (#29). - okf/links/broken is a warning, not an error: OKF §6.1 and §11 say consumers must tolerate broken cross-links and must not reject a bundle for them, so they no longer flip valid or the exit code. Footnote scanning now masks fenced code blocks and inline code spans, so prose that shows [^id] as an example is no longer a dangling reference (#26). Closes #26 Closes #27 Closes #29 Closes #31 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DwzDaurgXen8UC19AeN6tU --- README.md | 6 +- cmd/okf/main.go | 109 +++++++++++---- cmd/okf/main_test.go | 216 +++++++++++++++++++++++++++++ internal/bundle/bundle.go | 33 ++++- internal/bundle/bundle_test.go | 67 +++++++++ internal/cerr/errors.go | 24 +++- internal/cerr/errors_test.go | 38 +++++ internal/validate/rules.go | 6 +- internal/validate/sarif_test.go | 2 + internal/validate/v02.go | 124 ++++++++++++++++- internal/validate/v02_test.go | 122 ++++++++++++++++ internal/validate/validate.go | 9 +- internal/validate/validate_test.go | 34 ++++- 13 files changed, 742 insertions(+), 48 deletions(-) create mode 100644 cmd/okf/main_test.go diff --git a/README.md b/README.md index c270e4d..b0c2204 100644 --- a/README.md +++ b/README.md @@ -87,11 +87,13 @@ The JSON error envelope makes this trivial to automate: "kind": "validation", "code": 400, "reason": "validationError", - "message": "broken link: [Users] -> users.md (concept tables/users not found)" + "message": "load bundle ./bundles/ga4: parse notes/README.md: no YAML frontmatter block found" } } ``` +Broken cross-links are reported as warnings, never errors: OKF §6.1 says consumers must tolerate them because a link may point at not-yet-written knowledge, so they do not flip `valid` or the exit code. + Every finding also carries a stable rule ID (`okf//`, e.g. `okf/links/broken`, `okf/frontmatter/type-required`), so automation can suppress or route specific rules instead of matching message text. #### CI code scanning @@ -141,7 +143,7 @@ Progressive disclosure (index.md) lets the agent navigate level by level instead | Code | Meaning | |------|---------| | 0 | success | -| 1 | validation error (spec violation, broken link, concept not found) | +| 1 | validation error (spec violation, non-concept markdown file, concept not found) | | 2 | filesystem or I/O error | | 3 | internal error (unexpected) | | 4 | usage error (missing args, unknown command) | diff --git a/cmd/okf/main.go b/cmd/okf/main.go index 25f7bff..bbaa11d 100644 --- a/cmd/okf/main.go +++ b/cmd/okf/main.go @@ -10,6 +10,7 @@ package main import ( "encoding/json" + "errors" "fmt" "os" "strings" @@ -94,7 +95,7 @@ Commands: Exit codes: 0 success - 1 validation error (spec violation, broken link, bad input) + 1 validation error (spec violation, bad input) 2 filesystem or I/O error 3 internal error (unexpected) 4 usage error (missing args, unknown command) @@ -130,13 +131,47 @@ func exitErr(err error) { os.Exit(e.ExitCode()) } -func mustBundle(args []string) *bundle.Bundle { +// rejectFlags returns a usage error when any argument looks like a flag. +// Commands that take no flags call it so a mistyped or unsupported flag is +// reported as a usage mistake rather than being treated as a bundle path +// and failing as I/O (issue #31). +func rejectFlags(cmd string, args []string) *cerr.Error { + for _, a := range args { + if strings.HasPrefix(a, "-") { + return cerr.Usage("unknown %s flag: %s", cmd, a) + } + } + return nil +} + +// loadBundle loads the bundle at path and maps loader failures onto the +// error kinds the CLI documents. A .md file that is not a concept violates +// OKF §11, so it is a validation error that names the file (issue #27); +// anything else is a filesystem error. +func loadBundle(path string) (*bundle.Bundle, *cerr.Error) { + b, err := bundle.Load(path) + if err == nil { + return b, nil + } + var pe *bundle.ParseError + if errors.As(err, &pe) { + e := cerr.Validation("load bundle %s: %v", path, err) + e.Hint = "every non-reserved .md file in a bundle needs a YAML frontmatter block (OKF §11); add frontmatter to " + pe.Path + " or move it out of the bundle" + return nil, e + } + return nil, cerr.IO(err, "load bundle %s", path) +} + +func mustBundle(cmd string, args []string) *bundle.Bundle { + if err := rejectFlags(cmd, args); err != nil { + exitErr(err) + } if len(args) == 0 { exitErr(cerr.Usage("bundle path required")) } - b, err := bundle.Load(args[0]) + b, err := loadBundle(args[0]) if err != nil { - exitErr(cerr.IO(err, "load bundle %s", args[0])) + exitErr(err) } return b } @@ -180,9 +215,9 @@ func runValidate(args []string, strict bool) { exitErr(cerr.Usage("--format must be json or sarif, got %q", format)) } - b, err := bundle.Load(bundlePath) - if err != nil { - exitErr(cerr.IO(err, "load bundle %s", bundlePath)) + b, lerr := loadBundle(bundlePath) + if lerr != nil { + exitErr(lerr) } r := validate.Validate(b) @@ -237,10 +272,19 @@ func commandName(strict bool) string { // --- index --- func runIndex(args []string) { + if err := rejectFlags("index", args); err != nil { + exitErr(err) + } if len(args) == 0 { exitErr(cerr.Usage("bundle path required")) } root := args[0] + // Load before writing anything so a bundle that does not parse (issue + // #27) is reported by name instead of being half-indexed and then + // silently miscounted below. + if _, lerr := loadBundle(root); lerr != nil { + exitErr(lerr) + } if err := index.Generate(root); err != nil { exitErr(cerr.IO(err, "generate index in %s", root)) } @@ -248,11 +292,12 @@ func runIndex(args []string) { // Collect generated index file paths. var indexFiles []string b, err := bundle.Load(root) - if err == nil { - for _, r := range b.Reserved { - if r.ID == "index" || strings.HasSuffix(r.ID, "/index") { - indexFiles = append(indexFiles, r.Path) - } + if err != nil { + exitErr(cerr.IO(err, "reload bundle %s after indexing", root)) + } + for _, r := range b.Reserved { + if r.ID == "index" || strings.HasSuffix(r.ID, "/index") { + indexFiles = append(indexFiles, r.Path) } } @@ -267,7 +312,7 @@ func runIndex(args []string) { // --- graph --- func runGraph(args []string) { - b := mustBundle(args) + b := mustBundle("graph", args) g := graph.Build(b) s := g.Stats() @@ -301,7 +346,7 @@ func runGraph(args []string) { // --- list --- func runList(args []string) { - b := mustBundle(args) + b := mustBundle("list", args) concepts := make([]map[string]string, 0, len(b.Concepts)) for _, c := range b.Concepts { @@ -325,10 +370,13 @@ func runList(args []string) { // --- show --- func runShow(args []string) { + if err := rejectFlags("show", args); err != nil { + exitErr(err) + } if len(args) < 2 { exitErr(cerr.Usage("usage: okf show ")) } - b := mustBundle(args[:1]) + b := mustBundle("show", args[:1]) c, err := show.Show(b, args[1]) if err != nil { exitErr(cerr.Validation("%s", err)) @@ -431,6 +479,9 @@ func runSearch(args []string) { if len(args) == 0 { exitErr(cerr.Usage("usage: okf search [--tag ] [--type ] [--text ]")) } + if err := rejectFlags("search", args[:1]); err != nil { + exitErr(err) + } bundlePath := args[0] rest := args[1:] @@ -460,9 +511,9 @@ func runSearch(args []string) { } } - b, err := bundle.Load(bundlePath) - if err != nil { - exitErr(cerr.IO(err, "load bundle %s", bundlePath)) + b, lerr := loadBundle(bundlePath) + if lerr != nil { + exitErr(lerr) } results := search.Search(b, f) @@ -487,6 +538,9 @@ func runSearch(args []string) { // --- init --- func runInit(args []string) { + if err := rejectFlags("init", args); err != nil { + exitErr(err) + } if len(args) == 0 { exitErr(cerr.Usage("usage: okf init ")) } @@ -512,10 +566,13 @@ func runInit(args []string) { // --- backlinks --- func runBacklinks(args []string) { + if err := rejectFlags("backlinks", args); err != nil { + exitErr(err) + } if len(args) < 2 { exitErr(cerr.Usage("usage: okf backlinks ")) } - b := mustBundle(args[:1]) + b := mustBundle("backlinks", args[:1]) conceptID := args[1] links := backlinks.Backlinks(b, conceptID) @@ -608,7 +665,7 @@ func allSchemaCommands() []schemaCommand { Long: "Creates a bundle directory with standard subdirectories (tables, datasets, playbooks), a root index.md, and a .gitignore. Fails if the directory already exists.", Args: []schemaArg{{Name: "bundle", Required: true}}, Stdout: "json", - ExitCodes: []int{cerr.ExitCodeOK, cerr.ExitCodeIO, cerr.ExitCodeUsage}, + ExitCodes: []int{cerr.ExitCodeOK, cerr.ExitCodeValidation, cerr.ExitCodeIO, cerr.ExitCodeUsage}, }, { Name: "validate", @@ -631,7 +688,7 @@ func allSchemaCommands() []schemaCommand { }, Args: []schemaArg{{Name: "bundle", Required: true}}, Stdout: "json|sarif", - ExitCodes: []int{cerr.ExitCodeOK, cerr.ExitCodeIO, cerr.ExitCodeUsage}, + ExitCodes: []int{cerr.ExitCodeOK, cerr.ExitCodeValidation, cerr.ExitCodeIO, cerr.ExitCodeUsage}, }, { Name: "index", @@ -639,7 +696,7 @@ func allSchemaCommands() []schemaCommand { Long: "Writes index.md into every directory containing concept documents, providing progressive disclosure per OKF spec §6.", Args: []schemaArg{{Name: "bundle", Required: true}}, Stdout: "json", - ExitCodes: []int{cerr.ExitCodeOK, cerr.ExitCodeIO, cerr.ExitCodeUsage}, + ExitCodes: []int{cerr.ExitCodeOK, cerr.ExitCodeValidation, cerr.ExitCodeIO, cerr.ExitCodeUsage}, }, { Name: "list", @@ -647,7 +704,7 @@ func allSchemaCommands() []schemaCommand { Long: "Lists every concept document with its ID, type, title, lifecycle status, and trust tier (unverified, machine-confirmed, or human-reviewed).", Args: []schemaArg{{Name: "bundle", Required: true}}, Stdout: "json", - ExitCodes: []int{cerr.ExitCodeOK, cerr.ExitCodeIO, cerr.ExitCodeUsage}, + ExitCodes: []int{cerr.ExitCodeOK, cerr.ExitCodeValidation, cerr.ExitCodeIO, cerr.ExitCodeUsage}, }, { Name: "show", @@ -673,7 +730,7 @@ func allSchemaCommands() []schemaCommand { {Name: "bundle", Required: true}, }, Stdout: "json", - ExitCodes: []int{cerr.ExitCodeOK, cerr.ExitCodeIO, cerr.ExitCodeUsage}, + ExitCodes: []int{cerr.ExitCodeOK, cerr.ExitCodeValidation, cerr.ExitCodeIO, cerr.ExitCodeUsage}, }, { Name: "backlinks", @@ -684,7 +741,7 @@ func allSchemaCommands() []schemaCommand { {Name: "concept-id", Required: true}, }, Stdout: "json", - ExitCodes: []int{cerr.ExitCodeOK, cerr.ExitCodeIO, cerr.ExitCodeUsage}, + ExitCodes: []int{cerr.ExitCodeOK, cerr.ExitCodeValidation, cerr.ExitCodeIO, cerr.ExitCodeUsage}, }, { Name: "graph", @@ -692,7 +749,7 @@ func allSchemaCommands() []schemaCommand { Long: "Builds the directed cross-link graph from concept markdown links and prints nodes, edges, and summary statistics.", Args: []schemaArg{{Name: "bundle", Required: true}}, Stdout: "json", - ExitCodes: []int{cerr.ExitCodeOK, cerr.ExitCodeIO, cerr.ExitCodeUsage}, + ExitCodes: []int{cerr.ExitCodeOK, cerr.ExitCodeValidation, cerr.ExitCodeIO, cerr.ExitCodeUsage}, }, { Name: "version", diff --git a/cmd/okf/main_test.go b/cmd/okf/main_test.go new file mode 100644 index 0000000..46b3d6a --- /dev/null +++ b/cmd/okf/main_test.go @@ -0,0 +1,216 @@ +package main + +import ( + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/okfcli/okf/internal/cerr" +) + +// --- issue #31: commands without flags must still reject flags as usage --- + +func TestRejectFlags(t *testing.T) { + if err := rejectFlags("index", []string{"./bundle"}); err != nil { + t.Fatalf("plain path rejected: %v", err) + } + if err := rejectFlags("index", nil); err != nil { + t.Fatalf("empty args rejected: %v", err) + } + for _, args := range [][]string{ + {"--nope", "./bundle"}, + {"./bundle", "--check"}, + {"-x"}, + } { + err := rejectFlags("index", args) + if err == nil { + t.Fatalf("args %v: want usage error", args) + } + if err.Kind != cerr.KindUsage { + t.Errorf("args %v: kind = %v, want usage", args, err.Kind) + } + if !strings.Contains(err.Message, "unknown index flag: -") { + t.Errorf("args %v: message = %q", args, err.Message) + } + } +} + +// --- issue #27: a non-concept .md is a validation error naming the file --- + +func TestLoadBundle_MissingFrontmatterIsValidationError(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "index.md"), "# Demo\n") + writeFile(t, filepath.Join(dir, "README.md"), "# Demo project\n") + + _, err := loadBundle(dir) + if err == nil { + t.Fatal("expected error") + } + if err.Kind != cerr.KindValidation { + t.Errorf("kind = %v, want validation", err.Kind) + } + for _, want := range []string{"README.md", "no YAML frontmatter"} { + if !strings.Contains(err.Message, want) { + t.Errorf("message %q does not mention %q", err.Message, want) + } + } + if err.Hint == "" { + t.Error("expected a hint explaining the frontmatter requirement") + } +} + +func TestLoadBundle_MissingDirIsIOError(t *testing.T) { + _, err := loadBundle(filepath.Join(t.TempDir(), "nope")) + if err == nil || err.Kind != cerr.KindIO { + t.Fatalf("want io error, got %v", err) + } +} + +// --- end to end: the built binary's JSON envelope and exit code --- + +var okfBin string + +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "okf-test-bin") + if err != nil { + panic(err) + } + okfBin = filepath.Join(dir, "okf") + build := exec.Command("go", "build", "-o", okfBin, ".") + if out, err := build.CombinedOutput(); err != nil { + panic("build okf: " + err.Error() + "\n" + string(out)) + } + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} + +type envelope struct { + Error struct { + Kind string `json:"kind"` + Code int `json:"code"` + Message string `json:"message"` + Hint string `json:"hint"` + } `json:"error"` +} + +func runOKF(t *testing.T, args ...string) (string, int) { + t.Helper() + cmd := exec.Command(okfBin, args...) //nolint:gosec // test runs the binary built in TestMain + out, err := cmd.Output() + code := 0 + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + code = exitErr.ExitCode() + } else if err != nil { + t.Fatalf("run %v: %v", args, err) + } + return string(out), code +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestE2E_UnknownFlagIsUsageError(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "a.md"), "---\ntype: T\n---\n\nbody") + for _, c := range []string{"index", "list", "graph", "search", "validate", "lint"} { + out, code := runOKF(t, c, "--nope", dir) + if code != cerr.ExitCodeUsage { + t.Errorf("%s --nope: exit %d, want %d; out=%s", c, code, cerr.ExitCodeUsage, out) + } + var env envelope + if err := json.Unmarshal([]byte(out), &env); err != nil { + t.Errorf("%s --nope: bad json %q", c, out) + continue + } + if env.Error.Kind != "usage" || env.Error.Code != 400 { + t.Errorf("%s --nope: envelope %+v", c, env.Error) + } + if !strings.Contains(env.Error.Message, "--nope") { + t.Errorf("%s --nope: message %q does not name the flag", c, env.Error.Message) + } + } + for _, c := range []string{"show", "backlinks"} { + out, code := runOKF(t, c, dir, "--nope") + if code != cerr.ExitCodeUsage { + t.Errorf("%s dir --nope: exit %d, want %d; out=%s", c, code, cerr.ExitCodeUsage, out) + } + } + // Sanity: the bundle itself still loads. + if _, code := runOKF(t, "list", dir); code != 0 { + t.Errorf("list: exit %d, want 0", code) + } +} + +func TestE2E_MarkdownWithoutFrontmatterNamesFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "index.md"), "# Demo\n") + writeFile(t, filepath.Join(dir, "README.md"), "# Demo project\n") + + out, code := runOKF(t, "list", dir) + if code != cerr.ExitCodeValidation { + t.Errorf("exit %d, want %d; out=%s", code, cerr.ExitCodeValidation, out) + } + var env envelope + if err := json.Unmarshal([]byte(out), &env); err != nil { + t.Fatalf("bad json %q", out) + } + if env.Error.Kind != "validation" { + t.Errorf("kind = %q, want validation", env.Error.Kind) + } + if !strings.Contains(env.Error.Message, "README.md") || !strings.Contains(env.Error.Message, "frontmatter") { + t.Errorf("message %q does not name the file and reason", env.Error.Message) + } + if env.Error.Hint == "" { + t.Error("expected a hint") + } +} + +func TestE2E_BrokenLinkDoesNotFailValidate(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "a.md"), "---\ntype: T\ntitle: A\ndescription: d\ntags: [x]\n---\n\nSee [b](/b.md).") + out, code := runOKF(t, "validate", dir) + if code != 0 { + t.Fatalf("exit %d, want 0; out=%s", code, out) + } + var rep struct { + Valid bool `json:"valid"` + Warnings int `json:"warnings"` + Errors int `json:"errors"` + } + if err := json.Unmarshal([]byte(out), &rep); err != nil { + t.Fatalf("bad json %q", out) + } + if !rep.Valid || rep.Errors != 0 || rep.Warnings != 1 { + t.Errorf("report = %+v, want valid with one warning", rep) + } +} + +func TestE2E_IndexRefusesNonConceptMarkdown(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "a.md"), "---\ntype: T\ntitle: A\n---\n\nbody") + writeFile(t, filepath.Join(dir, "README.md"), "# Demo project\n") + + out, code := runOKF(t, "index", dir) + if code != cerr.ExitCodeValidation { + t.Errorf("exit %d, want %d; out=%s", code, cerr.ExitCodeValidation, out) + } + if !strings.Contains(out, "README.md") { + t.Errorf("output %q does not name the file", out) + } + if _, err := os.Stat(filepath.Join(dir, "index.md")); err == nil { + t.Error("index.md was written even though the bundle failed to load") + } +} diff --git a/internal/bundle/bundle.go b/internal/bundle/bundle.go index bbe0be2..c25347b 100644 --- a/internal/bundle/bundle.go +++ b/internal/bundle/bundle.go @@ -2,6 +2,7 @@ package bundle import ( + "errors" "fmt" "io/fs" "os" @@ -21,6 +22,30 @@ type Bundle struct { reservedByID map[string]*concept.Concept } +// ParseError reports a .md file whose contents could not be parsed as a +// concept: no frontmatter block, an unclosed one, or invalid YAML. Path is +// bundle-relative so callers can name the file (issue #27). It is distinct +// from a filesystem failure, which is returned unwrapped as an I/O error. +type ParseError struct { + Path string + Err error +} + +func (e *ParseError) Error() string { return "parse " + e.Path + ": " + e.Err.Error() } + +// Unwrap exposes the underlying parse failure for errors.Is / errors.As. +func (e *ParseError) Unwrap() error { return e.Err } + +// wrapParse classifies a concept parse failure: a filesystem error stays an +// I/O error, anything else is a ParseError naming the file. +func wrapParse(relPath string, err error) error { + var pathErr *fs.PathError + if errors.As(err, &pathErr) { + return fmt.Errorf("read %s: %w", relPath, err) + } + return &ParseError{Path: relPath, Err: err} +} + // Load walks a bundle directory and parses every .md file. // Reserved filenames (index.md, log.md) are separated into Reserved. func Load(root string) (*Bundle, error) { @@ -53,7 +78,9 @@ func Load(root string) (*Bundle, error) { } return nil } - if !strings.HasSuffix(d.Name(), ".md") { + // Skip hidden files (editor drafts, backups) the same way hidden + // directories are skipped: they are not part of the bundle. + if !strings.HasSuffix(d.Name(), ".md") || strings.HasPrefix(d.Name(), ".") { return nil } @@ -70,7 +97,7 @@ func Load(root string) (*Bundle, error) { if concept.ReservedNames[strings.ToLower(d.Name())] { c, perr := concept.ParseReserved(path, relPath) if perr != nil { - return fmt.Errorf("parse reserved %s: %w", relPath, perr) + return wrapParse(relPath, perr) } b.Reserved = append(b.Reserved, c) b.reservedByID[c.ID] = c @@ -79,7 +106,7 @@ func Load(root string) (*Bundle, error) { c, err := concept.Parse(path, relPath) if err != nil { - return fmt.Errorf("parse %s: %w", relPath, err) + return wrapParse(relPath, err) } b.Concepts = append(b.Concepts, c) b.conceptByID[c.ID] = c diff --git a/internal/bundle/bundle_test.go b/internal/bundle/bundle_test.go index 30b31b8..05adc3f 100644 --- a/internal/bundle/bundle_test.go +++ b/internal/bundle/bundle_test.go @@ -1,6 +1,7 @@ package bundle import ( + "errors" "os" "path/filepath" "strings" @@ -201,3 +202,69 @@ func TestLoad_GeneratedIndexIsDiscoverable(t *testing.T) { t.Error("generated index Body is empty, want raw index content") } } + +// Issue #27: a .md file that is not a concept must fail with an error that +// names the file and the reason, distinguishable from a filesystem failure. +func TestLoad_MissingFrontmatterNamesFile(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "index.md"), []byte("# Demo\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "docs"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "docs", "README.md"), []byte("# Demo project\n"), 0644); err != nil { + t.Fatal(err) + } + + _, err := Load(dir) + if err == nil { + t.Fatal("expected an error for markdown without frontmatter") + } + var pe *ParseError + if !errors.As(err, &pe) { + t.Fatalf("error %T %q is not a *ParseError", err, err) + } + if pe.Path != "docs/README.md" { + t.Errorf("ParseError.Path = %q, want docs/README.md", pe.Path) + } + if !errors.Is(err, concept.ErrNoFrontmatter) { + t.Errorf("error does not unwrap to ErrNoFrontmatter: %v", err) + } + for _, want := range []string{"docs/README.md", "no YAML frontmatter"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not mention %q", err, want) + } + } +} + +func TestLoad_UnclosedFrontmatterIsParseError(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "a.md"), []byte("---\ntype: T\n\nbody"), 0644); err != nil { + t.Fatal(err) + } + _, err := Load(dir) + var pe *ParseError + if !errors.As(err, &pe) || pe.Path != "a.md" { + t.Fatalf("want *ParseError for a.md, got %T %v", err, err) + } +} + +func TestLoad_HiddenFilesSkipped(t *testing.T) { + // Hidden directories are already skipped; hidden files (editor drafts, + // backups) get the same treatment rather than taking the bundle down. + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "a.md"), []byte("---\ntype: T\n---\n\nbody"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".draft.md"), []byte("# not a concept\n"), 0644); err != nil { + t.Fatal(err) + } + b, err := Load(dir) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if len(b.Concepts) != 1 || b.Concepts[0].ID != "a" { + t.Fatalf("got concepts %+v, want just a", b.Concepts) + } +} diff --git a/internal/cerr/errors.go b/internal/cerr/errors.go index 6ce14b0..16e3001 100644 --- a/internal/cerr/errors.go +++ b/internal/cerr/errors.go @@ -56,7 +56,7 @@ type ExitCodeDoc struct { // ExitCodeDocs enumerates every exit code the CLI may emit, in stable order. var ExitCodeDocs = []ExitCodeDoc{ {ExitCodeOK, "success"}, - {ExitCodeValidation, "validation error (spec violation, broken link, bad input)"}, + {ExitCodeValidation, "validation error (spec violation, bad input)"}, {ExitCodeIO, "filesystem or I/O error"}, {ExitCodeInternal, "internal error (unexpected)"}, {ExitCodeUsage, "usage error (missing args, unknown command)"}, @@ -120,6 +120,16 @@ func format(msg string, args ...any) string { return fmt.Sprintf(msg, args...) } +// withCause appends cause to msg. The JSON envelope and the stderr line carry +// only Message, so a wrapped cause that names the failing file or reason has +// to be folded into it or the user never sees it (issue #27). +func withCause(msg string, cause error) string { + if cause == nil { + return msg + } + return msg + ": " + cause.Error() +} + // Validation builds a validation error. func Validation(msg string, args ...any) *Error { return &Error{ @@ -136,7 +146,7 @@ func IO(cause error, msg string, args ...any) *Error { Kind: KindIO, Code: 500, Reason: "ioError", - Message: format(msg, args...), + Message: withCause(format(msg, args...), cause), Cause: cause, } } @@ -157,7 +167,7 @@ func Internal(cause error, msg string, args ...any) *Error { Kind: KindInternal, Code: 500, Reason: "internalError", - Message: format(msg, args...), + Message: withCause(format(msg, args...), cause), Cause: cause, } } @@ -172,5 +182,11 @@ func From(err error) *Error { if errors.As(err, &e) { return e } - return Internal(err, "%s", err.Error()) + return &Error{ + Kind: KindInternal, + Code: 500, + Reason: "internalError", + Message: err.Error(), + Cause: err, + } } diff --git a/internal/cerr/errors_test.go b/internal/cerr/errors_test.go index a6de0c3..c0b4f1e 100644 --- a/internal/cerr/errors_test.go +++ b/internal/cerr/errors_test.go @@ -90,3 +90,41 @@ func TestKindString(t *testing.T) { } } } + +// Issue #27: the JSON envelope carries only Message, so a wrapped cause that +// names the failing file must be part of the message or the user never sees it. +func TestIOMessageIncludesCause(t *testing.T) { + cause := errors.New("parse README.md: no YAML frontmatter block found") + e := IO(cause, "load bundle %s", "demo") + want := "load bundle demo: parse README.md: no YAML frontmatter block found" + if e.Message != want { + t.Errorf("message = %q, want %q", e.Message, want) + } + if e.ToEnvelope()["error"].(map[string]any)["message"] != want { + t.Errorf("envelope message does not carry the cause") + } +} + +func TestIONilCauseMessageUnchanged(t *testing.T) { + if e := IO(nil, "plain"); e.Message != "plain" { + t.Errorf("message = %q, want plain", e.Message) + } +} + +func TestInternalMessageIncludesCause(t *testing.T) { + e := Internal(errors.New("boom"), "marshal sarif") + if e.Message != "marshal sarif: boom" { + t.Errorf("message = %q", e.Message) + } +} + +func TestFromDoesNotDuplicateMessage(t *testing.T) { + plain := errors.New("plain error") + e := From(plain) + if e.Message != "plain error" { + t.Errorf("message = %q, want %q", e.Message, "plain error") + } + if !errors.Is(e, plain) { + t.Error("From lost the cause") + } +} diff --git a/internal/validate/rules.go b/internal/validate/rules.go index edf950a..7c81794 100644 --- a/internal/validate/rules.go +++ b/internal/validate/rules.go @@ -40,6 +40,8 @@ const ( RuleFootnoteUnmatched = "okf/sources/footnote-unmatched" RuleFootnoteUndefined = "okf/sources/footnote-undefined" RuleFootnoteUnreferenced = "okf/sources/footnote-unreferenced" + RuleFootnoteDuplicate = "okf/sources/footnote-duplicate" + RuleSourceIDDuplicate = "okf/sources/id-duplicate" // computation: the Attested Computation contract (§10, §6.2) RuleRuntimeRequired = "okf/computation/runtime-required" @@ -66,7 +68,7 @@ var ruleDescriptions = map[string]string{ RuleBodyEmpty: "body is empty, structural markdown is recommended (OKF §4.2)", - RuleLinkBroken: "cross-link does not resolve to a concept in the bundle (OKF §6)", + RuleLinkBroken: "cross-link does not resolve to a concept in the bundle; consumers must tolerate this (OKF §6.1, §11)", RuleStatusInvalid: "'status' must be draft, stable, or deprecated (OKF §5.4)", RuleStaleAfterInvalid: "'stale_after' must be an absolute YYYY-MM-DD date (OKF §5.5)", @@ -86,6 +88,8 @@ var ruleDescriptions = map[string]string{ RuleFootnoteUnmatched: "body footnote label has no matching 'sources[].id' (OKF §5.1)", RuleFootnoteUndefined: "body footnote is referenced but never defined (OKF §5.1)", RuleFootnoteUnreferenced: "body footnote is defined but never referenced (OKF §5.1)", + RuleFootnoteDuplicate: "body footnote is defined more than once (OKF §5.1)", + RuleSourceIDDuplicate: "'sources[].id' is declared more than once (OKF §5.1)", RuleRuntimeRequired: "'runtime' is required for type Attested Computation (OKF §10.2)", RuleComputationDup: "computation is provided both inline and via the 'computation' path (OKF §10.3)", diff --git a/internal/validate/sarif_test.go b/internal/validate/sarif_test.go index 76e81ca..e0769f6 100644 --- a/internal/validate/sarif_test.go +++ b/internal/validate/sarif_test.go @@ -32,9 +32,11 @@ var allRuleIDs = []string{ "okf/reserved/index-frontmatter-key", "okf/reserved/log-date-heading", "okf/reserved/okf-version-unknown", + "okf/sources/footnote-duplicate", "okf/sources/footnote-undefined", "okf/sources/footnote-unmatched", "okf/sources/footnote-unreferenced", + "okf/sources/id-duplicate", "okf/sources/resource-required", "okf/sources/usage-window-missing", "okf/trust/actor-convention", diff --git a/internal/validate/v02.go b/internal/validate/v02.go index c9a585f..9f0d688 100644 --- a/internal/validate/v02.go +++ b/internal/validate/v02.go @@ -81,7 +81,7 @@ func validateV02(r *Report, b *bundle.Bundle, c *concept.Concept) { r.add(c.ID, RuleLegacyTimestamp, SeverityWarning, "frontmatter: legacy 'timestamp' is superseded by 'generated.at' in OKF v0.2 (§13.1)") } - if citationsHeading.MatchString(c.Body) { + if citationsHeading.MatchString(maskCode(c.Body)) { r.add(c.ID, RuleLegacyCitations, SeverityWarning, "body: legacy '# Citations' list is superseded by the 'sources' frontmatter family in OKF v0.2 (§13.1)") } @@ -112,14 +112,112 @@ var ( isoDate = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`) ) +// maskCode replaces the contents of fenced code blocks and inline code spans +// with spaces. Length and newlines are preserved, so byte offsets into the +// result line up with the original body. Footnote and heading syntax inside +// code renders as literal text, so scanners must not see it (issue #26). +// +// Fences open on a line whose first non-blank characters are three or more +// backticks or tildes and close on a line with at least as many of the same +// character. Inline spans open with a run of n backticks and close at the +// next run of exactly n; an unclosed run is literal, as in CommonMark. +func maskCode(body string) string { + out := []byte(body) + lines := strings.SplitAfter(body, "\n") + pos := 0 + fenceChar, fenceLen := byte(0), 0 + for _, line := range lines { + content := strings.TrimRight(line, "\r\n") + trimmed := strings.TrimLeft(content, " \t") + if fenceLen > 0 { + if run := leadingRun(trimmed); run >= fenceLen && trimmed[0] == fenceChar && strings.TrimSpace(trimmed[run:]) == "" { + fenceChar, fenceLen = 0, 0 + } + blank(out, pos, pos+len(content)) + } else if run := leadingRun(trimmed); run >= 3 { + fenceChar, fenceLen = trimmed[0], run + blank(out, pos, pos+len(content)) + } else { + maskSpans(out, pos, content) + } + pos += len(line) + } + return string(out) +} + +// leadingRun returns the length of the run of backticks or tildes at the +// start of s, or 0 if s starts with neither. +func leadingRun(s string) int { + if s == "" || (s[0] != '`' && s[0] != '~') { + return 0 + } + n := 0 + for n < len(s) && s[n] == s[0] { + n++ + } + return n +} + +// maskSpans blanks inline code spans within one line of prose. base is the +// byte offset of line within out. +func maskSpans(out []byte, base int, line string) { + i := 0 + for i < len(line) { + open := strings.IndexByte(line[i:], '`') + if open == -1 { + return + } + start := i + open + n := 0 + for start+n < len(line) && line[start+n] == '`' { + n++ + } + j := start + n + for j < len(line) { + k := strings.IndexByte(line[j:], '`') + if k == -1 { + j = -1 + break + } + j += k + m := 0 + for j+m < len(line) && line[j+m] == '`' { + m++ + } + if m == n { + break + } + j += m + } + if j == -1 { + // No matching closer: the backticks are literal text. + i = start + n + continue + } + blank(out, base+start, base+j+n) + i = j + n + } +} + +// blank overwrites out[from:to] with spaces, leaving newlines intact. +func blank(out []byte, from, to int) { + for i := from; i < to && i < len(out); i++ { + if out[i] != '\n' && out[i] != '\r' { + out[i] = ' ' + } + } +} + // footnoteLabels splits a body's footnote markers into references and // definitions, each deduplicated in first-occurrence order so findings are // emitted deterministically. A definition is `[^label]:` at the start of a // line; every other `[^label]` occurrence, including one that happens to be -// followed by a literal colon mid-line, is a reference (OKF §5.1). -func footnoteLabels(body string) (refs, defs []string) { +// followed by a literal colon mid-line, is a reference (OKF §5.1). dupDefs +// lists labels defined more than once (issue #29). +func footnoteLabels(body string) (refs, defs, dupDefs []string) { seenRef := make(map[string]bool) seenDef := make(map[string]bool) + seenDup := make(map[string]bool) for _, m := range footnoteMark.FindAllStringSubmatchIndex(body, -1) { start, end, labelStart, labelEnd := m[0], m[1], m[2], m[3] label := body[labelStart:labelEnd] @@ -129,13 +227,16 @@ func footnoteLabels(body string) (refs, defs []string) { if !seenDef[label] { seenDef[label] = true defs = append(defs, label) + } else if !seenDup[label] { + seenDup[label] = true + dupDefs = append(dupDefs, label) } } else if !seenRef[label] { seenRef[label] = true refs = append(refs, label) } } - return refs, defs + return refs, defs, dupDefs } // labelSet converts a label list to a membership set. @@ -159,6 +260,12 @@ func validateSources(r *Report, c *concept.Concept) { "frontmatter: 'sources[%d]' requires 'resource' (OKF §5.1)", i)) } if s.ID != "" { + // The id is the join key for footnotes; a second declaration + // leaves which entry wins undefined (issue #29). + if ids[s.ID] { + r.add(c.ID, RuleSourceIDDuplicate, SeverityWarning, fmt.Sprintf( + "frontmatter: 'sources[%d].id' %q is declared more than once - footnotes join by id, so which entry a reader sees is undefined (OKF §5.1)", i, s.ID)) + } ids[s.ID] = true } if s.UsageCount != nil && s.UsageWindow == nil && fm.UsageWindow == nil { @@ -171,7 +278,8 @@ func validateSources(r *Report, c *concept.Concept) { // dangling marker, and a definition with no reference renders as // nothing, silently dropping a source that reads as cited. Both are // warnings regardless of whether the label also joins into sources[].id. - refs, defs := footnoteLabels(c.Body) + body := maskCode(c.Body) + refs, defs, dupDefs := footnoteLabels(body) refSet, defSet := labelSet(refs), labelSet(defs) for _, label := range refs { if !defSet[label] { @@ -185,6 +293,10 @@ func validateSources(r *Report, c *concept.Concept) { "body: footnote [^%s] is defined but never referenced - renders as nothing, so a source that reads as cited is absent from the output (OKF §5.1)", label)) } } + for _, label := range dupDefs { + r.add(c.ID, RuleFootnoteDuplicate, SeverityWarning, fmt.Sprintf( + "body: footnote [^%s] is defined more than once - renderers disagree about which definition wins (OKF §5.1)", label)) + } // Per-claim attribution: footnote labels are join keys into sources[].id. // Only meaningful when the concept declares source ids at all. @@ -192,7 +304,7 @@ func validateSources(r *Report, c *concept.Concept) { return } seen := make(map[string]bool) - for _, m := range footnoteRef.FindAllStringSubmatch(c.Body, -1) { + for _, m := range footnoteRef.FindAllStringSubmatch(body, -1) { label := m[1] if seen[label] || ids[label] { seen[label] = true diff --git a/internal/validate/v02_test.go b/internal/validate/v02_test.go index fea1196..d4b90bf 100644 --- a/internal/validate/v02_test.go +++ b/internal/validate/v02_test.go @@ -396,3 +396,125 @@ func TestValidate_ContractPathRootRelativeFallback(t *testing.T) { r := Validate(b) mustNotFinding(t, r, "does not exist") } + +// --- issue #26: footnote syntax inside code is literal text --- + +func TestMaskCode_PreservesLengthAndNewlines(t *testing.T) { + in := "a `x` b\n```\n[^c]\n```\nd ``[^e]`` f\n" + out := maskCode(in) + if len(out) != len(in) { + t.Fatalf("maskCode changed length: %d -> %d", len(in), len(out)) + } + for i := range in { + if in[i] == '\n' && out[i] != '\n' { + t.Fatalf("newline at %d was masked", i) + } + } + if strings.Contains(out, "[^c]") || strings.Contains(out, "[^e]") || strings.Contains(out, "x") { + t.Fatalf("code content survived masking: %q", out) + } + if !strings.Contains(out, "a ") || !strings.Contains(out, " b\n") || !strings.Contains(out, "d ") || !strings.Contains(out, " f\n") { + t.Fatalf("prose outside code was masked: %q", out) + } +} + +func TestMaskCode_UnclosedSpanIsLiteral(t *testing.T) { + in := "a `b [^c]" + if got := maskCode(in); got != in { + t.Fatalf("unclosed backtick should be literal, got %q", got) + } +} + +func TestMaskCode_TildeFenceAndIndentedFence(t *testing.T) { + in := "~~~\n[^a]\n~~~\n ```go\n[^b]\n ```\n[^c]" + out := maskCode(in) + if strings.Contains(out, "[^a]") || strings.Contains(out, "[^b]") { + t.Fatalf("fenced content survived: %q", out) + } + if !strings.Contains(out, "[^c]") { + t.Fatalf("prose after fence was masked: %q", out) + } +} + +func TestValidate_FootnoteInCodeSpanIgnored(t *testing.T) { + b := testBundle(t, map[string]string{ + "a.md": "---\ntype: T\ntitle: A\ndescription: d\ntags: [x]\n---\n\nAttribute claims with `[^id]` footnotes.", + }) + r := Validate(b) + mustNotFinding(t, r, "footnote") +} + +func TestValidate_FootnoteInFencedBlockIgnored(t *testing.T) { + b := testBundle(t, map[string]string{ + "a.md": "---\ntype: T\ntitle: A\ndescription: d\ntags: [x]\n---\n\nExample:\n\n```markdown\nA claim.[^src]\n\n[^src]: example\n```\n", + }) + r := Validate(b) + mustNotFinding(t, r, "footnote") +} + +func TestValidate_FootnoteOutsideCodeStillReported(t *testing.T) { + b := testBundle(t, map[string]string{ + "a.md": "---\ntype: T\ntitle: A\ndescription: d\ntags: [x]\n---\n\nSee `code` then a claim.[^id]", + }) + mustFinding(t, Validate(b), SeverityWarning, "[^id] is referenced but never defined") +} + +// --- issue #29: duplicates are invisible to the join rule by construction --- + +func TestValidate_DuplicateSourceIDWarns(t *testing.T) { + b := testBundle(t, map[string]string{ + "a.md": "---\ntype: T\ntitle: A\ndescription: d\ntags: [x]\nsources:\n - id: d\n title: First\n resource: https://example.com/doc\n - id: d\n title: Second\n resource: https://example.com/doc\n---\n\nA claim.[^d]\n\n[^d]: [First](https://example.com/doc)\n", + }) + r := Validate(b) + mustFinding(t, r, SeverityWarning, "'sources[1].id' \"d\" is declared more than once") + if r.HasErrors() { + t.Fatalf("duplicate id is advisory, must not be an error: %+v", r.Findings) + } + for _, f := range r.Findings { + if strings.Contains(f.Message, "declared more than once") && f.RuleID != RuleSourceIDDuplicate { + t.Fatalf("wrong rule id %q", f.RuleID) + } + } +} + +func TestValidate_DuplicateSourceIDReportedOnce(t *testing.T) { + b := testBundle(t, map[string]string{ + "a.md": "---\ntype: T\ntitle: A\ndescription: d\ntags: [x]\nsources:\n - id: d\n resource: r\n - id: d\n resource: r\n - id: d\n resource: r\n---\n\n[^d]\n\n[^d]: x\n", + }) + n := 0 + for _, f := range Validate(b).Findings { + if f.RuleID == RuleSourceIDDuplicate { + n++ + } + } + if n != 2 { + t.Fatalf("want one finding per extra copy (2), got %d", n) + } +} + +func TestValidate_DuplicateFootnoteDefinitionWarns(t *testing.T) { + b := testBundle(t, map[string]string{ + "a.md": "---\ntype: T\ntitle: A\ndescription: d\ntags: [x]\nsources:\n - id: d\n resource: r\n---\n\nA claim.[^d]\n\n[^d]: [First](https://example.com/doc)\n[^d]: [Second](https://example.com/doc)\n", + }) + r := Validate(b) + mustFinding(t, r, SeverityWarning, "footnote [^d] is defined more than once") + if r.HasErrors() { + t.Fatalf("duplicate definition is advisory: %+v", r.Findings) + } + for _, f := range r.Findings { + if strings.Contains(f.Message, "defined more than once") && f.RuleID != RuleFootnoteDuplicate { + t.Fatalf("wrong rule id %q", f.RuleID) + } + } + // The existing undefined/unreferenced rules must stay quiet: the label is + // both referenced and defined. + mustNotFinding(t, r, "never defined") + mustNotFinding(t, r, "never referenced") +} + +func TestValidate_SingleDefinitionNoDuplicateWarning(t *testing.T) { + b := testBundle(t, map[string]string{ + "a.md": "---\ntype: T\ntitle: A\ndescription: d\ntags: [x]\n---\n\nA claim.[^d] and again.[^d]\n\n[^d]: x\n", + }) + mustNotFinding(t, Validate(b), "more than once") +} diff --git a/internal/validate/validate.go b/internal/validate/validate.go index 928ffdd..8169e24 100644 --- a/internal/validate/validate.go +++ b/internal/validate/validate.go @@ -100,6 +100,11 @@ func validateBody(r *Report, c *concept.Concept) { // existing concept or reserved file (index.md, log.md). Both absolute // (/path/to/concept.md) and relative links are checked. // +// A broken link is a warning, not an error: §6.1 says consumers MUST +// tolerate broken links because they may represent not-yet-written +// knowledge, and §11 says a bundle MUST NOT be rejected because of them +// (issue #26). The finding still surfaces so authors can repair it. +// // Relative links (without a leading /) resolve from the concept's own // directory: a link [X](organizations/cloaked) in pages/about.md targets // pages/organizations/cloaked, not organizations/cloaked. When such a @@ -131,11 +136,11 @@ func validateLinks(r *Report, b *bundle.Bundle) { // regardless of fromConceptID), so an already-absolute broken // link correctly falls through to the plain message below. if absTarget := resolveLink("", link); absTarget != "" && absTarget != target && (b.HasConcept(absTarget) || b.HasReserved(absTarget)) { - r.add(c.ID, RuleLinkBroken, SeverityError, fmt.Sprintf( + r.add(c.ID, RuleLinkBroken, SeverityWarning, fmt.Sprintf( "broken link: [%s] -> %s (relative links resolve from the current concept's directory; use /%s%s for an absolute path)", link.Text, link.Target, absTarget, fragmentOf(link.Target))) } else { - r.add(c.ID, RuleLinkBroken, SeverityError, fmt.Sprintf( + r.add(c.ID, RuleLinkBroken, SeverityWarning, fmt.Sprintf( "broken link: [%s] -> %s (concept %s not found)", link.Text, link.Target, target)) } diff --git a/internal/validate/validate_test.go b/internal/validate/validate_test.go index 8b97902..31a9728 100644 --- a/internal/validate/validate_test.go +++ b/internal/validate/validate_test.go @@ -97,6 +97,32 @@ func TestExtractFrontmatterLinks_Empty(t *testing.T) { } } +// Issue #26: OKF §6.1 and §11 say consumers MUST tolerate broken cross-links +// and MUST NOT reject a bundle because of them, so a broken link is a +// warning and never flips the conformance verdict. +func TestValidateLinks_BrokenLinkIsWarningNotError(t *testing.T) { + b := testBundle(t, map[string]string{ + "a.md": "---\ntype: T\ntitle: A\ndescription: d\ntags: [x]\n---\n\nSee [B](/b.md) and [img](/assets/diagram.png).", + }) + r := Validate(b) + if r.HasErrors() { + t.Fatalf("broken links must not make the bundle invalid: %+v", r.Findings) + } + got := 0 + for _, f := range r.Findings { + if f.RuleID != RuleLinkBroken { + continue + } + got++ + if f.Severity != SeverityWarning { + t.Errorf("broken link severity = %s, want WARN: %+v", f.Severity, f) + } + } + if got != 2 { + t.Fatalf("got %d broken-link findings, want 2: %+v", got, r.Findings) + } +} + func TestValidate_FrontmatterLinkToNonexistentConcept(t *testing.T) { b := testBundle(t, map[string]string{ "a.md": "---\ntype: T\ntitle: A\ndescription: d\ntags: [x]\nlinks:\n - /does-not-exist\n---\n\nbody", @@ -105,12 +131,12 @@ func TestValidate_FrontmatterLinkToNonexistentConcept(t *testing.T) { r := Validate(b) found := false for _, f := range r.Findings { - if f.Severity == SeverityError && strings.Contains(f.Message, "does-not-exist") { + if f.Severity == SeverityWarning && strings.Contains(f.Message, "does-not-exist") { found = true } } if !found { - t.Fatalf("expected a broken-link error for the frontmatter link, findings = %+v", r.Findings) + t.Fatalf("expected a broken-link warning for the frontmatter link, findings = %+v", r.Findings) } } @@ -301,13 +327,13 @@ func TestValidateLinks_LinkToNonexistentIndexIsBrokenError(t *testing.T) { var msg string for _, f := range r.Findings { - if f.Severity == SeverityError && strings.Contains(f.Message, "broken link") { + if f.Severity == SeverityWarning && strings.Contains(f.Message, "broken link") { msg = f.Message break } } if msg == "" { - t.Fatalf("expected a broken-link error for a nonexistent index.md, findings = %+v", r.Findings) + t.Fatalf("expected a broken-link warning for a nonexistent index.md, findings = %+v", r.Findings) } if !strings.Contains(msg, "sub/index") { t.Errorf("error %q does not name the missing target sub/index", msg) From 65c2504d1b10ca294034d0e6f51a5eebc674d5d1 Mon Sep 17 00:00:00 2001 From: Akeem Jenkins Date: Wed, 2 Sep 2026 23:23:54 -0600 Subject: [PATCH 2/2] test: give the e2e test binary an .exe suffix on Windows Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01DwzDaurgXen8UC19AeN6tU --- cmd/okf/main_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cmd/okf/main_test.go b/cmd/okf/main_test.go index 46b3d6a..bcc3922 100644 --- a/cmd/okf/main_test.go +++ b/cmd/okf/main_test.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" @@ -80,6 +81,9 @@ func TestMain(m *testing.M) { panic(err) } okfBin = filepath.Join(dir, "okf") + if runtime.GOOS == "windows" { + okfBin += ".exe" + } build := exec.Command("go", "build", "-o", okfBin, ".") if out, err := build.CombinedOutput(); err != nil { panic("build okf: " + err.Error() + "\n" + string(out))