aontu 0.59.0 → 0.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/format.ts CHANGED
@@ -26,6 +26,7 @@ import { Aontu } from './aontu'
26
26
  import { failureFinding } from './vet'
27
27
  import type { VetFinding } from './vet'
28
28
  import type { Resolver } from './type'
29
+ import { desugarTemplate, resugarTemplate, templateOutputs } from './template'
29
30
 
30
31
 
31
32
  // The packing budget (§3.1). It decides which of two legal spellings
@@ -46,6 +47,12 @@ export type FormatOptions = {
46
47
  // Report the style findings of §4 -- key case, repeated shapes --
47
48
  // beside the text. The formatter never acts on them.
48
49
  lint?: boolean
50
+ // THE SOURCE IS A GENERATOR, and this is its marker
51
+ // (docs/design/TEMPLATE.0.md; FMT.0.md §3.14). The file is desugared,
52
+ // formatted and resugared, so `text` is a template again -- the aontu
53
+ // in the agreed form, indented after the marker, and every line of
54
+ // output exactly where it was.
55
+ template?: string
49
56
  }
50
57
 
51
58
  // A style finding (§4): what the formatter points at and never
@@ -183,6 +190,10 @@ type Node = {
183
190
 
184
191
  // The source index of the node's first token: the lint's positions.
185
192
  at?: number
193
+
194
+ // The node begins a line of the target's own file (§3.14), so it has
195
+ // no one-line form and every container holding it opens.
196
+ held?: boolean
186
197
  }
187
198
 
188
199
  const BINARY: Record<string, boolean> = { '#E&': true, '#E|': true, '#E+': true }
@@ -608,7 +619,7 @@ function pairHead(node: Node, tight: boolean): string {
608
619
  // lines. `tight` is the inline form of a pair, `a:1`, used inside a
609
620
  // container; a statement's pair is `a: 1`.
610
621
  function inline(node: Node, tight: boolean): string | undefined {
611
- if (undefined !== node.trail) {
622
+ if (undefined !== node.trail || node.held) {
612
623
  return undefined
613
624
  }
614
625
  switch (node.t) {
@@ -744,6 +755,15 @@ class Writer {
744
755
  return this.lines.slice(mark).concat([this.line]).map(rtrim).join('\n') + '\n'
745
756
  }
746
757
 
758
+ // A blank line above the line at an index: the gap of §3.8, opened
759
+ // once the statement below it turns out to be a tree. Never at the
760
+ // top of the page, and never a second time.
761
+ gap(at: number): void {
762
+ if (0 < at && '' !== this.lines[at - 1]) {
763
+ this.lines.splice(at, 0, '')
764
+ }
765
+ }
766
+
747
767
  // The lines since a mark replaced by a text: the spelling before,
748
768
  // where a rewrite did not pass its check.
749
769
  replace(mark: number, text: string): void {
@@ -776,29 +796,57 @@ function rtrim(s: string): string {
776
796
  // body of a plain map that is itself the value of a statement) a pair
777
797
  // is laid out by §3.4, which may repeat its key; anywhere else -- a
778
798
  // list, an operand, an argument -- by §3.5 alone.
779
- function emitBody(w: Writer, body: Node[], indent: number, stmt: Stmt | undefined): void {
799
+ function emitBody(
800
+ w: Writer, body: Node[], indent: number, stmt: Stmt | undefined, root?: boolean
801
+ ): void {
780
802
  let pending = false
781
803
  let count = 0
804
+ // Where the run being written begins: a statement, with the comments
805
+ // standing directly above it, so that the gap below opens ABOVE the
806
+ // comments rather than between them and what they describe. A blank
807
+ // line ends a run -- comments across a gap belong to what is above.
808
+ let head = 0
809
+ let noted = false
782
810
  for (const node of body) {
783
811
  if ('blank' === node.t) {
784
812
  pending = 0 < count
785
813
  continue
786
814
  }
815
+ const gapped = pending
787
816
  w.open(indent, pending)
817
+ if (gapped || !noted) {
818
+ head = w.mark()
819
+ }
788
820
  pending = false
789
821
  count++
790
822
  if ('comment' === node.t) {
823
+ noted = true
791
824
  w.text(node.text!)
792
825
  continue
793
826
  }
827
+ noted = false
828
+ const from = w.mark()
794
829
  if (undefined !== stmt && 'pair' === node.t) {
795
830
  emitStatement(w, node, indent, stmt, '')
796
- continue
797
831
  }
798
- const e = chain(node)
799
- emitValue(w, e, indent)
800
- if (undefined !== e.trail) {
801
- w.text(' ' + e.trail)
832
+ else {
833
+ const e = chain(node)
834
+ emitValue(w, e, indent)
835
+ if (undefined !== e.trail) {
836
+ w.text(' ' + e.trail)
837
+ }
838
+ }
839
+ // A TOP-LEVEL STATEMENT WRITTEN AS A TREE STANDS APART (§3.8). A
840
+ // document states several things -- a service, then its entities,
841
+ // then its errors -- and where one of them is a tree rather than a
842
+ // line, the eye finds it by the space around it. What counts as a
843
+ // tree is measured rather than guessed: the statement took more
844
+ // than one line to write. So `a: 1` beside `b: 2` is left alone,
845
+ // and this rule cannot fire below the root, where a blank line is
846
+ // the author's (§3.8) and nothing else.
847
+ if (root && from < w.mark()) {
848
+ w.gap(head)
849
+ pending = true
802
850
  }
803
851
  }
804
852
  }
@@ -1282,7 +1330,7 @@ function emitAt(nodes: Node[], indent: number): string {
1282
1330
  function emit(root: Node[], meet: Meet | undefined): string {
1283
1331
  const w = new Writer()
1284
1332
  emitBody(w, undefined === meet ? root : mergeRuns(root), 0,
1285
- undefined === meet ? undefined : { meet, covered: false })
1333
+ undefined === meet ? undefined : { meet, covered: false }, true)
1286
1334
  return w.finish()
1287
1335
  }
1288
1336
 
@@ -1516,13 +1564,66 @@ function checkFinding(path: string | undefined, expected: string, actual: string
1516
1564
  }
1517
1565
  }
1518
1566
 
1567
+ // THE TARGET'S OWN LINES ARE HELD ON LINES OF THEIR OWN (§3.14). The
1568
+ // desugaring is line for line, so a line of output is known by the
1569
+ // offset it begins at, and the node beginning there -- the quoted
1570
+ // string the desugaring wrote -- is marked. From there on it has no
1571
+ // one-line form, so every container holding it opens, and no two lines
1572
+ // of the generated file are ever packed onto one.
1573
+ function holdOutput(nodes: Node[], at: Set<number>): void {
1574
+ for (const node of nodes) {
1575
+ if (undefined !== node.at && at.has(node.at)) {
1576
+ node.held = true
1577
+ }
1578
+ holdOutput(node.body ?? [], at)
1579
+ holdOutput(node.args ?? [], at)
1580
+ holdOutput(node.inner ?? [], at)
1581
+ holdOutput(node.items ?? [], at)
1582
+ if (undefined !== node.value) {
1583
+ holdOutput([node.value], at)
1584
+ }
1585
+ }
1586
+ }
1587
+
1588
+
1589
+ // The offsets the flagged lines of a document begin at.
1590
+ function outputAt(doc: string, flags: boolean[]): Set<number> {
1591
+ const at = new Set<number>()
1592
+ let off = 0
1593
+ const lines = doc.split('\n')
1594
+ for (let k = 0; k < lines.length; k++) {
1595
+ if (flags[k]) {
1596
+ at.add(off)
1597
+ }
1598
+ off += lines[k].length + 1
1599
+ }
1600
+ return at
1601
+ }
1602
+
1603
+
1604
+ // A finding's column in the TEMPLATE rather than in the document it
1605
+ // carries (§3.14): the marker and its one space stand before the aontu
1606
+ // on every line the resugaring writes. Every finding is on such a
1607
+ // line -- the two rules point at a key or at a container, and a line of
1608
+ // output is a bare string, which is neither.
1609
+ function shiftFindings(findings: LintFinding[], mark: string | undefined): LintFinding[] {
1610
+ return undefined === mark ? findings :
1611
+ findings.map((f) => ({ ...f, col: f.col + mark.length + 1 }))
1612
+ }
1613
+
1614
+
1519
1615
  // Format one document. The text is the agreed form of the source;
1520
1616
  // `changed` says whether it differs from what was given, which is
1521
1617
  // what `--check` and `--list` report.
1522
1618
  export function format(src: string, opts?: FormatOptions, hooks?: FormatHooks): FormatReport {
1523
1619
  const text = lf(src)
1620
+ // A GENERATOR IS FORMATTED AS THE DOCUMENT IT CARRIES (§3.14): the
1621
+ // template surface's two transforms stand either side of the
1622
+ // formatter, and between them is what happens to any other document.
1623
+ const mark = opts?.template
1624
+ const doc = undefined === mark ? text : desugarTemplate(text, mark)
1524
1625
  const toks: Tok[] = []
1525
- const parsed = parseDoc(text, opts?.path, toks)
1626
+ const parsed = parseDoc(doc, opts?.path, toks)
1526
1627
  if (undefined !== parsed.errors) {
1527
1628
  return { verdict: 'error', errors: parsed.errors }
1528
1629
  }
@@ -1531,6 +1632,9 @@ export function format(src: string, opts?: FormatOptions, hooks?: FormatHooks):
1531
1632
  if (reader.deep) {
1532
1633
  return { verdict: 'error', errors: [depthFinding()] }
1533
1634
  }
1635
+ if (undefined !== mark) {
1636
+ holdOutput(root, outputAt(doc, templateOutputs(text, mark)))
1637
+ }
1534
1638
  // The syntactic tier first, checked against the parse tree; then the
1535
1639
  // lawful tier over it, each rewrite checked by the meet.
1536
1640
  const plain = emit(root, undefined)
@@ -1542,9 +1646,10 @@ export function format(src: string, opts?: FormatOptions, hooks?: FormatHooks):
1542
1646
  }
1543
1647
  }
1544
1648
  const out = emit(root, hooks?.meet ?? sameByMeet)
1649
+ const done = undefined === mark ? out : resugarTemplate(out, mark)
1545
1650
  return {
1546
- verdict: 'formatted', text: out, changed: out !== src,
1547
- findings: opts?.lint ? lintOf(root, text) : [],
1651
+ verdict: 'formatted', text: done, changed: done !== src,
1652
+ findings: opts?.lint ? shiftFindings(lintOf(root, doc), mark) : [],
1548
1653
  }
1549
1654
  }
1550
1655
 
package/src/hcanon.ts CHANGED
@@ -16,6 +16,13 @@
16
16
  // (test/spec/hcanon.tsv). User-facing canon is UNCHANGED — hcanon is a
17
17
  // separate rendering.
18
18
  //
19
+ // An ALIAS REFERENCE left standing in a spread template renders its
20
+ // EXPANSION through this same walk rather than through the reference's
21
+ // own canon, so a close() or a mark on the aliased value survives into
22
+ // the hash. Without that the wrappers above are absent exactly where
23
+ // the alias filter has already erased the declaration that carried
24
+ // them (BUGS.md §60).
25
+ //
19
26
  // The marks PROPAGATE to every descendant at unification (walkMark), so
20
27
  // a wrapper is emitted only where a mark STARTS: the walk carries the
21
28
  // inherited marks down and a child whose mark the parent already
@@ -49,10 +56,12 @@ type HMarks = {
49
56
  // One node's rendering, carrying the marks the ANCESTORS already
50
57
  // wrapped. Bags and junctions recurse structurally (their canon getters
51
58
  // render children through plain canon, which would drop a nested
52
- // close); everything else — scalars, kinds, funcs, refs, constraints —
53
- // delegates to its own canon, whose text is already in cross-port
54
- // parity. The non-Val arm mirrors MapVal.canon's raw-peg fallback and
55
- // is unreachable through an evaluated tree (direct-tested, ADR-002).
59
+ // close), and so does an expanded alias reference, for the reason its
60
+ // own arm gives. Everything else — scalars, kinds, funcs, constraints,
61
+ // and a reference with nothing to expand — delegates to its own canon,
62
+ // whose text is already in cross-port parity. The non-Val arm mirrors
63
+ // MapVal.canon's raw-peg fallback and is unreachable through an
64
+ // evaluated tree (direct-tested, ADR-002).
56
65
  function render(v: any, inh: HMarks): string {
57
66
  if (true !== v?.isVal) {
58
67
  return String(v)
@@ -106,6 +115,25 @@ function render(v: any, inh: HMarks): string {
106
115
  else if (true === v.isConjunct || true === v.isDisjunct) {
107
116
  s = junctionText(v, true === v.isConjunct ? '&' : '|', inner)
108
117
  }
118
+ // AN EXPANDED ALIAS REFERENCE IS RENDERED, NOT DELEGATED (BUGS.md
119
+ // §60). A reference standing after unification is one inside a
120
+ // spread template, and `RefVal.canon` answers with the EXPANSION's
121
+ // plain canon -- which drops exactly what this renderer exists to
122
+ // keep. The declaration carrying `close()` or a mark is erased by
123
+ // the alias filter above, so a `close()` lost here is lost from the
124
+ // hash entirely: `%A = close({n:string})` and `%A = {n:string}`,
125
+ // used as `box: [&: %A]`, hashed to ONE STRING while refusing and
126
+ // admitting `{n:"x",z:1}` respectively -- a change of meaning the
127
+ // pin reported as no change, in the unsafe direction. Recursing
128
+ // through `inner` is what makes the alias form and its longhand
129
+ // twin agree again, which is ALIASES.0.md §4's own requirement.
130
+ //
131
+ // A reference with NO expansion still spells its name: a plain
132
+ // `$.A` names a key the hash form still carries in full, and the
133
+ // knot of a recursive alias inside its own template never gets one.
134
+ else if (true === v.isRef && undefined !== v.expansion) {
135
+ s = render(v.expansion, inner)
136
+ }
109
137
  else {
110
138
  s = v.canon
111
139
  }
package/src/std.ts CHANGED
@@ -186,6 +186,7 @@ const STD_CODE = String.raw`# aontu:code --- THE OUTPUT VOCABULARY. An aontu tra
186
186
  k: "prim"
187
187
  prim: "string" | "int" | "bigint" | "float" | "decimal" | "bool" | "null" | "any"
188
188
  })
189
+
189
190
  %ref = close({ k:"ref" name:%name unit?:string })
190
191
  %leaf = %prim | %ref | %text
191
192
 
@@ -208,6 +209,7 @@ const STD_CODE = String.raw`# aontu:code --- THE OUTPUT VOCABULARY. An aontu tra
208
209
  })
209
210
 
210
211
  %member = close({ name:%name value?:string | number doc?:%doc x?:{} })
212
+
211
213
  %param = close({
212
214
  name: %name
213
215
  type: %type
@@ -221,12 +223,14 @@ const STD_CODE = String.raw`# aontu:code --- THE OUTPUT VOCABULARY. An aontu tra
221
223
  %inline = string & re("^[^\n\r]*$") | %ref
222
224
  %line = close({ k:"line" at:*0 | integer & min(0) & max(64) of:[&: %inline] })
223
225
  %blank = close({ k:"blank" n:*1 | integer & min(1) & max(16) })
226
+
224
227
  %raw = close({
225
228
  k: "raw"
226
229
  at: *0 | integer & min(0) & max(64)
227
230
  text: string
228
231
  reindent: *true | boolean
229
232
  })
233
+
230
234
  %piece = %line | %blank | %raw | string & re("^[^\n\r]*$")
231
235
  %frag = close({ k:"frag" of:[&: %piece] })
232
236
  %body = %frag | close({ k:"abstract" })
@@ -241,7 +245,9 @@ const STD_CODE = String.raw`# aontu:code --- THE OUTPUT VOCABULARY. An aontu tra
241
245
  check?: [&: %check]
242
246
  x?: {}
243
247
  })
248
+
244
249
  %enum = close({ k:"enum" name:%name doc?:%doc members:[&: %member] x?:{} })
250
+
245
251
  %alias = close({
246
252
  k: "alias"
247
253
  name: %name
@@ -250,6 +256,7 @@ const STD_CODE = String.raw`# aontu:code --- THE OUTPUT VOCABULARY. An aontu tra
250
256
  check?: [&: %check]
251
257
  x?: {}
252
258
  })
259
+
253
260
  %const = close({
254
261
  k: "const"
255
262
  name: %name
@@ -258,6 +265,7 @@ const STD_CODE = String.raw`# aontu:code --- THE OUTPUT VOCABULARY. An aontu tra
258
265
  value: string | number | boolean | null
259
266
  x?: {}
260
267
  })
268
+
261
269
  %func = close({
262
270
  k: "func"
263
271
  name: %name
package/src/template.ts CHANGED
@@ -24,12 +24,22 @@
24
24
  // comment, and derives for a language this table has never seen: the
25
25
  // caller passes the token.
26
26
  //
27
- // A MARKER IS RECOGNISED AFTER LEADING WHITESPACE AND KEEPS ITS OWN
28
- // INDENTATION (D2). That is what lets a template be written exactly
29
- // where its output appears (D7): a body line is verbatim, so a line
30
- // indented two spaces in the template is indented two spaces in the
31
- // generated file, and the marker lines around it are indented to match
32
- // the code they sit in rather than to the aontu they carry.
27
+ // A MARKER IS RECOGNISED AFTER LEADING WHITESPACE, AND THE INDENTATION
28
+ // THE RESUGARING WRITES IS THE AONTU'S (D2, amended 2026-09-07). An
29
+ // output line is verbatim -- a line indented two spaces in the template
30
+ // is indented two spaces in the generated file (D7) -- and that is the
31
+ // half a reader of the TARGET needs. The other half is the document:
32
+ // the marker lines carry a tree, and a tree nobody can see the shape of
33
+ // is the one thing this surface took away. So the marker stands at the
34
+ // left margin and the aontu is indented AFTER it, which `aontu fmt`
35
+ // (FMT.0.md M-BM-'3.14) is what writes.
36
+ //
37
+ // Reading is unchanged and stays generous: a marker after leading
38
+ // whitespace is a marker, and its own indentation still counts toward
39
+ // the aontu it carries, so a template written the other way desugars to
40
+ // the same document. What changes is the spelling the round trip
41
+ // answers, and `template --check` names the difference as the drift it
42
+ // is.
33
43
  //
34
44
  // THE SUGAR IS THE FIXPOINT OF THE TWO TRANSFORMS (D6), and that is
35
45
  // the whole of the resugaring's safety. A canonical line that looks
@@ -128,13 +138,19 @@ function indentOf(line: string): number {
128
138
  }
129
139
 
130
140
 
131
- // The line without its indentation or its trailing spaces and tabs.
132
- function trimLine(line: string): string {
141
+ // The line without its trailing spaces and tabs.
142
+ function trimEnd(line: string): string {
133
143
  let end = line.length
134
144
  while (0 < end && (' ' === line[end - 1] || '\t' === line[end - 1])) {
135
145
  end--
136
146
  }
137
- return line.slice(indentOf(line), end)
147
+ return line.slice(0, end)
148
+ }
149
+
150
+
151
+ // The line without its indentation or its trailing spaces and tabs.
152
+ function trimLine(line: string): string {
153
+ return trimEnd(line).slice(indentOf(line))
138
154
  }
139
155
 
140
156
 
@@ -159,12 +175,15 @@ function readLine(line: string, marker: string): Line {
159
175
  // The block form's closer is part of the marker, not of the aontu.
160
176
  // A block marker line that never closes is not a marker line: the
161
177
  // language's own parser would not read it as a comment either.
178
+ // Only the TRAILING space goes: what stands between the opener and
179
+ // the aontu is the aontu's indentation, exactly as it is for a line
180
+ // marker, and the one-space rule below takes the marker's own.
162
181
  if (isBlock(marker)) {
163
182
  const end = body.lastIndexOf(BLOCK_CLOSE)
164
183
  if (end < 0) {
165
184
  return { marker: false, indent: '', text: line }
166
185
  }
167
- body = trimLine(body.slice(0, end))
186
+ body = trimEnd(body.slice(0, end))
168
187
  }
169
188
  // ONE SPACE AFTER THE MARKER IS THE MARKER'S, so `//- x: 1` carries
170
189
  // `x: 1` and the resugaring writes the space back. A marker written
@@ -273,19 +292,40 @@ function resugarTemplate(src: string, marker?: string): string {
273
292
  if (undefined !== target && desugarTemplate(target, mark) === trimLine(line)) {
274
293
  return target
275
294
  }
276
- const cut = indentOf(line)
277
- const text = line.slice(cut)
278
- const open = line.slice(0, cut) + mark
295
+ // THE MARKER STANDS AT THE LEFT MARGIN and the line's indentation
296
+ // is written after it, so the aontu's own shape is on the page.
297
+ // The one space is the marker's, which the reading takes back.
298
+ const text = trimEnd(line)
279
299
  const close = isBlock(mark) ? ' ' + BLOCK_CLOSE : ''
280
- return '' === text ? open + close : open + ' ' + text + close
300
+ return '' === text ? mark + close : mark + ' ' + text + close
281
301
  })
282
302
  return out.join('\n') + (tail ? '\n' : '')
283
- } /* node:coverage ignore next 8 */
303
+ }
304
+
305
+
306
+ // WHICH LINES OF THE DESUGARED DOCUMENT ARE THE TARGET'S. The
307
+ // desugaring is line for line, so this is one flag per line of what
308
+ // `desugarTemplate` returns: true where the template's line was not a
309
+ // marker, and so where the document's line is one quoted line of the
310
+ // generated file.
311
+ //
312
+ // `aontu fmt` reads it (FMT.0.md M-BM-'3.14) to hold those lines on lines of
313
+ // their own. A body element is a string like any other, and the packing
314
+ // budget would put three of them on one line -- which is aontu where
315
+ // three lines of output were, and a generator that writes them as one.
316
+ function templateOutputs(src: string, marker: string): boolean[] {
317
+ const lines = src.split('\n')
318
+ if (1 < lines.length && '' === lines[lines.length - 1]) {
319
+ lines.pop()
320
+ }
321
+ return lines.map((line) => !readLine(line, marker).marker)
322
+ } /* node:coverage ignore next 9 */
284
323
 
285
324
 
286
325
  export {
287
326
  desugarTemplate,
288
327
  resugarTemplate,
328
+ templateOutputs,
289
329
  markerFor,
290
330
  DEFAULT_MARKER,
291
331
  }
@@ -204,6 +204,17 @@ function bumpRecurse(v: any, xc: number): void {
204
204
  v.xc = Math.max(v.xc, xc)
205
205
  return
206
206
  }
207
+ // A RAW REFERENCE TO A RECURSIVE TARGET IS THE RECURSION, minted or
208
+ // not -- the same reading containsRecurseOf already takes, and the
209
+ // reason bound 2 was inert. A freshly cloned level holds the
210
+ // definition's references UNRESOLVED, so this walk found no
211
+ // residual to stamp and `xc` read 0 at every expansion, in the
212
+ // healthy form too. The seed rides on the reference and the
213
+ // residual minted from it starts there (ts/src/val/RefVal.ts).
214
+ if (true === v.isRef) {
215
+ v.rxc = Math.max(v.rxc ?? 0, xc)
216
+ return
217
+ }
207
218
  const peg: any = v.peg
208
219
  if (true === v.isMap && null != peg) {
209
220
  for (const k of Object.keys(peg)) {
package/src/val/RefVal.ts CHANGED
@@ -107,6 +107,15 @@ class RefVal extends FeatureVal {
107
107
  // after unification (see `canon` below). Not a ValSpec field: it is
108
108
  // a rendering of the settled tree, never a parse-time property.
109
109
  expansion: Val | undefined = undefined
110
+
111
+ // THE RECURSION SEED (use-cases/BUGS.md §57). A reference that names
112
+ // a recursive definition IS the fixpoint reference; it mints a
113
+ // RecurseVal when it resolves. bumpRecurse stamps the expansion
114
+ // depth here, because a freshly cloned level holds the definition's
115
+ // references UNRESOLVED and so has no residual to stamp -- which is
116
+ // why the design's second termination bound read 0 at every
117
+ // expansion. The minted residual starts from this.
118
+ rxc: number = 0
110
119
  prefix: boolean = false
111
120
 
112
121
  constructor(
@@ -329,7 +338,7 @@ class RefVal extends FeatureVal {
329
338
  out = makeNilErr(ctx, 'path_cycle', this)
330
339
  }
331
340
  else {
332
- const rec: any = new RecurseVal({ target } as any, ctx)
341
+ const rec: any = new RecurseVal({ target, xc: this.rxc } as any, ctx)
333
342
  rec.site = this.site
334
343
  rec.path = [...this.path]
335
344
  out = rec
@@ -610,7 +619,8 @@ class RefVal extends FeatureVal {
610
619
  // residual is the resolved form, exactly as at the prefix
611
620
  // positions inside the definition.
612
621
  else if (null != out && !snap && containsRecurseOf(out, this.peg as any)) {
613
- const rec: any = new RecurseVal({ target: [...this.peg] } as any, ctx)
622
+ const rec: any = new RecurseVal(
623
+ { target: [...this.peg], xc: this.rxc } as any, ctx)
614
624
  rec.site = this.site
615
625
  rec.path = [...this.path]
616
626
  out = rec
@@ -829,6 +839,10 @@ class RefVal extends FeatureVal {
829
839
  ...(spec || {})
830
840
  }) as RefVal)
831
841
  out.expansion = this.expansion
842
+ // The recursion seed travels with the clone: a spread template is
843
+ // cloned per destination, and each clone's residual must start
844
+ // where the level it came from left off.
845
+ out.rxc = this.rxc
832
846
  return out
833
847
  }
834
848
 
@@ -290,13 +290,25 @@ class ReferVal extends FeatureVal {
290
290
  // resolved string should take.
291
291
  settle(ctx: AontuContext, site: Val): Val {
292
292
  if (undefined === this.addr) {
293
- // NOT DONE, unlike `string` or `min(1)`. A refer without an
294
- // address has not done its work — it exists to check one — and
295
- // the pass loop must keep offering it the chance. The cost is
296
- // that a SCHEMA mentioning a link never resolves either, so
297
- // `type({from: refer($.std.Port)})` is not expressible today;
298
- // G4 phase 4 records why, and what it would take.
299
- this.dc = 0
293
+ // DONE while unmet, as `string` and `min(1)` are, and as RelVal
294
+ // has been since it was written (see its constructor). An
295
+ // ADDRESS-LESS refer has nothing to check yet and nothing to
296
+ // refuse: it is its own settled residual, and the meet
297
+ // re-activates it the moment a value arrives, because map
298
+ // merges build the conjunct regardless.
299
+ //
300
+ // NOT-DONE here used to be justified as keeping the pass loop
301
+ // offering the refer a chance -- but that is the job of the
302
+ // OTHER pending branch below, where an address exists and its
303
+ // target has not appeared. This branch has no address to
304
+ // resolve, so staying not-done bought nothing and cost the
305
+ // schema idiom: a `type()` body holding a link never settled,
306
+ // so its mark never transferred, so generation could not skip
307
+ // the marked subtree and raised `mapval_no_gen` naming the
308
+ // definition. `type({p: integer})`, `type({p: path()})` and
309
+ // `type({p: min(1)})` all settled; only a link did not
310
+ // (aontu-lang/aontu#172, G4 phase 4's recorded cost).
311
+ this.dc = DONE
300
312
  return this
301
313
  }
302
314
  // The address is a TREE PATH, resolved from the link's own
@@ -477,11 +489,13 @@ class RelVal extends FeatureVal {
477
489
  super(spec, ctx)
478
490
  this.tval = (spec as any).tval ?? top()
479
491
  this.held = (spec as any).held
480
- // DONE while unmet, deliberately -- the property refer() lacks and
481
- // G4 phase 4 records the cost of: a type() body holding a rel()
492
+ // DONE while unmet, deliberately: a type() body holding a rel()
482
493
  // must SETTLE, or the schema idiom (`dependsOn?: rel($.T)` inside
483
494
  // a vocabulary) leaves the type unresolved and every reference to
484
- // it deferring forever. An unmet rel is its own settled residual,
495
+ // it deferring forever. This was the property refer() lacked --
496
+ // G4 phase 4 recorded the cost -- until 2026-09-07, when an
497
+ // address-less refer was given it too (#172); the two now settle
498
+ // by the same rule, which is what the shared reasoning below is. An unmet rel is its own settled residual,
485
499
  // like `min(1)`; the meet re-activates it whenever a value
486
500
  // arrives, because map merges build the conjunct regardless.
487
501
  this.dc = DONE