diff --git a/.loom/event-log.md b/.loom/event-log.md index 0eed615..56a60fe 100644 --- a/.loom/event-log.md +++ b/.loom/event-log.md @@ -1185,3 +1185,40 @@ first thing to batch.** had to repair a lock by hand. **No test we wrote would have caught it**: both sites round-tripped fine in isolation, and the field only vanishes across the handoff between two of them.* + +## 2026-09-11 — a published surface is not only markdown + +**`loomctl external add openapi.json` asked for `openapi.json.md`.** *`.md` +was appended unconditionally, which is right for `cart` and wrong for anything +carrying its own extension.* **Reported from use against a repository publishing +an OpenAPI document beside its prose.** + +**Fixed: `.md` is supplied only when the name has no extension at all**, *never +appended to one that has its own.* + +**Two consequences went with it, and neither would have been noticed from the +report:** + +**Facets.** *A facet's name was built by stripping the extension, so +`openapi.json` would have taken `openapi.usages.md`* — **colliding with the facet +for a sibling `openapi.md`.** *Only a trailing `.md` is stripped now, so it is +`openapi.json.usages.md`, and `sibling-facets`' shape survives for documents that +are not prose.* + +**The unlocked walk.** *It matched `*.md` only, so a hand-fetched `.json` under +`.loom/externals/` was invisible to `check`* — **adopted, unlocked, and never +reported.** *It now walks everything that is not the lock file or a dotfile.* + +> **The assumption was never decided; it was typed.** *`externals` says a +> repository publishes **documents**, and `publication` says prose **in whatever +> form its readers need**.* **Neither says markdown, and three places in this tool +> did.** + +**Belief that could be shown wrong:** *that extension-or-not is a good enough +rule.* **A name like `v1.2-notes` would be read as carrying an extension**, *and +the 404 path now lists the real surface, which is how somebody would find out.* + +*Incidental, and worth recording because it cost a wrong diagnosis: the repository +is private, and a scratch test with a token-less config made every fetch look +broken.* **`loomctl config` says whether a token was found, and reading it first +would have been faster than reasoning.** diff --git a/internal/external/byname.go b/internal/external/byname.go index 07841e8..b71bdde 100644 --- a/internal/external/byname.go +++ b/internal/external/byname.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os/exec" + "path" "strings" "git.hypertheory-labs.dev/loom/loom-cli/internal/config" @@ -32,7 +33,10 @@ func AddByName(root, ownerRepo, name string, out io.Writer) error { if strings.Count(ownerRepo, "/") != 1 { return fmt.Errorf("want owner/repo, got %q", ownerRepo) } - if !strings.HasSuffix(name, ".md") { + // A published surface is not only markdown — an OpenAPI document is a + // document. Supply .md only when the name carries no extension at all, + // and never append one to a name that already has its own. + if path.Ext(name) == "" { name += ".md" } diff --git a/internal/external/external.go b/internal/external/external.go index 9fe1488..39e1c1c 100644 --- a/internal/external/external.go +++ b/internal/external/external.go @@ -430,7 +430,9 @@ func unlockedDocs(root string, locked map[string]bool) ([]string, error) { } return err } - if d.IsDir() || !strings.HasSuffix(d.Name(), ".md") { + // Every file here is an adopted document or something we wrote beside + // one — except the lock itself, and anything else beginning with a dot. + if d.IsDir() || strings.HasPrefix(d.Name(), ".") { return nil } rel, err := filepath.Rel(base, p) diff --git a/internal/external/external_test.go b/internal/external/external_test.go index 79b5a3d..7079c87 100644 --- a/internal/external/external_test.go +++ b/internal/external/external_test.go @@ -1,6 +1,10 @@ package external -import "testing" +import ( + "path" + "strings" + "testing" +) func TestLocalPath(t *testing.T) { for _, tc := range []struct { @@ -70,3 +74,39 @@ func TestIsFacet(t *testing.T) { } } } + +func TestPublishedNameExtension(t *testing.T) { + // A published surface is not only markdown, and .md must never be appended + // to a name that already carries an extension of its own. + for _, tc := range []struct{ in, want string }{ + {"cart", "cart.md"}, + {"cart.md", "cart.md"}, + {"recovering-data", "recovering-data.md"}, + {"openapi.json", "openapi.json"}, + {"entitlement-examples.json", "entitlement-examples.json"}, + } { + got := tc.in + if path.Ext(got) == "" { + got += ".md" + } + if got != tc.want { + t.Errorf("%q -> %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestFacetNameKeepsANonMarkdownExtension(t *testing.T) { + // openapi.json.usages.md, never openapi.usages.md — which would collide with + // the facet for a sibling openapi.md. + for _, tc := range []struct{ doc, want string }{ + {"h/o/r/cart.md", "h/o/r/cart.usages.md"}, + {"h/o/r/openapi.json", "h/o/r/openapi.json.usages.md"}, + } { + if got := strings.TrimSuffix(tc.doc, ".md") + ".usages.md"; got != tc.want { + t.Errorf("facet for %q = %q, want %q", tc.doc, got, tc.want) + } + } + if !isFacet("h/o/r/openapi.json.usages.md") { + t.Error("a facet beside a non-markdown document should still read as a facet") + } +} diff --git a/internal/external/polad.go b/internal/external/polad.go index 29f040a..78f1a29 100644 --- a/internal/external/polad.go +++ b/internal/external/polad.go @@ -5,6 +5,7 @@ import ( "io" "os" "path/filepath" + "strings" "git.hypertheory-labs.dev/loom/loom-cli/internal/lock" ) @@ -59,7 +60,7 @@ func poladLockPath(root string) string { // usually survive unchanged — what moves is the manifests, the config, the code // that a usage named, which is why a usage names them. func usagesFor(root, rel string) (string, bool) { - p := rel[:len(rel)-len(filepath.Ext(rel))] + ".usages.md" + p := strings.TrimSuffix(rel, ".md") + ".usages.md" _, err := os.Stat(filepath.Join(root, lock.Dir, filepath.FromSlash(p))) return p, err == nil } diff --git a/internal/orient/orient.go b/internal/orient/orient.go index 9050a6c..70620d8 100644 --- a/internal/orient/orient.go +++ b/internal/orient/orient.go @@ -13,7 +13,6 @@ package orient import ( "fmt" "os" - "path" "path/filepath" "strings" @@ -93,7 +92,10 @@ func Generate(root, out string) (string, error) { // facetsFor finds the files written beside an adopted document. func facetsFor(root, rel string) map[string]string { found := map[string]string{} - stem := strings.TrimSuffix(rel, path.Ext(rel)) + // Only a trailing .md is stripped: openapi.json keeps its extension, so + // its facet is openapi.json.usages.md and cannot collide with a facet for + // a sibling openapi.md. + stem := strings.TrimSuffix(rel, ".md") for _, kind := range []string{"usages", "gaps"} { p := stem + "." + kind + ".md" if _, err := os.Stat(filepath.Join(root, lock.Dir, filepath.FromSlash(p))); err == nil {