From 276f6755abc1401cf0cd04e55f7565c2e4d08879 Mon Sep 17 00:00:00 2001 From: mesh-admin Date: Tue, 13 Oct 2026 23:58:16 +0000 Subject: [PATCH] Let a file resource name a template file in its module's repository: the catalogue's reading of templates Co-Authored-By: Claude Opus 5.5 --- internal/catalogue/template.go | 274 +++++++++++++++++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 internal/catalogue/template.go diff --git a/internal/catalogue/template.go b/internal/catalogue/template.go new file mode 100644 index 00000000..ac785e2a --- /dev/null +++ b/internal/catalogue/template.go @@ -0,0 +1,274 @@ +package catalogue + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" + "unicode/utf8" +) + +// A file's text kept as a template file in its module's repository (novox/hq ADR 0349 §2, design 57 package T1, +// issue 654). +// +// **Two forms, one composition.** A file resource says its text inline (`content`) or names a template file +// beside its manifest (`"template": "files/"`), never both. The module's build packs `files/` into an +// archive artifact of its own — an `archive` artifact built `from: "files"` — and resolving the build reads each +// template's text back from that packed archive into `content` and drops `template`. So the manifest the mesh +// holds, composition and the node-engine are exactly as they were: a module with its text in `files/` composes +// byte-identically to the same module with it inline. Where there is no build — `module check`, the merge gate, +// `module add` — the text is read from the repository tree beside the manifest (WithTemplatesFrom). +// +// **Read back by the builder, carried in the resolved manifest.** The builder reads each template from the very +// bytes it hashed into the archive's digest, so the text the mesh holds is the archive's. The controller +// registers the resolved manifest as the build result carries it and fetches nothing from the artifact store; a +// manifest that still names a template when it would be held — built by a builder that predates templates — is +// refused at registration (HeldTemplateProblem). + +// TemplateField is the key a file resource names its template file by. +const TemplateField = "template" + +// TemplatesDir is the module's directory its template files are kept in, and the directory its templates archive +// is built from. +const TemplatesDir = "files" + +// Templated is whether any of the manifest's resources names a template. +func (m Manifest) Templated() bool { + for _, r := range m.Resources { + if _, has := r[TemplateField]; has { + return true + } + } + return false +} + +// TemplatesArtifact is the name of the archive artifact the module packs its templates into — the one built from +// files/ — or "" when it declares none, or more than one (both refused by templateProblems for a module naming a +// template). +func TemplatesArtifact(m Manifest) string { + found := templatesArtifacts(m) + if len(found) != 1 { + return "" + } + return found[0] +} + +func templatesArtifacts(m Manifest) []string { + if m.Build == nil { + return nil + } + var found []string + for _, a := range m.Build.Artifacts { + if a.Kind == ArtifactArchive && a.URL == "" && cleanFrom(a.From) == TemplatesDir { + found = append(found, a.Name) + } + } + return found +} + +// cleanFrom is an artifact's directory as one relative path: "./files/" is "files". +func cleanFrom(from string) string { + c := path.Clean(strings.TrimSpace(from)) + return strings.TrimPrefix(c, "./") +} + +// templateOf is the template a resource names, whether it names one, and what is wrong with it ("" when nothing). +func templateOf(module string, r map[string]any) (string, bool, string) { + raw, has := r[TemplateField] + if !has { + return "", false, "" + } + id := fmt.Sprint(r["id"]) + if t := fmt.Sprint(r["type"]); t != "file" { + return "", true, fmt.Sprintf("%s: %s is a %s and names a template; only a file takes its text from one "+ + "(novox/hq ADR 0349 §2)", module, id, t) + } + name, ok := raw.(string) + if !ok || strings.TrimSpace(name) == "" { + return "", true, fmt.Sprintf("%s: %s names a template that is not a path: %v; a template is %q", + module, id, raw, TemplatesDir+"/") + } + if _, both := r["content"]; both { + return name, true, fmt.Sprintf("%s: %s names the template %q and says its content too; a file's text is "+ + "in one place, the template or the content, never both (novox/hq ADR 0349 §2)", module, id, name) + } + if strings.HasPrefix(name, "/") || path.Clean(name) != name || !strings.HasPrefix(name, TemplatesDir+"/") || + strings.Contains(name, "\\") { + return name, true, fmt.Sprintf("%s: %s names the template %q, which is not a file under %s/ of its own "+ + "repository: a template is %q, a plain relative path, and the build packs only %s/", + module, id, name, TemplatesDir, TemplatesDir+"/", TemplatesDir) + } + return name, true, "" +} + +// templateProblems is what is wrong with the manifest's templates, as written: a template on something not a file, +// beside content, outside files/, or a module naming templates whose build packs no archive from files/. Whether +// each template exists is judged where a tree is (ReadTemplates) or the archive's text is (Resolve). +func templateProblems(m Manifest) []string { + var problems []string + templated := false + for _, r := range m.Resources { + _, has, wrong := templateOf(m.Module, r) + templated = templated || has + if wrong != "" { + problems = append(problems, wrong) + } + } + if !templated { + return problems + } + switch found := templatesArtifacts(m); len(found) { + case 0: + problems = append(problems, fmt.Sprintf("%s names templates and its build packs no archive from %s/: "+ + "declare an artifact {\"name\": \"files\", \"kind\": \"archive\", \"from\": %q} (novox/hq ADR 0349 §2)", + m.Module, TemplatesDir, TemplatesDir)) + case 1: + default: + sort.Strings(found) + problems = append(problems, fmt.Sprintf("%s packs %s/ into %s; its templates are read from one archive", + m.Module, TemplatesDir, strings.Join(found, " and "))) + } + return problems +} + +// fillTemplates is resources with each template's text as its content and the template key dropped: text says the +// text of a template by its path ("files/"), and whether it has one. A template it has no text for, or text +// that is not UTF-8, is refused by name. Every resource is copied; the manifest's own are left as they were. +func fillTemplates(module string, resources []map[string]any, text func(string) (string, bool), + where string) ([]map[string]any, error) { + out := make([]map[string]any, 0, len(resources)) + var problems []string + for _, r := range resources { + name, has, wrong := templateOf(module, r) + if !has { + out = append(out, r) + continue + } + if wrong != "" { + problems = append(problems, wrong) + continue + } + body, ok := text(name) + if !ok { + problems = append(problems, fmt.Sprintf("%s: %v names the template %q, and %s holds no such file", + module, r["id"], name, where)) + continue + } + if !utf8.ValidString(body) { + // A manifest is JSON: text that is not UTF-8 would be changed on the way, silently. + problems = append(problems, fmt.Sprintf("%s: %v names the template %q, which is not UTF-8 text; a "+ + "file's text is carried as JSON and would not reach the machine as written", module, r["id"], name)) + continue + } + filled := make(map[string]any, len(r)) + for k, v := range r { + filled[k] = v + } + delete(filled, TemplateField) + filled["content"] = body + out = append(out, filled) + } + if len(problems) > 0 { + return nil, errors.New(strings.Join(problems, "; ")) + } + return out, nil +} + +// ReadTemplates is the text of each template the manifest names, read from the module's directory in a repository +// tree, by template path. A missing template, or one that is not a plain file (a symlink is refused by the build's +// packing too), is refused by name. +func ReadTemplates(m Manifest, dir string) (map[string]string, error) { + texts := map[string]string{} + var problems []string + for _, r := range m.Resources { + name, has, wrong := templateOf(m.Module, r) + if !has { + continue + } + if wrong != "" { + problems = append(problems, wrong) + continue + } + if _, read := texts[name]; read { + continue + } + full := filepath.Join(dir, filepath.FromSlash(name)) + info, err := os.Lstat(full) + switch { + case errors.Is(err, fs.ErrNotExist): + problems = append(problems, fmt.Sprintf("%s: %v names the template %q, and the module's directory "+ + "has no such file", m.Module, r["id"], name)) + continue + case err != nil: + problems = append(problems, fmt.Sprintf("%s: %v names the template %q, which cannot be read: %v", + m.Module, r["id"], name, err)) + continue + case !info.Mode().IsRegular(): + problems = append(problems, fmt.Sprintf("%s: %v names the template %q, which is not a plain file; "+ + "the build packs only files", m.Module, r["id"], name)) + continue + } + body, err := os.ReadFile(full) + if err != nil { + problems = append(problems, fmt.Sprintf("%s: %v names the template %q, which cannot be read: %v", + m.Module, r["id"], name, err)) + continue + } + texts[name] = string(body) + } + if len(problems) > 0 { + return nil, errors.New(strings.Join(problems, "; ")) + } + return texts, nil +} + +// WithTemplatesFrom is the manifest with each template's text read from the module's directory in a repository +// tree as its content: what `module check`, the merge gate and `module add` judge, where nothing was built. Read +// again strictly afterwards, so every rule about a file's text judges the template's text exactly as it judges +// inline content. A manifest naming no template is returned as it is. +func WithTemplatesFrom(m Manifest, dir string) (Manifest, error) { + if !m.Templated() { + return m, nil + } + texts, err := ReadTemplates(m, dir) + if err != nil { + return Manifest{}, err + } + filled, err := fillTemplates(m.Module, m.Resources, func(name string) (string, bool) { + t, ok := texts[name] + return t, ok + }, "the module's directory") + if err != nil { + return Manifest{}, err + } + out := m + out.Resources = filled + raw, err := json.Marshal(out) + if err != nil { + return Manifest{}, err + } + return ParseManifest(raw) +} + +// HeldTemplateProblem is why a manifest cannot be held by the mesh because it still names a template, or "": the +// manifest the mesh holds carries every file's text, which a build reads back from its templates archive. One +// naming a template was resolved by a builder that does not read templates, and would compose files with no text. +func HeldTemplateProblem(m Manifest) string { + var named []string + for _, r := range m.Resources { + if t, has := r[TemplateField]; has { + named = append(named, fmt.Sprintf("%v (%v)", r["id"], t)) + } + } + if len(named) == 0 { + return "" + } + return fmt.Sprintf("%s still names the template(s) of %s: the manifest the mesh holds carries each file's "+ + "text, read back from the build's templates archive — this one was resolved by a builder that does not "+ + "read templates, or not built at all (novox/hq ADR 0349 §2)", m.Module, strings.Join(named, ", ")) +}