Catalogue: a module's needs include its templates' text (ADR 0632 §2; issue 1966)

docker's daemon file and artifact-store-tools' store address move to the image
seat's reach in their templates while their manifests stay the same, so needs
read from the manifest alone let an engine before the reading lose the file.
ModuleNeeds reads the templates TemplatePaths names; the merge message carries
the result per directory for the indexer.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
This commit is contained in:
2026-11-04 21:37:43 +01:00
parent ecdbf1a668
commit 2e4eb0be90
4 changed files with 75 additions and 7 deletions
+35 -6
View File
@@ -163,12 +163,8 @@ func ManifestNeeds(raw []byte) ([]string, error) {
return nil, err
}
used := map[string]bool{}
for _, f := range referenceForms {
if bytes.Contains(raw, []byte("${"+f+":")) {
used[ReadForm+f] = true
}
}
if ofSeatReach.Match(raw) || claimsAtMesh(top, ContainerImageRegistrySeat) {
textNeeds(raw, used)
if claimsAtMesh(top, ContainerImageRegistrySeat) {
used[ReadForm+ReferenceFormSeatReach] = true
}
if _, has := top[ConfigField]; has || takesConfigKey.Match(raw) {
@@ -429,6 +425,39 @@ func asList(v any) []any {
return list
}
// textNeeds adds the reference forms a manifest's or a template's text writes to used: a form the engine fills in a
// file's text is read there whether the text is inline or in a template file.
func textNeeds(text []byte, used map[string]bool) {
for _, f := range referenceForms {
if bytes.Contains(text, []byte("${"+f+":")) {
used[ReadForm+f] = true
}
}
if ofSeatReach.Match(text) {
used[ReadForm+ReferenceFormSeatReach] = true
}
}
// ModuleNeeds is ManifestNeeds of a module whose files take their text from templates (template.go): what the manifest
// needs and what each template's text writes, by template path (TemplatePaths), sorted. A module's needs are both: an
// engine renders the template's text as it renders inline content, so a template naming ${seat:x:reach} needs the
// reading as the same content inline does, though the manifest itself is unchanged (novox/hq ADR 0632 §2). A template
// named and not given is no need; the reader of the files says it could not read one.
func ModuleNeeds(raw []byte, templates map[string]string) ([]string, error) {
needs, err := ManifestNeeds(raw)
if err != nil || len(templates) == 0 {
return needs, err
}
used := map[string]bool{}
for _, n := range needs {
used[n] = true
}
for _, text := range templates {
textNeeds([]byte(text), used)
}
return sortedSet(used), nil
}
func sortedSet(set map[string]bool) []string {
out := make([]string, 0, len(set))
for k := range set {
+16 -1
View File
@@ -1,6 +1,7 @@
package catalogue
import (
"maps"
"os"
"slices"
"testing"
@@ -87,12 +88,26 @@ func TestOwnManifestUsesNoWordTheRunningControllerRefuses(t *testing.T) {
{"the store's holder", `{"module":"distribution","version":"1","claims":[{"name":"container-image-registry",` +
`"scope":"mesh","serves":["list"]}],"listens":[{"name":"registry","port":5000,"from":"mesh"}]}`,
[]string{"form:seat-reach", "seat:container-image-registry"}, []string{"form:seat-reach", "seat:container-image-registry"}},
// docker's daemon file as ADR 0632 leaves it: the manifest unchanged, its template naming the image seat's reach;
// TemplatePaths names the template a reader of the files reads.
{"a template naming a seat's reach", `{"module":"d","version":"1","resources":[{"id":"daemon","type":"file",` +
`"path":"/etc/d.json","template":"files/daemon.json"}]}`, []string{"form:seat", "form:seat-reach"},
[]string{"form:seat-reach"}},
{"a word this contract does not know", `{"module":"u","version":"1","newer-word":{"inside":1}}`, []string{"word:module"}, nil},
} {
needs, err := ManifestNeeds([]byte(row.raw))
templates := map[string]map[string]string{ // the templates' text a row's module names
"a template naming a seat's reach": {"files/daemon.json": `{"x":"${seat:container-image-registry:reach}"}`},
}[row.why]
needs, err := ModuleNeeds([]byte(row.raw), templates)
if err != nil {
t.Fatalf("%s: %v", row.why, err)
}
if templates != nil {
m, err := Decode([]byte(row.raw))
if paths := TemplatePaths(m); err != nil || !slices.Equal(paths, slices.Sorted(maps.Keys(templates))) {
t.Errorf("%s: TemplatePaths = %v (%v), want %v", row.why, paths, err, slices.Sorted(maps.Keys(templates)))
}
}
for _, n := range row.needs {
if !slices.Contains(needs, n) {
t.Errorf("%s: needs %v, not %s", row.why, needs, n)
+20
View File
@@ -8,6 +8,7 @@ import (
"os"
"path"
"path/filepath"
"slices"
"strings"
"unicode/utf8"
)
@@ -184,6 +185,25 @@ func fillItemTemplates(m Manifest, items []Item, text func(string) (string, bool
return out, nil
}
// TemplatePaths is every template the manifest's files and items name, by its path in the module's directory, sorted
// and once each: what a reader of the module's files reads for ModuleNeeds. A template whose name is wrong is left out;
// the judge refuses it by name.
func TemplatePaths(m Manifest) []string {
var out []string
for _, r := range m.Resources {
if name, has, wrong := templateOf(m.Module, m.TemplatesDir(), r); has && wrong == "" {
out = append(out, name)
}
}
for _, it := range m.Items {
if it.Template != "" && itemTemplateProblem(m, it.Template) == "" {
out = append(out, it.Template)
}
}
slices.Sort(out)
return slices.Compact(out)
}
// ReadTemplates is the text of each template the manifest names, read from the module's directory in a repository tree,
// by template path. Refused by name: a template the directory lacks, and anything under its templates directory
// (TemplatesDir) the build's packing refuses — that directory itself, or any directory or file under it, that is a
+4
View File
@@ -594,6 +594,10 @@ type Forge struct {
// relative to it (novox/hq ADR 0465 clause 1). Older writers send neither.
Manifests map[string]string `json:"manifests,omitempty"`
Locks map[string]map[string]string `json:"locks,omitempty"`
// Needs are, of each such directory whose manifest names templates, the reader capabilities of the module whole:
// its manifest's and its templates' text's at the same commit (catalogue.ModuleNeeds; novox/hq ADR 0632 §2). The
// indexer takes them over the manifest's own; a directory absent, or an older writer, has the manifest's alone.
Needs map[string][]string `json:"needs,omitempty"`
// Paths are, of each module directory whose manifest names paths on every artifact, each named path's git object
// id at the same commit as its manifest, by path relative to the directory (novox/hq ADR 0499 clause 3). The
// indexer names the paths; a message without them gives such a module none. Older writers send none.