Refuse a reference into the category it sits in, and run a record's column fences at load
Tests / vet + fmt + tests (pull_request) Successful in 1m8s

This commit is contained in:
2026-09-16 09:42:36 +02:00
parent 3e535bcc43
commit a995aba4e9
6 changed files with 58 additions and 36 deletions
+15 -6
View File
@@ -131,9 +131,10 @@ columns that read a path into one category — `{/currency.code}` and
record may not overlap — `{/cat.a}`, or a bare `{/cat}`, beside
`{/cat.a.b}` is refused, naming the fields to write instead, as
[One draw, one spelling](#one-draw-one-spelling) refuses that pair inside a single
format. A column may not reference the record it belongs to by any spelling: `{/users.first}`
or a bare `{/users}` inside `users` describes a draw other than the columns beside
it, so put a value two columns share in its own category and reference that. A field hold, transform or
format. Both fences run at load, so a category that loads renders as either shape. A
category never references itself — `{/users.first}` or a bare `{/users}` inside `users`
describes a draw other than the fields beside it — so read a sibling as a path, and put
a value two fields share in its own category and reference that. A field hold, transform or
operand ties fields together within one column as always (see
[Correlated fields](#correlated-fields) and [Decisions](#decisions)).
@@ -447,9 +448,9 @@ a render of its own, in no group, so it draws anew, and a [draw group](#draw-gro
draw apart. A bare reference names no field and makes its own picks each time —
`{/misc.uuid} {/misc.uuid}` is two draws — while the reference paths inside what it
renders still read the render's draws. Rejected at `New`: a path that is
unknown, names a folder, has no folder above, or reads a field not every variant
of a choice carries, and a reference that leads back to its own value, directly,
mutually or through a chain.
unknown, names a folder, has no folder above, reads a field not every variant
of a choice carries, or names the category the reference sits in, and a reference
that leads back to its own value, directly, mutually or through a chain.
### Draw group
@@ -690,6 +691,14 @@ tokens add cost in proportion to the output.
different things — an operand pins the value its own render produced and stops at a
reference, a path pins every level it passes through — so one walk would carry both
rules and both scopes anyway, and tell them apart at every step.
- **A category never references itself, and a record's fences run at load.** A category
is one unit: a reference back into it — `{/users.first}` inside `users` — describes a
draw other than the fields beside it, so `New` refuses it and the sibling path stays
the one spelling for a field of one's own. A value two fields share goes in its own
category, which both reference. That settled, a record's column fences run at `New`
too, so a category that loads renders as whichever shape is asked for, and a reference
reaching back into a category through another one is refused there as the overlap it
is.
- **A record's column set is fixed before the first draw.** Only a category-level
template is a record: a path descending into a field, or naming a folder or a
choice, errors. A tail may pass through a choice whose variants carry different
+28 -20
View File
@@ -133,7 +133,7 @@ func (c *drawCheck) checkDraws(path string, n node) error {
if !ok || !c.readsPath(t) {
return nil
}
w := newDrawWalk(nil)
w := newDrawWalk()
for _, e := range renderEdges(t) {
w.edge(t, e, drawAt{group: t.drawGroupKey, route: drawRoute{e.reached(), e.label}})
}
@@ -143,6 +143,23 @@ func (c *drawCheck) checkDraws(path string, n node) error {
return nil
}
// checkRecordDraws fences the columns of a record — a template compiled at the top without a
// repeat — so a load proves the record view of it as well as the string view.
func (c *drawCheck) checkRecordDraws(path string, n node) error {
t, ok := n.(*template)
if !ok || !t.record {
return nil
}
columns := recordColumns(t)
if len(columns) == 0 {
return nil
}
if err := checkColumnDraws(t, columns); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
return nil
}
// readsPath reports whether rendering n reads a reference path, short of a repeat, which renders
// over draws of its own.
func (c *drawCheck) readsPath(n node) bool {
@@ -206,7 +223,7 @@ func refRead(n node, label string) (arm, node, bool) {
// checkColumnDraws fences a record's columns as one render.
func checkColumnDraws(t *template, columns []string) error {
w := newDrawWalk(t)
w := newDrawWalk()
for _, name := range columns {
w.walk(t.fields[name], drawAt{group: t.drawGroupKey, route: drawRoute{spelling: fmt.Sprintf("column %q", name)}})
}
@@ -214,14 +231,12 @@ func checkColumnDraws(t *template, columns []string) error {
}
// drawWalk gathers what one render reads by reference and what it draws afresh, each by draw group,
// for check to compare. record is set for a record's columns, which may not read the record back.
// for check to compare.
type drawWalk struct {
record *template
reads []pathRead
read map[drawKey]bool
fresh []freshDraw
seen map[drawVisit]bool
err error
reads []pathRead
read map[drawKey]bool
fresh []freshDraw
seen map[drawVisit]bool
}
// drawAt is where a walk stands: the draw group it draws in, how the render's root reached it, the
@@ -259,15 +274,15 @@ type drawKey struct {
key any
}
func newDrawWalk(record *template) *drawWalk {
return &drawWalk{record: record, read: map[drawKey]bool{}, seen: map[drawVisit]bool{}}
func newDrawWalk() *drawWalk {
return &drawWalk{read: map[drawKey]bool{}, seen: map[drawVisit]bool{}}
}
// walk follows what rendering n renders. A repeat renders over draws of its own, so the walk stops
// there.
func (w *drawWalk) walk(n node, at drawAt) {
v := drawVisit{n, at.group, at.held}
if w.seen[v] || w.err != nil {
if w.seen[v] {
return
}
w.seen[v] = true
@@ -287,13 +302,9 @@ func (w *drawWalk) walk(n node, at drawAt) {
func (w *drawWalk) edge(from node, e renderEdge, at drawAt) {
a, target, reads := refRead(from, e.label)
switch {
case !reads:
if !reads {
w.walk(e.to, at)
return
case w.record != nil && target == node(w.record):
w.err = fmt.Errorf("%s reads {%s}, which points back at this record; a column cannot read another column — move the shared value into its own category and reference that", at.route.spelling, a.name)
return
}
if k := (drawKey{at.group, a.path}); !w.read[k] {
w.read[k] = true
@@ -306,9 +317,6 @@ func (w *drawWalk) edge(from node, e renderEdge, at drawAt) {
// check refuses what one draw per reference path cannot answer for, reads compared in path order so
// which pair is reported does not vary.
func (w *drawWalk) check() error {
if w.err != nil {
return w.err
}
sort.SliceStable(w.reads, func(i, j int) bool {
if w.reads[i].at.group != w.reads[j].at.group {
return w.reads[i].at.group < w.reads[j].at.group
+3
View File
@@ -203,6 +203,9 @@ func checkRenders(s nodeScope) error {
if err := s(fence.checkDraws); err != nil {
return err
}
if err := s(fence.checkRecordDraws); err != nil {
return err
}
return s((&valueProof{}).checkDatatype)
}
+1 -1
View File
@@ -164,6 +164,6 @@ func linkNodeRefs(scope nodeScope, root map[string]node) error {
return fmt.Errorf("%s: reference {%s}: an inline template has no folder; write {/%s}", path, name, rest)
}
}
return linkTemplateRefs(nil, path, t, root)
return linkTemplateRefs(nil, path, "", t, root)
})
}
-3
View File
@@ -211,9 +211,6 @@ func recordOf(n node) (*template, []Column, error) {
if !t.record {
return nil, nil, fmt.Errorf("carries repeat %d, which composes its format into one string; a record projects columns instead — drop the repeat and render the record again for more rows", t.repeat)
}
if err := checkColumnDraws(t, names); err != nil {
return nil, nil, err
}
columns := make([]Column, len(names))
for i, name := range names {
datatype, _ := columnDatatype(t.fields[name]) // checkColumns refused items that disagree wherever DataType is read
+11 -6
View File
@@ -72,15 +72,17 @@ func refSegments(name string, folder []string) ([]string, error) {
// error, never a random render-time one.
func linkRefs(root map[string]node) error {
return eachTemplate(root, func(folder []string, path string, t *template) error {
t.keyDrawGroup(strings.Join(strings.Split(path, ".")[:len(folder)+1], "."))
return linkTemplateRefs(folder, path, t, root)
category := strings.Join(strings.Split(path, ".")[:len(folder)+1], ".")
t.keyDrawGroup(category)
return linkTemplateRefs(folder, path, category, t, root)
})
}
// linkTemplateRefs binds one template's references against root. A template with
// none is left untouched, so an inline format that references nothing costs only
// the refTokens scan.
func linkTemplateRefs(folder []string, path string, t *template, root map[string]node) error {
// linkTemplateRefs binds one template's references against root, refusing one that names the
// category it sits in: a category is a unit, and a reference back into it describes a draw other
// than the fields beside it. A template with no reference is left untouched, so an inline format
// that references nothing costs only the refTokens scan.
func linkTemplateRefs(folder []string, path, category string, t *template, root map[string]node) error {
names := refTokens(t.format)
if len(names) == 0 {
return nil
@@ -99,6 +101,9 @@ func linkTemplateRefs(folder []string, path string, t *template, root map[string
return fmt.Errorf("%s: reference {%s}: %w", path, name, err)
}
key := "/" + strings.Join(head, ".")
if category != "" && key == "/"+category {
return fmt.Errorf("%s: reference {%s}: names the category it sits in; read a sibling field as a path, or move the shared value into its own category and reference that", path, name)
}
if err := checkPath(target, tail, key); err != nil {
return fmt.Errorf("%s: reference {%s}: %w", path, name, err)
}