From da6e8b9b516b9e74db97bbce13066098f3948801 Mon Sep 17 00:00:00 2001 From: Jeff Gonzalez Date: Tue, 8 Sep 2026 09:11:04 -0400 Subject: [PATCH] loomctl orient: one table of contents, restating no rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generates .loom/orientation.md — what this repository depends on, where each copy came from, and which facets sit beside it — for whoever arrives next, of any make. One file rather than two, decided by loom's own rule. Generate what varies, adopt what does not: the publishing half varies not at all and is already adopted, so publication.md appears in the index like any other adopted document, in exactly the repositories that adopted it. A second command would emit a file whose whole content is a pointer to a file already in the tree, and would revive a noun declined when published check went. Restates no rule. The three moves are phrased as operations — what to run, what to write, where — and every rule stays in the document that owns it. The .usages.md is pointed at rather than summarised, because what depends on a document is free prose and anything extracting a claim from prose eventually extracts it wrong. It says nothing recorded when a document has no usages file, which is a finding rather than an omission and is currently true of six of nine. Byte-deterministic, pinned by a test that inserts records out of order and generates three times. Written to .loom/orientation.md rather than inside .loom/externals/, where check would report it as an unlocked external in every repository using the feature. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris --- .loom/orientation.md | 63 +++++++++++++++++++ internal/orient/orient.go | 109 +++++++++++++++++++++++++++++++++ internal/orient/orient_test.go | 73 ++++++++++++++++++++++ main.go | 18 ++++++ 4 files changed, 263 insertions(+) create mode 100644 .loom/orientation.md create mode 100644 internal/orient/orient.go create mode 100644 internal/orient/orient_test.go diff --git a/.loom/orientation.md b/.loom/orientation.md new file mode 100644 index 0000000..3ee206f --- /dev/null +++ b/.loom/orientation.md @@ -0,0 +1,63 @@ + + +# What this repository depends on + +Copies of other people's documents are kept under `.loom/externals/`, at a path +that says where each came from. **They are copies: do not edit them.** Anything +you want to say about one goes in a file *beside* it, never into it. + +Three moves, and each has a document that owns the rule: + +- **A copy is wrong, or you needed something it does not say** — write it in + `.gaps.md` beside the copy. +- **What of ours depends on a copy** — write it in `.usages.md` beside it. +- **A source changed** — `loomctl external check` says so and stages the new copy; + `loomctl external apply` takes it. Neither edits anything on its own. + +## `git.hypertheory-labs.dev/jeffry/homelab-cluster/gitea.md` + +- source: https://git.hypertheory-labs.dev/jeffry/homelab-cluster/raw/branch/main/.loom/published/gitea.md +- what of ours depends on it: `.loom/externals/git.hypertheory-labs.dev/jeffry/homelab-cluster/gitea.usages.md` + +## `git.hypertheory-labs.dev/loom/annotating/annotating.md` + +- source: https://git.hypertheory-labs.dev/loom/annotating/raw/branch/main/.loom/published/annotating.md +- what of ours depends on it: **nothing recorded** — no `.usages.md` +- what we expected and did not find: `.loom/externals/git.hypertheory-labs.dev/loom/annotating/annotating.gaps.md` + +## `git.hypertheory-labs.dev/loom/bedrock/loom-directory.md` + +- source: https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/loom-directory.md +- what of ours depends on it: **nothing recorded** — no `.usages.md` + +## `git.hypertheory-labs.dev/loom/bedrock/publication.md` + +- source: https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/publication.md +- what of ours depends on it: **nothing recorded** — no `.usages.md` + +## `git.hypertheory-labs.dev/loom/bedrock/recording-decisions.md` + +- source: https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/recording-decisions.md +- what of ours depends on it: **nothing recorded** — no `.usages.md` + +## `git.hypertheory-labs.dev/loom/bedrock/sibling-facets.md` + +- source: https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/sibling-facets.md +- what of ours depends on it: **nothing recorded** — no `.usages.md` + +## `git.hypertheory-labs.dev/loom/bedrock/starting.md` + +- source: https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/starting.md +- what of ours depends on it: **nothing recorded** — no `.usages.md` + +## `git.hypertheory-labs.dev/loom/cart/cart.md` + +- source: https://git.hypertheory-labs.dev/loom/cart/raw/branch/main/.loom/published/cart.md +- what of ours depends on it: `.loom/externals/git.hypertheory-labs.dev/loom/cart/cart.usages.md` +- what we expected and did not find: `.loom/externals/git.hypertheory-labs.dev/loom/cart/cart.gaps.md` + +## `git.hypertheory-labs.dev/loom/externals/externals.md` + +- source: https://git.hypertheory-labs.dev/loom/externals/raw/branch/main/.loom/published/externals.md +- what of ours depends on it: `.loom/externals/git.hypertheory-labs.dev/loom/externals/externals.usages.md` diff --git a/internal/orient/orient.go b/internal/orient/orient.go new file mode 100644 index 0000000..9050a6c --- /dev/null +++ b/internal/orient/orient.go @@ -0,0 +1,109 @@ +// Package orient generates a table of contents over what a repository depends +// on, for whoever arrives next — a person, or an agent of any make. +// +// It restates no rule. Every rule it might repeat is owned by a document already +// in the working tree, and a copy of a rule is a copy that goes stale: this file +// says only what is here, where it came from, and where the rules live. +// +// The output is byte-deterministic. A generated file that churns produces diffs +// nobody reads, and the diff is most of the value — it is how somebody sees that +// their dependencies moved. +package orient + +import ( + "fmt" + "os" + "path" + "path/filepath" + "strings" + + "git.hypertheory-labs.dev/loom/loom-cli/internal/lock" +) + +// File is where the orientation lives, relative to the repository root. +// +// Beside .loom/event-log.md rather than inside .loom/externals/, because +// everything in that directory is somebody else's document — which is what makes +// "do not edit these" a rule you can state in four words — and because `check` +// walks it and would report a generated file as an unlocked external forever. +const File = ".loom/orientation.md" + +const preamble = ` + +# What this repository depends on + +Copies of other people's documents are kept under ` + "`.loom/externals/`" + `, at a path +that says where each came from. **They are copies: do not edit them.** Anything +you want to say about one goes in a file *beside* it, never into it. + +Three moves, and each has a document that owns the rule: + +- **A copy is wrong, or you needed something it does not say** — write it in + ` + "`.gaps.md`" + ` beside the copy. +- **What of ours depends on a copy** — write it in ` + "`.usages.md`" + ` beside it. +- **A source changed** — ` + "`loomctl external check`" + ` says so and stages the new copy; + ` + "`loomctl external apply`" + ` takes it. Neither edits anything on its own. +` + +// Generate writes the orientation for the repository at root. +func Generate(root, out string) (string, error) { + locks, err := lock.Load(root) + if err != nil { + return "", err + } + var b strings.Builder + b.WriteString(preamble) + + recs := locks.All() // already ordered by path + if len(recs) == 0 { + b.WriteString("\nThis repository adopts nothing yet.\n") + } + for _, r := range recs { + fmt.Fprintf(&b, "\n## `%s`\n\n", r.Path) + fmt.Fprintf(&b, "- source: %s\n", r.URL) + facets := facetsFor(root, r.Path) + if u, ok := facets["usages"]; ok { + fmt.Fprintf(&b, "- what of ours depends on it: `%s`\n", u) + } else { + fmt.Fprintf(&b, "- what of ours depends on it: **nothing recorded** — no `.usages.md`\n") + } + if g, ok := facets["gaps"]; ok { + fmt.Fprintf(&b, "- what we expected and did not find: `%s`\n", g) + } + } + + if dir := filepath.Join(root, ".loom", "published"); isDir(dir) { + b.WriteString("\n## This repository publishes\n\n") + b.WriteString("See `.loom/published/`. What is handed over for others to depend on is\n") + b.WriteString("there; the rest of the repository is not hidden, it is simply not what\n") + b.WriteString("anybody depends on.\n") + } + + dest := out + if dest == "" { + dest = filepath.Join(root, filepath.FromSlash(File)) + } + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return "", err + } + return dest, os.WriteFile(dest, []byte(b.String()), 0o644) +} + +// 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)) + for _, kind := range []string{"usages", "gaps"} { + p := stem + "." + kind + ".md" + if _, err := os.Stat(filepath.Join(root, lock.Dir, filepath.FromSlash(p))); err == nil { + found[kind] = lock.Dir + "/" + p + } + } + return found +} + +func isDir(p string) bool { + fi, err := os.Stat(p) + return err == nil && fi.IsDir() +} diff --git a/internal/orient/orient_test.go b/internal/orient/orient_test.go new file mode 100644 index 0000000..6ee35f3 --- /dev/null +++ b/internal/orient/orient_test.go @@ -0,0 +1,73 @@ +package orient + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "git.hypertheory-labs.dev/loom/loom-cli/internal/lock" +) + +// A generated file that churns produces diffs nobody reads, and the diff is most +// of the value. +func TestGenerateIsDeterministic(t *testing.T) { + root := t.TempDir() + locks, err := lock.LoadFile(lock.Path(root)) + if err != nil { + t.Fatal(err) + } + // Inserted out of order on purpose: the output must not depend on it. + for _, r := range []lock.Record{ + {Path: "h/o/zeta/z.md", URL: "https://h/z", ETag: `"3"`}, + {Path: "h/o/alpha/a.md", URL: "https://h/a", ETag: `"1"`}, + {Path: "h/o/mid/m.md", URL: "https://h/m", ETag: `"2"`}, + } { + locks.Put(r) + } + if err := locks.Save(); err != nil { + t.Fatal(err) + } + + var first string + for i := 0; i < 3; i++ { + dest, err := Generate(root, filepath.Join(root, "out.md")) + if err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(dest) + if err != nil { + t.Fatal(err) + } + if i == 0 { + first = string(b) + continue + } + if string(b) != first { + t.Fatal("output changed between runs") + } + } + if a, z := strings.Index(first, "alpha"), strings.Index(first, "zeta"); a > z { + t.Error("entries are not ordered by path") + } + if strings.Contains(first, "publishes") { + t.Error("claimed the repository publishes with no .loom/published") + } +} + +func TestSaysWhenNothingRecordsADependency(t *testing.T) { + root := t.TempDir() + locks, _ := lock.LoadFile(lock.Path(root)) + locks.Put(lock.Record{Path: "h/o/r/doc.md", URL: "https://h/doc", ETag: `"1"`}) + if err := locks.Save(); err != nil { + t.Fatal(err) + } + dest, err := Generate(root, filepath.Join(root, "out.md")) + if err != nil { + t.Fatal(err) + } + b, _ := os.ReadFile(dest) + if !strings.Contains(string(b), "nothing recorded") { + t.Error("a document with no .usages.md should say so — it is a finding, not an omission") + } +} diff --git a/main.go b/main.go index 78e72d8..de561b2 100644 --- a/main.go +++ b/main.go @@ -17,6 +17,7 @@ import ( "git.hypertheory-labs.dev/loom/loom-cli/internal/config" "git.hypertheory-labs.dev/loom/loom-cli/internal/external" + "git.hypertheory-labs.dev/loom/loom-cli/internal/orient" ) const usage = `loomctl — fetch what you depend on, and find out when it changed. @@ -50,6 +51,7 @@ Adopted documents live in .loom/externals////.md, and their origins in .loom/externals/.locks. The path is for a person to read; the lock is what a machine uses, because the path does not round-trip to a URL. + loomctl orient [--out path] write .loom/orientation.md for whoever arrives next loomctl config which context is current, and from where A bare owner/repo is resolved against the current context in @@ -87,6 +89,22 @@ func run(args []string) error { switch args[0] { case "external": return runExternal(args[1:]) + case "orient": + fs := flag.NewFlagSet("orient", flag.ContinueOnError) + out := fs.String("out", "", "write here instead of "+orient.File) + if err := fs.Parse(args[1:]); err != nil { + return err + } + root, err := root() + if err != nil { + return err + } + dest, err := orient.Generate(root, *out) + if err != nil { + return err + } + fmt.Fprintf(os.Stdout, "wrote %s\n", dest) + return nil case "config": return showConfig(os.Stdout) default: