a published surface is not only markdown

add appended .md unconditionally, so openapi.json became openapi.json.md. Reported
from use against a repository that publishes an OpenAPI document beside its prose.
It is now supplied only when the name carries no extension of its own.

Two consequences came with it that the report could not have seen. A facet's name
was built by stripping the extension, so openapi.json would have taken
openapi.usages.md and collided with the facet for a sibling openapi.md; only a
trailing .md is stripped now. And the unlocked walk matched *.md only, so a
hand-fetched .json under .loom/externals was adopted, unlocked, and invisible to
check; 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.

Also records that the wrong diagnosis cost more than the bug: the repository is
private, a scratch config with no token made every fetch look broken, and loomctl
config would have said so in one line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
This commit is contained in:
2026-09-11 11:16:31 -04:00
co-authored by Claude Opus 5
parent e0ef0d02f8
commit b92797b4a2
6 changed files with 92 additions and 6 deletions
+5 -1
View File
@@ -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"
}
+3 -1
View File
@@ -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)
+41 -1
View File
@@ -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")
}
}
+2 -1
View File
@@ -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
}