Skip to content

Commit 49c8b56

Browse files
author
merge-queue-bot
committed
Merge PR #535: feat(cuelite): phase 0 — public package, CUE-backed façade, differential harness (plan 236)
2 parents 2bff8ae + 5eb3542 commit 49c8b56

20 files changed

Lines changed: 3237 additions & 37 deletions

.github/workflows/ci.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,36 @@ jobs:
289289
# including the <?...?> processing-instruction block.
290290
- run: go test -run=^$ -bench=. -benchtime=20x ./pkg/markdown/...
291291

292+
cuelite-bench:
293+
runs-on: ubuntu-latest
294+
steps:
295+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
296+
with:
297+
persist-credentials: false
298+
- uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0
299+
with:
300+
go-version-file: go.mod
301+
# Plan 236: the cuelite-versus-CUE differential benchmark (the
302+
# in-house path against the direct-CUE oracle) must genuinely run
303+
# in CI, not just exist. The phases that flip cuelite to the in-house
304+
# engine are judged against these arms.
305+
- run: go test -run=^$ -bench=. -benchtime=20x ./internal/cuelitetest/...
306+
# Plan 236: the cuelite/cue FACTOR gate. TestFactorGate computes the
307+
# cuelite-over-cue ns/op ratio for the hot (validate) and cold
308+
# (compile-validate) paths and FAILS when either exceeds its interim
309+
# budget (2.5x hot, 2.0x cold) — the ratio cancels runner speed, so a
310+
# blowup trips it but a slow runner does not. The gate is an ordinary
311+
# `go test` (the per-arm measurement loop lives in Go, no inline shell)
312+
# and writes a factor table to GITHUB_STEP_SUMMARY for the run page.
313+
# -v so the per-benchmark factor lines (t.Log) land in the job
314+
# log on success, not only in the step summary.
315+
# CUELITE_FACTOR_GATE=1 arms the gate: it skips everywhere else
316+
# (notably the parallel `test` job) because the ratio is only
317+
# meaningful on a quiet runner.
318+
- run: go test -v -run=TestFactorGate ./internal/cuelitetest/...
319+
env:
320+
CUELITE_FACTOR_GATE: "1"
321+
292322
# Nested-module test job: pkg/goldmark/ has its own go.mod (plan
293323
# 197+198 fork), so the root `test` job's `go test ./...` does NOT
294324
# traverse it. This dedicated step runs the fork's unit tests so

PLAN.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ footer: |
146146
| 216 || opus | [Per-document parse cache for the LSP, keyed by version](plan/216_lsp-parse-cache.md) |
147147
| 217 || opus | [Obsidian plugin (WASM runtime)](plan/217_obsidian-plugin.md) |
148148
| 218 || sonnet | [Finish MD054 link-image-style coverage in MDS068](plan/218_finish-md054-link-image-style.md) |
149-
| 218 | 🔲 | opus | [In-house CUE-subset engine for WASM size and tinygo](plan/218_wasm-size-reduction.md) |
149+
| 218 | 🔳 | opus | [In-house CUE-subset engine for WASM size and tinygo](plan/218_wasm-size-reduction.md) |
150150
| 219 || opus | [Multiplexed AST walk to close the parity gap to mado](plan/219_multiplexed-ast-walk.md) |
151151
| 219 || opus | [Route cmd/mdsmith and the LSP through pkg/mdsmith.Session](plan/219_session-cli-lsp-migration.md) |
152152
| 220 || opus | [Harden the git-index writers against a transient index.lock](plan/220_git-index-lock-retry.md) |
@@ -162,7 +162,7 @@ footer: |
162162
| 234 | 🔳 | sonnet | [Distribute mdsmith on Windows via Scoop and WinGet](plan/234_windows-package-managers.md) |
163163
| 235 || sonnet | [Playwright end-to-end tests for the website, runnable by CI and agents](plan/235_playwright-site-e2e.md) |
164164
| 236 || sonnet | [Consolidate duplicated table-parse helpers in tablereadability](plan/236_arch-fix-tablereadability-dup.md) |
165-
| 236 | 🔲 | opus | [cuelite phase 0 — package, façade, and differential harness](plan/236_cuelite-package-harness.md) |
165+
| 236 | | opus | [cuelite phase 0 — package, façade, and differential harness](plan/236_cuelite-package-harness.md) |
166166
| 237 || haiku | [Unit tests for include-rule private validation helpers](plan/237_arch-fix-include-helper-tests.md) |
167167
| 237 | 🔲 | sonnet | [cuelite phase 1 — surface D (placeholder paths)](plan/237_cuelite-surface-d.md) |
168168
| 238 | 🔲 | opus | [cuelite phase 2 — surfaces A + B (schema, query)](plan/238_cuelite-surfaces-ab.md) |

cue/cuelite/error.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package cuelite
2+
3+
import (
4+
"reflect"
5+
"slices"
6+
"strings"
7+
)
8+
9+
// PathError reports a validation failure tagged with the field path
10+
// at which it occurred. The path mirrors cuelang.org/go/cue/errors
11+
// Error.Path() — the dotted route into the data tree where the value
12+
// failed its constraint (for example []string{"meta", "status"}). A
13+
// nil path marks an error not associated with a specific leaf.
14+
//
15+
// A PathError may wrap the underlying error it was built from — the CUE
16+
// validation error for a per-leaf failure, or a bottom Value's cause —
17+
// reachable through Unwrap so errors.Is/As keep working against the
18+
// original error or a sentinel. Its own message, not the wrapped cause,
19+
// is the rejection; Errors treats a PathError as a leaf and does not
20+
// descend into the wrapped error.
21+
type PathError struct {
22+
path []string
23+
msg string
24+
wrapped error
25+
}
26+
27+
// newPathError builds a PathError at the given field path with the
28+
// given message, retaining cause as its wrapped error (nil when there is
29+
// none) so errors.Is/As against the original CUE error or a sentinel
30+
// resolve through the returned leaf. A nil or empty path produces an
31+
// error whose Error() is the bare message, with no path prefix. The
32+
// message must be path-free: Error() owns the single path prefix, so a
33+
// message that already carries the path would double it.
34+
func newPathError(path []string, msg string, cause error) *PathError {
35+
return &PathError{path: path, msg: msg, wrapped: cause}
36+
}
37+
38+
// Unwrap returns the underlying error the PathError was built from, or
39+
// nil when it carries none, so errors.Is/As reach the wrapped CUE error
40+
// or sentinel. Errors does not follow this link — a PathError is a leaf
41+
// in the per-field walk — so unwrapping is for errors.Is/As only.
42+
func (e *PathError) Unwrap() error {
43+
return e.wrapped
44+
}
45+
46+
// Path returns the field path the error is tagged with, or nil when
47+
// the error is not associated with a specific leaf. It mirrors
48+
// cue/errors Error.Path() so the differential harness can compare
49+
// in-house and CUE-backed error locations field by field.
50+
//
51+
// The returned slice is a fresh copy, never the error's internal slice
52+
// (matching cue/errors, which clones in Error.Path): a caller that
53+
// mutates it — the harness collects leaf paths into its own structures —
54+
// cannot corrupt a later Error() render. slices.Clone(nil) is nil, so an
55+
// unpathed error still returns nil rather than an empty slice.
56+
func (e *PathError) Path() []string {
57+
return slices.Clone(e.path)
58+
}
59+
60+
// Error renders the message prefixed by the dotted field path, or the
61+
// bare message when the path is empty.
62+
func (e *PathError) Error() string {
63+
if len(e.path) == 0 {
64+
return e.msg
65+
}
66+
return strings.Join(e.path, ".") + ": " + e.msg
67+
}
68+
69+
// Errors enumerates the per-field failures carried by an error returned
70+
// from Validate. It is THE way a consumer reads every rejecting leaf,
71+
// and it does not depend on the concrete shape Validate returns — which
72+
// Validate's own doc leaves unspecified. Whatever that shape is (today a
73+
// bare *PathError for a single failing field, an errors.Join of
74+
// *PathErrors for several, a path-free *PathError for a bottom), Errors
75+
// flattens it into one slice so callers (the internal/schema validator
76+
// emitting one MDS020 diagnostic per field, the differential harness
77+
// comparing every rejected path) iterate uniformly without type-switching
78+
// on the result. It mirrors cuelang.org/go/cue/errors.Errors.
79+
//
80+
// Errors is a full error-tree walk: it descends through both join
81+
// wrappers (Unwrap() []error) and single wrappers (Unwrap() error),
82+
// collecting every *PathError leaf in encounter order. A *PathError
83+
// hidden behind a fmt.Errorf("%w", …) wrapper, or a join nested inside
84+
// such a wrapper, is therefore reported in full — not truncated to the
85+
// first leaf an errors.As would stop at. A nil error, or an error tree
86+
// carrying no *PathError, yields nil — never a non-nil empty slice — so
87+
// a caller can range over the result unconditionally.
88+
//
89+
// This walk underpins the invariant documented on Validate: every
90+
// non-nil error Validate returns decomposes to at least one *PathError,
91+
// so a consumer loop over Errors emits at least one diagnostic for any
92+
// failing value.
93+
func Errors(err error) []*PathError {
94+
if err == nil {
95+
return nil
96+
}
97+
var out []*PathError
98+
return collectPathErrors(err, out, map[error]struct{}{})
99+
}
100+
101+
// collectPathErrors appends every *PathError leaf reachable from err to
102+
// out in encounter order. A node that is itself a *PathError is a leaf
103+
// and is appended directly (its own message, not its wrapped cause,
104+
// being the rejection — the walk does NOT descend into a PathError's
105+
// Unwrap). Otherwise the walk recurses through a join wrapper (Unwrap()
106+
// []error) or a single wrapper (Unwrap() error). A node that is neither
107+
// a *PathError nor any wrapper contributes nothing.
108+
//
109+
// visited records the nodes already walked, so a node reachable by more
110+
// than one path — errors.Join sharing a leaf with a %w-wrapper of it —
111+
// is counted once, and a cyclic Unwrap chain terminates instead of
112+
// recursing forever. A *PathError leaf is appended before the visited
113+
// check can dedup it only through its parents, so each distinct leaf
114+
// pointer still yields one entry.
115+
//
116+
// Only a comparable node is memoized: an uncomparable concrete type (a
117+
// slice- or map-backed error) cannot be a map key — inserting it would
118+
// panic "hash of unhashable type". Such a node is walked WITHOUT
119+
// memoization, so a cycle reachable only through it could recurse
120+
// forever; in practice a cycle needs a comparable self-referencing node
121+
// (an uncomparable value cannot equal itself by ==, so it cannot close a
122+
// Go error chain back onto itself), which is still memoized and still
123+
// terminates.
124+
func collectPathErrors(err error, out []*PathError, visited map[error]struct{}) []*PathError {
125+
if err == nil {
126+
return out
127+
}
128+
comparable := reflect.TypeOf(err).Comparable()
129+
if comparable {
130+
if _, seen := visited[err]; seen {
131+
return out
132+
}
133+
visited[err] = struct{}{}
134+
}
135+
if pe, ok := err.(*PathError); ok {
136+
return append(out, pe)
137+
}
138+
switch w := err.(type) {
139+
case interface{ Unwrap() []error }:
140+
for _, leaf := range w.Unwrap() {
141+
out = collectPathErrors(leaf, out, visited)
142+
}
143+
case interface{ Unwrap() error }:
144+
out = collectPathErrors(w.Unwrap(), out, visited)
145+
}
146+
return out
147+
}

0 commit comments

Comments
 (0)