diff --git a/.gitattributes b/.gitattributes index 0368cf01..ccbb4f6c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -22,6 +22,25 @@ src/Heddle.Generator.IntegrationTests/Fixtures/** text eol=lf src/Heddle.LanguageServices.Tests/Corpus/** text eol=lf src/Heddle.Demo.Wasm/contract-fixtures/** text eol=lf +# Golden oracle corpus (docs/spec/cross-stack-benchmarks/phase-1-cross-stack-foundation/ +# golden-corpus.md): the *.golden.html files intentionally end WITHOUT a trailing newline and +# must round-trip byte-exact on every platform, so they are pinned -text (no smudging at all) +# rather than eol=lf. The JSON sidecars are ordinary LF text. +src/Heddle.Performance/GoldenCorpus/*.golden.html -text +src/Heddle.Performance/GoldenCorpus/*.verify.json text eol=lf +src/Heddle.Performance/GoldenCorpus/manifest.json text eol=lf + +# Rust benchmark harness (docs/spec/cross-stack-benchmarks/phase-2-rust/README.md D2): +# composed-page fragment data must round-trip byte-exact (-text); template files are ordinary +# LF text (their line endings are erased by contract N2, but LF keeps diffs clean). +benchmarks/rust/data/** -text +benchmarks/rust/templates/** text eol=lf + +# Python benchmark harness (docs/spec/cross-stack-benchmarks/phase-5-python/README.md D3): +# template files are committed LF on every platform — Mako passes a literal \r\n in a template +# file through into rendered output, so LF-pinning keeps checked-in bytes equal to authored bytes. +benchmarks/python/templates/** text eol=lf + # Custom for Visual Studio *.cs diff=csharp *.sln merge=union diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 642ca429..2f8ae7af 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -48,7 +48,7 @@ jobs: # Full history so VitePress lastUpdated timestamps work. fetch-depth: 0 - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: 24 cache: npm @@ -85,7 +85,7 @@ jobs: cp -R src/Heddle.Language/ace_build/ace/build/src-noconflict/. docs/public/ace/ # ---- Phase 9: the typed WASM demo bundle (module worker + _framework), Roslyn-free ---- - name: Setup .NET - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: dotnet-version: 10.0.x - name: Install wasm-tools workload diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 10c72144..b60e5055 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -28,7 +28,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: dotnet-version: | 6.0.x @@ -52,7 +52,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: dotnet-version: | 6.0.x @@ -86,7 +86,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: dotnet-version: | 6.0.x diff --git a/.github/workflows/lsp.yml b/.github/workflows/lsp.yml index cea8c674..bdd28ea6 100644 --- a/.github/workflows/lsp.yml +++ b/.github/workflows/lsp.yml @@ -17,7 +17,7 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@v7 - - uses: actions/setup-dotnet@v5 + - uses: actions/setup-dotnet@v6 with: # The multi-targeted test suite runs net6.0/net8.0/net10.0/net48; the runner image # no longer ships the EOL .NET 6 (and may drop 8) runtime, so install them explicitly @@ -32,7 +32,7 @@ jobs: run: dotnet test src/Heddle.Tests/Heddle.Tests.csproj -c Release --no-build - name: Test language services run: dotnet test src/Heddle.LanguageServices.Tests/Heddle.LanguageServices.Tests.csproj -c Release --no-build - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: node-version: 24 - name: JS editor-artifact harness (highlight / beautify / completions) @@ -45,7 +45,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: actions/setup-dotnet@v5 + - uses: actions/setup-dotnet@v6 with: global-json-file: global.json # Framework-dependent + R2R per-RID packs cross-pack from one runner (phase 6 D19). @@ -74,7 +74,7 @@ jobs: - uses: actions/setup-dotnet@v4 with: global-json-file: global.json - - uses: actions/setup-node@v6 + - uses: actions/setup-node@v7 with: node-version: 24 - name: Map VS Code target to .NET RID diff --git a/.github/workflows/npm.yml b/.github/workflows/npm.yml index 3c7400cb..b0b0b917 100644 --- a/.github/workflows/npm.yml +++ b/.github/workflows/npm.yml @@ -1,6 +1,6 @@ # Builds the custom Ace editor bundle (@multiarc/ace_heddle) and publishes it to the # public npm registry (npmjs.org) using npm Trusted Publishing (OIDC) — no NPM_TOKEN. -# - internal PRs -> `npm stage publish` 1.0.0-beta. (staged for review, +# - internal PRs -> `npm stage publish` -beta. (staged for review, # NOT publicly installable until a maintainer approves it) # - tag vX.Y.Z -> `npm publish` X.Y.Z (live release, dist-tag "latest") # @@ -29,7 +29,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: 24 registry-url: https://registry.npmjs.org @@ -48,7 +48,8 @@ jobs: if: ${{ github.event_name == 'pull_request' }} working-directory: src/Heddle.Language/ace_build/ace/build run: | - npm version --no-git-tag-version 1.0.0-beta.${{ github.run_number }} + BASE_VERSION="$(node -p "require('./package.json').version")" + npm version --no-git-tag-version "${BASE_VERSION}-beta.${{ github.run_number }}" npm stage publish --tag beta - name: Publish release package (tag) if: ${{ startsWith(github.ref, 'refs/tags/v') }} diff --git a/.github/workflows/samples.yml b/.github/workflows/samples.yml index cd5dca1d..bd2b8cf6 100644 --- a/.github/workflows/samples.yml +++ b/.github/workflows/samples.yml @@ -39,7 +39,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: Setup .NET - uses: actions/setup-dotnet@v5 + uses: actions/setup-dotnet@v6 with: dotnet-version: 10.0.x - name: Comparer self-test diff --git a/.gitignore b/.gitignore index 34604d4e..90b72b20 100644 --- a/.gitignore +++ b/.gitignore @@ -146,3 +146,11 @@ samples/**/generated/ docs/test-results/ docs/playwright-report/ /docs/logs + +# Python benchmark harness (docs/spec/cross-stack-benchmarks/phase-5-python/ WI1): +# venv and raw measurement outputs are never committed; published copies go to docs/benchmarks// +benchmarks/python/.venv/ +benchmarks/python/results/ +# Windows master-runner outputs (benchmarks/run-all.ps1): per-run logs + copied artifacts +benchmarks/out/ +/benchmarks/go/bench.exe diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 00000000..b118388b --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,134 @@ +# Cross-stack benchmarks — reproduce book + +One directory per ecosystem harness, plus two master runners (Windows and Linux) that run +every gate and every measurement with the parameters the phase specs make normative. + +The specs are the source of truth; nothing in this file overrides them: + +| Ecosystem | Harness | Normative spec | +|---|---|---| +| .NET (anchor) | `src/Heddle.Performance` (BenchmarkDotNet) | [phase 1 — metrics-protocol.md](../docs/spec/cross-stack-benchmarks/phase-1-cross-stack-foundation/metrics-protocol.md) | +| Rust | `benchmarks/rust` (Criterion) | [phase 2 — README.md](../docs/spec/cross-stack-benchmarks/phase-2-rust/README.md) (D9/WI10) | +| JVM | `benchmarks/jvm` (JMH) | [phase 3 — harness-and-jmh.md](../docs/spec/cross-stack-benchmarks/phase-3-jvm/harness-and-jmh.md) | +| JS | `benchmarks/js` (mitata) | [phase 4 — harness-and-run.md](../docs/spec/cross-stack-benchmarks/phase-4-js/harness-and-run.md) | +| Python | `benchmarks/python` (pyperf) | [phase 5 — harness.md](../docs/spec/cross-stack-benchmarks/phase-5-python/harness.md) | +| Go | `benchmarks/go` (testing + benchstat) | [phase 6 — harness-and-measurement.md](../docs/spec/cross-stack-benchmarks/phase-6-go/harness-and-measurement.md) | +| Linux cross-check | `benchmarks/linux-crosscheck` | [phase 8 — README.md](../docs/spec/cross-stack-benchmarks/phase-8-linux-crosscheck/README.md) | + +## Prerequisites (toolchain pins) + +| Toolchain | Pin | Notes | +|---|---|---| +| .NET SDK | the exact SDK of the Windows protocol run (observed line: 10.0.302) | suites target `net10.0`, `-c Release` | +| Rust | rustc/cargo 1.97.1 | `rust-toolchain.toml` in `benchmarks/rust` | +| JDK | Temurin 25 | `pom.xml` pins `maven.compiler.release=25`; on a JDK < 25 the runner passes `-Dmaven.compiler.release=23` | +| Node.js | v24.18.0 | `package.json` `engines`; on any other node the runner uses `npm ci --engine-strict=false` | +| CPython | 3.14.6 | harness venv at `benchmarks/python/.venv`; pyperf 2.10.0 + psutil 7.2.2 from `requirements.txt` | +| Go | go1.26.x (1.26.5 asserted by `run-benchmarks.ps1`) | templ CLI v0.3.1020 via `go tool templ` | + +**Version deltas (SR-3 posture):** the Windows master runner *warns* on any delta from a pin +and continues; a *measurement* run on a delta'd toolchain must record the delta in the run +report's environment block (the Go harness is stricter: `run-benchmarks.ps1` hard-fails its +own version asserts). The Linux side records deltas in `version-deltas.md` +(`setup-toolchains.sh`). + +## The commands + +Run from the **repo root**: + +```powershell +# Windows — FULL measurement (protocol machine, quiet, AC power, elevated shell for pyperf): +powershell -ExecutionPolicy Bypass -File benchmarks\run-all.ps1 + +# Windows — SMOKE (short functional pass, ~10–20 min; results carry NO measurement validity): +powershell -ExecutionPolicy Bypass -File benchmarks\run-all.ps1 -Smoke +``` + +Linux, from `benchmarks/linux-crosscheck/` (see [its README](linux-crosscheck/README.md) for +the full bare-metal order of operations — install, GRUB isolation entry, environment capture): + +```bash +# Linux — FULL measurement (bare-metal protocol box only): +sudo ./tune.sh && ./run-all.sh; ./untune.sh + +# Linux/WSL — functional pass (no tuned state required; bypass is recorded): +./run-all.sh --smoke --no-tune-check +``` + +### Windows runner options + +| Option | Effect | +|---|---| +| `-Smoke` | short functional flags everywhere (see table below) | +| `-Ecosystem dotnet,rust,jvm,js,python,go` | subset (any combination; program order is preserved) | +| `-OutDir ` | artifact/log destination (default `benchmarks\out\windows-run-`, git-ignored; the path must not contain spaces) | +| `-JsStabilityRepeat` | opt in to the JS five-run stability procedure (Phase 4 D13) before the timed JS suites — it is publication-gating but a separate step, so it never auto-runs | + +## What a run does + +**Phase 1 — gates, all selected ecosystems, stop on first red** (same rule as +`linux-crosscheck/run-all.sh`: a red gate is triaged before anything later runs): + +1. .NET: `parity` + `verify-corpus` (golden-corpus freshness + verifier calibration) +2. Rust: `cargo run --release --bin gate` +3. JVM: `mvnw -q clean verify` (gates wired into `verify`) +4. JS: `npm ci`, `npm run selftest`, `npm run gate` +5. Python: venv create/reuse + `pip install -r requirements.txt`, `python -m runner.selftest`, `python -m runner.gate_all` +6. Go: `go test ./suites` (TestMain gates before anything can time) + +**Phase 2 — measurement**, in the anchor-first program order dotnet → rust → jvm → js → +python → go. The runner prints the machine-state rules (protocol box, quiet machine, AC/High +Performance power) before this phase; failures here are recorded and the run continues, with +a nonzero overall exit at the end. + +| Ecosystem | Full mode (protocol shape) | Smoke mode | +|---|---|---| +| .NET | 8 suites, one `--filter **` run each, BDN defaults + MemoryDiagnoser | same, `--job Dry` | +| Rust | `cargo bench --bench controlled --bench idiomatic --bench cold -- --noplot`, then `alloc_report` (alloc-count feature), then `summarize` (non-fatal while the Phase 1 Heddle reference rows are pending) | `cargo bench … -- --test` | +| JVM | `java -jar target/benchmarks.jar -prof gc -rf json -rff ` — committed annotation regime (Fork 5, 5×10 s / 5×10 s), **~4.5–5.5 h** | `-f 1 -wi 1 -i 1 -w 1s -r 1s -foe true` | +| JS | `run.ps1` per bench script (High priority, `--expose-gc --allow-natives-syntax`); stability 5-repeat only with `-JsStabilityRepeat` | the three scripts once via `run.ps1` | +| Python | five pyperf Runner scripts, `--affinity=4 -o \python\.json`, elevated shell (warned if not), then the separate `mem_tracemalloc.py` pass | pyperf `--debug-single-value`; memory `--reps 5` | +| Go | `run-benchmarks.ps1` (version asserts, templ freshness, vet, gates, prebuild, High-priority timed runs `-test.count=20 -test.benchtime=1s`, benchstat) | `-Count 1 -BenchTime 100ms` | + +## Where artifacts land + +Everything from one Windows run lands under the run's `-OutDir` +(default `benchmarks\out\windows-run-`, git-ignored): + +``` +\ + logs\-.log every step's full output (also echoed live) + logs\preamble.log toolchain versions + pin-delta warnings + summary.txt the per-step exit-code table + dotnet\ copy of BenchmarkDotNet.Artifacts (results\*.md/csv/json + logs) + rust\criterion\ copy of target\criterion (estimates.json per benchmark) + jvm\jmh-result.json JMH JSON (written there directly via -rff) + js\ copy of benchmarks\js\artifacts (*.txt captures + *.json) + python\.json pyperf JSONs + memory.json (written there directly via -o) + go\ copy of benchmarks\go\results (bench + benchstat outputs) +``` + +Linux run outputs land under `benchmarks/linux-crosscheck/out/` (self-git-ignored). + +## Running one harness alone (manual per-ecosystem commands) + +All commands from the harness directory shown; gates first, always. + +| Ecosystem | Directory | Gate | Measurement | +|---|---|---|---| +| .NET | `src/Heddle.Performance` | `dotnet run -c Release -f net10.0 -- parity` then `… -- verify-corpus` | `dotnet run -c Release -f net10.0 -- --filter **` (8 suites; `export-corpus` rewrites goldens — never run it casually) | +| Rust | `benchmarks/rust` | `cargo run --release --bin gate` | `cargo bench --bench controlled --bench idiomatic --bench cold -- --noplot`; `cargo run --release --features alloc-count --bin alloc_report`; `cargo run --release --bin summarize` | +| JVM | `benchmarks/jvm` | `.\mvnw.cmd -q clean verify` (add `-Dmaven.compiler.release=23` on a JDK < 25) | `java -jar target\benchmarks.jar -prof gc -rf json -rff jmh-result.json` | +| JS | `benchmarks/js` | `npm ci` + `npm run selftest` + `npm run gate` | `./run.ps1 bench/controlled.mjs` (also `idiomatic.mjs`, `cold-compile.mjs`); stability: `./run.ps1 bench/controlled.mjs -Repeat 5` | +| Python | `benchmarks/python` | `python -m runner.selftest` + `python -m runner.gate_all` (venv python) | `python bench_.py --affinity=4 -o results\.json` (×5, elevated shell); `python mem_tracemalloc.py -o results\memory.json` | +| Go | `benchmarks/go` | `go test ./suites` | `./run-benchmarks.ps1` | + +## Publication + +Assembling a published report is **manual**, per +[metrics-protocol.md](../docs/spec/cross-stack-benchmarks/phase-1-cross-stack-foundation/metrics-protocol.md): +copy the run's artifacts into `docs/benchmarks//` with the environment block, +the two-track tables, the allocation labels/caveats, and the honest-reporting rules — the +runners never write into `docs/`. The Linux cross-check report additionally goes through +`linux-crosscheck/validate.py` (part 2 tables/verdicts) and the WI8 assembly step described +in [linux-crosscheck/README.md](linux-crosscheck/README.md). diff --git a/benchmarks/go/.gitattributes b/benchmarks/go/.gitattributes new file mode 100644 index 00000000..7070ddc5 --- /dev/null +++ b/benchmarks/go/.gitattributes @@ -0,0 +1,5 @@ +# Go benchmark harness (docs/spec/cross-stack-benchmarks/phase-6-go/README.md D2/D3): +# composed-page fragment data must round-trip byte-exact (-text) — the files are embedded +# (go:embed) as RUNTIME DATA and byte-checked against the golden corpus by the controlled +# gate; any smudging would fail the gate. Sources and generated *_templ.go are ordinary text. +internal/model/data/** -text diff --git a/benchmarks/go/README.md b/benchmarks/go/README.md new file mode 100644 index 00000000..8f04449c --- /dev/null +++ b/benchmarks/go/README.md @@ -0,0 +1,113 @@ +# Go benchmark harness (Phase 6 — go) + +The Go module for the cross-stack benchmarks Phase 6 +([spec](../../docs/spec/cross-stack-benchmarks/phase-6-go/README.md)): the stdlib engine +(text/template on the raw suites, html/template on the encoded suite — Q6.1) and templ, both +fairness tracks, gated against the Phase 1 golden corpus at +`src/Heddle.Performance/GoldenCorpus/` (read repo-relative; the corpus does not move). + +Module: `heddle.dev/benchmarks/go` (repo-local, never published). Pins (spec D3): `go 1.26` / +`toolchain go1.26.5`; `github.com/a-h/templ v0.3.1020` (runtime require + CLI `tool` +directive); benchstat via `tool golang.org/x/perf/cmd/benchstat` at +`golang.org/x/perf v0.0.0-20260709024250-82a0b07e230d`. Generated `*_templ.go` files are +committed; regenerate with the pinned CLI only: + +``` +go tool templ generate +``` + +## How to run + +From `benchmarks/go/`: + +``` +go vet ./... # static checks +go test ./internal/... # unit tests: N1–N5 pipeline, verifier calibration, alphabet assert, model pins, S1 probes +go test ./suites # the phase's parity command: TestMain runs every registered gate before anything times +``` + +Layout (harness-and-measurement.md §Module layout): + +- `internal/corpus/` — corpus loader + manifest SHA-256 check, N1–N5 pipeline (N3b comparison + strip included), controlled byte gate + encoded security floor, idiomatic verifier, + untrusted-data alphabet assert (D5). +- `internal/model/` — the eight pinned models (C# field spellings, `strconv`/`%d` numerics); + composed-page fragments embedded byte-exact as runtime data under + `internal/model/data/composed-page/` (pinned `-text` in `.gitattributes`) and byte-checked + against the oracle by `TestComposedAssemblyMatchesTheCorpusOracle`. +- `internal/spike/` — the S1 templ feasibility probes (evidence, excluded from timing). +- `suites/` — `gate_test.go` (TestMain + gate registry; engine cells register here as WI3–WI5 + land), later `bench_test.go` / `coldparse_test.go`. + +Benchmarks, the runner script (`run-benchmarks.ps1`), and the engine ports land with WI3–WI9. + +## S1 results (templ feasibility spike — WI2) + +Executed 2026-07-21 at the pinned toolchain (`go1.26.5`, templ `v0.3.1020`, Windows 11). +Command: `go test ./internal/spike/ -v` (probes are the committed tests in +`internal/spike/spike_test.go`; templ sources in `internal/spike/spike.templ`, generated code +committed alongside). **Verdict: clean pass on all four probes — the expected outcome of +[templ-feasibility.md](../../docs/spec/cross-stack-benchmarks/phase-6-go/templ-feasibility.md). +No non-whitespace divergence surfaced, so no `EXCLUSIONS.md` and no `internal/spike/attempts/` +exist; the implementation plan proceeds unchanged (WI3+).** + +### P1 — smallest-workload byte gate: PASS (byte-identical, pre-N3b) + +`trivial-substitution.templ` authored densely renders, after N1–N4, **byte-identical to the +committed oracle even before the N3b comparison strip** — templ's collapser found nothing to +change in the dense form, and templ emitted the void element as `` +(no self-closing slash), matching the oracle. Observed normalized output (338 bytes = the +oracle's byte length): + +``` +

Heddle Handbook

HB-2001

+``` + +Fixed-point check: `

{ a } { b } { c }

` with single literal spaces renders +`

Mercantile 2026 support at mercantile.example

` — **the single spaces survive +verbatim** (F3 fixed point confirmed; the mixed-page footer construct is safe). + +### P2 — escaper bytes: PASS (every spelling inside the N5 table) + +Text path (fortunes-shaped row over `& < > " '` + Japanese) — observed bytes: + +``` +1& < > " ' こんにちは +``` + +Attribute path (encoded-loop-shaped row, `data-tag={ tag }`, pinned row-0 data) — observed +bytes (templ auto-quotes the attribute with `"` and uses the same escaper on both paths): + +``` +item <0> & "co"'q' & <angle> "d" こんにちは 0 +``` + +Emitted spellings: `& < > " '` on **both** paths (templ.EscapeString → +stdlib html.EscapeString; the attribute path did *not* emit `"`) — **every spelling is +inside the N5 table**; the only non-canonical spelling is `"`, which N5 maps to `"`, +after which both outputs equal the canonical oracle bytes exactly. The Japanese strings pass +through byte-intact, and escape-free data round-trips unescaped (raw-suite no-op premise +confirmed). The non-whitespace contingency (a spelling outside N5) did **not** occur. + +### P3 — style passthrough: PASS (byte-identical) + +The exact mixed-page ``) — F4 executed and confirmed; the `@templ.Raw` fallback of port-mapping rule 6 is +**not** needed; mixed-page will be authored with a literal ` +} + +// P4 — inter-statement whitespace (evidence only): two consecutive @templ.Raw calls on +// separate lines whose payloads reproduce the composed-page CSS-comment neighborhood +// (comp-assets-styles → comp-custom-styles → comp-head-scripts, the one non-tag-flanked +// fragment boundary of the oracle). +templ boundary(link string, css string, script string) { + @templ.Raw(link) + @templ.Raw(css) + @templ.Raw(script) +} diff --git a/benchmarks/go/internal/spike/spike_templ.go b/benchmarks/go/internal/spike/spike_templ.go new file mode 100644 index 00000000..98fd39a0 --- /dev/null +++ b/benchmarks/go/internal/spike/spike_templ.go @@ -0,0 +1,453 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +// S1 — templ controlled-track feasibility spike (WI2). + +// Probes P1–P4 of docs/spec/cross-stack-benchmarks/phase-6-go/templ-feasibility.md, + +// executed at the pinned toolchain (Go 1.26.5, templ v0.3.1020). Throwaway-quality but + +// committed in-tree as evidence; excluded from benchmark runs. Generated via + +// `go tool templ generate` (the pinned CLI through the go.mod tool directive — D3). + +package spike + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "heddle.dev/benchmarks/go/internal/model" + +// P1 — smallest-workload byte gate: trivial-substitution authored densely per +// port-mapping.md §Workload 2 (the pinned one-line
card, ten substitutions in +// pinned order including the two attribute positions). +func trivialSubstitution(m model.SubstitutionModel) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(m.Title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/spike/spike.templ`, Line: 14, Col: 23} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(m.Sku) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/spike/spike.templ`, Line: 14, Col: 52} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(m.Price) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/spike/spike.templ`, Line: 14, Col: 84} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(m.Brand) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/spike/spike.templ`, Line: 14, Col: 116} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(m.Category) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/spike/spike.templ`, Line: 14, Col: 149} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(m.Availability) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/spike/spike.templ`, Line: 14, Col: 188} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(m.Summary) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/spike/spike.templ`, Line: 14, Col: 279} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(m.Rating) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/spike/spike.templ`, Line: 14, Col: 313} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// P1 (second half) — the mixed-page footer construct: string expressions separated by +// single literal spaces; F3's fixed point (a lone space maps to itself). +func spacedExpressions(a string, b string, c string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var12 := templ.GetChildren(ctx) + if templ_7745c5c3_Var12 == nil { + templ_7745c5c3_Var12 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(a) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/spike/spike.templ`, Line: 20, Col: 7} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(b) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/spike/spike.templ`, Line: 20, Col: 13} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(c) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/spike/spike.templ`, Line: 20, Col: 19} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// P2 — escaper bytes, text path: a one-row fortunes-shaped fragment. +func fortunesRow(id string, message string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var16 := templ.GetChildren(ctx) + if templ_7745c5c3_Var16 == nil { + templ_7745c5c3_Var16 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(id) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/spike/spike.templ`, Line: 25, Col: 13} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(message) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/spike/spike.templ`, Line: 25, Col: 33} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// P2 — escaper bytes, attribute path: an encoded-loop-shaped row (data-tag={ tag } plus +// two text expressions). +func encodedRow(tag string, name string, comment string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var19 := templ.GetChildren(ctx) + if templ_7745c5c3_Var19 == nil { + templ_7745c5c3_Var19 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var21 string + templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/spike/spike.templ`, Line: 31, Col: 32} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(comment) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/spike/spike.templ`, Line: 31, Col: 52} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// P3 — style passthrough: the exact mixed-page ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// P4 — inter-statement whitespace (evidence only): two consecutive @templ.Raw calls on +// separate lines whose payloads reproduce the composed-page CSS-comment neighborhood +// (comp-assets-styles → comp-custom-styles → comp-head-scripts, the one non-tag-flanked +// fragment boundary of the oracle). +func boundary(link string, css string, script string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var24 := templ.GetChildren(ctx) + if templ_7745c5c3_Var24 == nil { + templ_7745c5c3_Var24 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templ.Raw(link).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.Raw(css).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ.Raw(script).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/benchmarks/go/internal/spike/spike_test.go b/benchmarks/go/internal/spike/spike_test.go new file mode 100644 index 00000000..fa4996ac --- /dev/null +++ b/benchmarks/go/internal/spike/spike_test.go @@ -0,0 +1,220 @@ +// S1 probe execution (WI2). Each test is one probe of templ-feasibility.md §S1; the +// observed bytes are additionally recorded in benchmarks/go/README.md §S1 results. +package spike + +import ( + "context" + "strings" + "testing" + + "github.com/a-h/templ" + + "heddle.dev/benchmarks/go/internal/corpus" + "heddle.dev/benchmarks/go/internal/model" +) + +func render(t *testing.T, c templ.Component) string { + t.Helper() + var b strings.Builder + if err := c.Render(context.Background(), &b); err != nil { + t.Fatalf("render failed: %v", err) + } + return b.String() +} + +// ---- P1 — smallest-workload byte gate -------------------------------------------------------- + +func TestP1TrivialSubstitutionPassesTheByteGate(t *testing.T) { + output := render(t, trivialSubstitution(model.Substitution)) + + // The full controlled gate: N1–N4 pipeline, N3b strip on both sides, byte compare + // against the committed corpus entry. + if err := corpus.CheckControlled("trivial-substitution", "raw", "templ-spike", + func() string { return output }); err != nil { + t.Fatal(err) + } + + // Evidence: is the normalized candidate byte-identical to the stored oracle even + // BEFORE the N3b comparison strip? + golden, err := corpus.LoadGolden("trivial-substitution") + if err != nil { + t.Fatal(err) + } + normalized := corpus.NormalizeRaw(output) + if normalized == golden { + t.Log("P1: normalized output is byte-identical to the oracle pre-N3b (no whitespace divergence at all)") + } else { + t.Logf("P1: whitespace-only divergence reconciled by N3/N3b (normalized %d bytes vs oracle %d)", + len(normalized), len(golden)) + } +} + +func TestP1SingleSpacesBetweenExpressionsSurviveVerbatim(t *testing.T) { + // The mixed-page footer construct: `{ a } { b } { c }` separated by single literal + // spaces. F3: collapsing maps a lone space to itself (fixed point) — the spaces must + // survive verbatim in the rendered output. + output := render(t, spacedExpressions("Mercantile", "2026", "support at mercantile.example")) + const want = "

Mercantile 2026 support at mercantile.example

" + if output != want { + t.Fatalf("single spaces did not survive verbatim:\n want %q\n got %q", want, output) + } +} + +// ---- P2 — escaper bytes ---------------------------------------------------------------------- + +// n5Table lists every spelling the contract's N5 table recognizes for the five +// markup-significant characters; any escaper output must be composed of literal safe text +// plus spellings from this set. +var n5Table = []string{ + "&", "&", "&", + "<", "<", "<", + ">", ">", ">", + """, """, """, + "'", "'", "'", +} + +// assertSpellingsInsideN5 walks every entity-shaped span ("&…;") of the escaped output and +// asserts it is one of the N5 table's recognized spellings. +func assertSpellingsInsideN5(t *testing.T, label, escaped string) { + t.Helper() + for i := 0; i < len(escaped); i++ { + if escaped[i] != '&' { + continue + } + end := strings.IndexByte(escaped[i:], ';') + if end < 0 { + t.Fatalf("%s: unterminated entity at %d in %q", label, i, escaped) + } + entity := escaped[i : i+end+1] + found := false + for _, spelling := range n5Table { + if entity == spelling { + found = true + break + } + } + if !found { + t.Fatalf("%s: escaper spelling %q is OUTSIDE the N5 table — non-whitespace divergence (Q1.2 exclusion contingency)", label, entity) + } + i += end + } +} + +func TestP2TextPathEscaperBytes(t *testing.T) { + // A fortunes-shaped row whose message covers all five specials plus Japanese. + const message = `& < > " ' こんにちは` + output := render(t, fortunesRow("1", message)) + t.Logf("P2 text path: %q", output) + + // Every emitted spelling must be inside the N5 table. + assertSpellingsInsideN5(t, "text path", output) + + // Pin the exact observed spellings (templ.EscapeString → html.EscapeString: five + // characters, decimal quotes — F5). + const wantRow = `1& < > " ' こんにちは` + if output != wantRow { + t.Fatalf("text-path bytes changed:\n want %q\n got %q", wantRow, output) + } + + // After N5 the output must carry the canonical spellings, byte-for-byte. + canonical := corpus.N5Canonicalize(output) + const wantCanonical = `1& < > " ' こんにちは` + if canonical != wantCanonical { + t.Fatalf("N5 canonicalization mismatch:\n want %q\n got %q", wantCanonical, canonical) + } + + // The Japanese string is byte-intact (the stdlib escaper touches only the five ASCII). + if !strings.Contains(output, "こんにちは") { + t.Fatal("Japanese string must pass through byte-intact") + } +} + +func TestP2AttributePathEscaperBytes(t *testing.T) { + // An encoded-loop-shaped row: data-tag={ tag } attribute plus two text expressions, + // using the pinned row-0 values (tag covers & and '; name covers < > and "). + row := model.EncodedLoop.Items[0] + output := render(t, encodedRow(row.Tag, row.Name, row.Comment)) + t.Logf("P2 attribute path: %q", output) + + assertSpellingsInsideN5(t, "attribute path", output) + + // Pin the exact observed bytes: templ auto-quotes the attribute and escapes both paths + // with the same five-character decimal-quote table. + const wantRow = `item <0> & "co"'q' & <angle> "d" こんにちは 0` + if output != wantRow { + t.Fatalf("attribute-path bytes changed:\n want %q\n got %q", wantRow, output) + } + + // After N5, the bytes equal the oracle's canonical row 0 exactly. + const wantCanonical = `item <0> & "co"'q' & <angle> "d" こんにちは 0` + if canonical := corpus.N5Canonicalize(output); canonical != wantCanonical { + t.Fatalf("N5 canonicalization mismatch:\n want %q\n got %q", wantCanonical, canonical) + } +} + +func TestP2EscapeFreeDataRoundTripsUnescaped(t *testing.T) { + // '+'-free (and escapable-free) data round-trips byte-identical — the raw suites' + // no-op-escaper premise (authoring rule 4). + output := render(t, fortunesRow("7", "Computers make very fast, very accurate mistakes.")) + const want = "7Computers make very fast, very accurate mistakes." + if output != want { + t.Fatalf("escape-free data must round-trip unescaped:\n want %q\n got %q", want, output) + } +} + +// ---- P3 — style passthrough ------------------------------------------------------------------ + +// styleCSS is the exact mixed-page " + if !strings.HasPrefix(output, prefix) || !strings.HasSuffix(output, suffix) { + t.Fatalf("style element shape changed: %q", output) + } + content := output[len(prefix) : len(output)-len(suffix)] + if content == styleCSS { + return // F4 holds: rendered without any changes — no fallback needed + } + // Only a confirmed NON-whitespace change routes to the @templ.Raw fallback + // (port-mapping rule 6); a whitespace-only difference passes via N3b. + if corpus.N3bStrip(content) == corpus.N3bStrip(styleCSS) { + t.Logf("P3: whitespace-only difference in style content (reconciled by N3b): %q", content) + return + } + t.Fatalf("P3: NON-whitespace change to style content — the @templ.Raw fallback branch applies:\n want %q\n got %q", styleCSS, content) +} + +// ---- P4 — inter-statement whitespace (evidence only) ----------------------------------------- + +func TestP4ComposedPageBoundaryBytes(t *testing.T) { + // The composed-page CSS-comment neighborhood: comp-assets-styles → + // comp-custom-styles → comp-head-scripts, issued as consecutive @templ.Raw calls on + // separate lines — the fragments verbatim from the pinned model data. + link := model.Composed.Comp["assets_styles"] + css := model.Composed.Comp["custom_styles"] + script := model.Composed.Comp["head_scripts"] + output := render(t, boundary(link, css, script)) + + // Evidence: the exact bytes templ emits between consecutive statement nodes. + zeroSeparator := link + css + script + switch { + case output == zeroSeparator: + t.Log("P4: templ emits ZERO separator bytes between consecutive @templ.Raw nodes — boundary already byte-exact vs the oracle") + case corpus.N3bStrip(output) == corpus.N3bStrip(zeroSeparator): + t.Logf("P4: whitespace-only separator between @templ.Raw nodes (passes via N3b). Observed output: %q", output) + default: + t.Fatalf("P4: NON-whitespace divergence at the inter-statement boundary:\n want (stripped) %q\n got %q", + zeroSeparator, output) + } + + // The oracle's non-tag-flanked boundary must survive the comparison projection: the + // stripped candidate must contain the oracle's `/>/*CSSCommentTest*//*CSSCommentTest*/.verify.json semantics), not the byte gate. +// +// Doc citations (Q1.7 — official documentation patterns followed here): +// - https://pkg.go.dev/text/template (Actions, Text and spaces / trim markers, +// Nested template definitions, Examples) +// - https://pkg.go.dev/html/template (same template API; contextual auto-escaping) +// +// Spec: docs/spec/cross-stack-benchmarks/phase-6-go/port-mapping.md +// §Idiomatic track — both engines, all eight workloads. +package stdlibtpl + +import ( + htmltemplate "html/template" + texttemplate "text/template" + + "heddle.dev/benchmarks/go/internal/model" +) + +// ---- workload 1 — composed-page (text/template) ---------------------------------------------- + +// Layout composition via nested template definitions ({{define "layout"}} + +// {{template "layout" .}}), one fragment per line; the HTML fragments are runtime data and +// text/template passes them through unescaped. +const idiomaticComposedSrc = `{{define "layout"}} +{{index .Section "meta"}} +{{index .Section "social"}} +{{index .Comp "assets_styles"}} +{{index .Comp "custom_styles"}} +{{index .Comp "head_scripts"}} +{{index .Comp "body_scripts"}} +{{range .AreaNames -}} +{{index $.Areas .}} +{{end -}} +{{index .Comp "assets_scripts"}} +{{index .Section "page_scripts"}} +{{index .Section "endpage_scripts"}} +{{index .Comp "body_end_scripts"}} +{{end}} +{{- template "layout" .}}` + +// ---- workload 2 — trivial-substitution (text/template) --------------------------------------- + +const idiomaticTrivialSubstitutionSrc = `
+

{{.Title}}

+

{{.Sku}}

+

{{.Price}}

+

{{.Brand}}

+

{{.Category}}

+

{{.Availability}}

+ +

{{.Summary}}

+

{{.Rating}}

+
+` + +// ---- workload 3 — large-loop (text/template) ------------------------------------------------- + +// Trim markers around the range body, the shape the text/template docs' "Text and spaces" +// section teaches for loop output. +const idiomaticLargeLoopSrc = `{{range .Items -}} + + {{.Name}} + {{.Value}} + +{{end -}} +` + +// ---- workload 4 — mixed-page (text/template) ------------------------------------------------- + +const idiomaticMixedPageSrc = ` + + + + {{.PageTitle}} + + + +
+

{{.StoreName}}

+ +
+
+ {{if .ShowBanner}}{{end}} +
+

{{.HeroHeading}}

+

{{.HeroTagline}}

+
+
+ {{range .Products -}} +
+

{{.Name}}

+

{{.Sku}}

+

{{.Price}}

+ {{if .OnSale}}

On sale

{{end}} +

{{.Blurb}}

+
+ {{end -}} +
+ {{if .ShowDebugPanel}}
debug
{{end}} +
+
+

{{.FooterNote}}

+

{{.StoreName}} {{.Year}} {{.SupportEmail}}

+
+ + +` + +// ---- workload 5 — conditional-heavy (text/template) ------------------------------------------ + +const idiomaticConditionalHeavySrc = `
    + {{range .Rows -}} +
  • + {{if .IsBronze -}} + bronze + {{- else if .IsSilver -}} + silver + {{- else if .IsGold -}} + gold + {{- else -}} + platinum + {{- end}} + {{.Name}} + {{if .HasNote}}{{.Note}}{{end}} + {{if .IsActive}}active{{end}} +
  • + {{end -}} +
+` + +// ---- workload 6 — fragment-heavy (text/template) --------------------------------------------- + +// The partial-with-current-row construct exactly as the docs' nested-template-definition +// examples show it: a named {{define}} invoked with {{template "tile" .}} per row. +const idiomaticFragmentHeavySrc = `{{define "tile"}} +
+

{{.Name}}

+

{{.Value}}

+ {{.Badge}} +
+{{end}} +{{- define "panel"}} +
+ {{range .Items}}{{template "tile" .}}{{end}} +
+{{end}} +{{- template "panel" .}}` + +// ---- workload 7 — fortunes-encoded (html/template) ------------------------------------------- + +// html/template's contextual auto-escaper fires on {{.Id}}/{{.Message}}; the authoring is +// otherwise identical to how the html/template docs teach a data-driven page. +const idiomaticFortunesEncodedSrc = ` + + + Fortunes + + + + + {{range .Rows -}} + + + + + {{end -}} +
idmessage
{{.Id}}{{.Message}}
+ + +` + +// ---- workload 8 — encoded-loop (html/template) ----------------------------------------------- + +// {{.Tag}} in quoted-attribute context, the other two substitutions in text context — the +// escaper picks the context per the html/template package docs. +const idiomaticEncodedLoopSrc = ` + {{range .Items -}} + + + + + {{end -}} +
{{.Name}}{{.Comment}}
+` + +// ---- parsed templates (once, at package init — the cached-template render path) -------------- + +var ( + idiomaticComposedTpl = texttemplate.Must(texttemplate.New("composed-page").Parse(idiomaticComposedSrc)) + idiomaticTrivialSubstitutionTpl = texttemplate.Must(texttemplate.New("trivial-substitution").Parse(idiomaticTrivialSubstitutionSrc)) + idiomaticLargeLoopTpl = texttemplate.Must(texttemplate.New("large-loop").Parse(idiomaticLargeLoopSrc)) + idiomaticMixedPageTpl = texttemplate.Must(texttemplate.New("mixed-page").Parse(idiomaticMixedPageSrc)) + idiomaticConditionalHeavyTpl = texttemplate.Must(texttemplate.New("conditional-heavy").Parse(idiomaticConditionalHeavySrc)) + idiomaticFragmentHeavyTpl = texttemplate.Must(texttemplate.New("fragment-heavy").Parse(idiomaticFragmentHeavySrc)) + idiomaticFortunesEncodedTpl = htmltemplate.Must(htmltemplate.New("fortunes-encoded").Parse(idiomaticFortunesEncodedSrc)) + idiomaticEncodedLoopTpl = htmltemplate.Must(htmltemplate.New("encoded-loop").Parse(idiomaticEncodedLoopSrc)) +) + +// ---- per-cell reused buffers (render.go's newBuf; same sizing cases as controlled) ----------- + +var ( + idComposedBuf = newBuf(64 << 10) + idTrivialBuf = newBuf(1 << 10) + idLargeLoopBuf = newBuf(256 << 10) + idMixedBuf = newBuf(32 << 10) + idConditionalBuf = newBuf(32 << 10) + idFragmentBuf = newBuf(16 << 10) + idFortunesBuf = newBuf(4 << 10) + idEncodedLoopBuf = newBuf(2 << 20) +) + +// ---- the eight idiomatic cells (raw = stdlib-text, encoded = stdlib-html) -------------------- + +// RenderIdiomaticComposedPage renders workload 1 idiomatically via text/template. +func RenderIdiomaticComposedPage() string { + return render(idiomaticComposedTpl, idComposedBuf, model.Composed) +} + +// RenderIdiomaticTrivialSubstitution renders workload 2 idiomatically via text/template. +func RenderIdiomaticTrivialSubstitution() string { + return render(idiomaticTrivialSubstitutionTpl, idTrivialBuf, model.Substitution) +} + +// RenderIdiomaticLargeLoop renders workload 3 idiomatically via text/template. +func RenderIdiomaticLargeLoop() string { + return render(idiomaticLargeLoopTpl, idLargeLoopBuf, model.LargeLoop) +} + +// RenderIdiomaticMixedPage renders workload 4 idiomatically via text/template. +func RenderIdiomaticMixedPage() string { + return render(idiomaticMixedPageTpl, idMixedBuf, model.Mixed) +} + +// RenderIdiomaticConditionalHeavy renders workload 5 idiomatically via text/template. +func RenderIdiomaticConditionalHeavy() string { + return render(idiomaticConditionalHeavyTpl, idConditionalBuf, model.Conditional) +} + +// RenderIdiomaticFragmentHeavy renders workload 6 idiomatically via text/template. +func RenderIdiomaticFragmentHeavy() string { + return render(idiomaticFragmentHeavyTpl, idFragmentBuf, model.Fragment) +} + +// RenderIdiomaticFortunesEncoded renders workload 7 idiomatically via html/template. +func RenderIdiomaticFortunesEncoded() string { + return render(idiomaticFortunesEncodedTpl, idFortunesBuf, model.Fortunes) +} + +// RenderIdiomaticEncodedLoop renders workload 8 idiomatically via html/template. +func RenderIdiomaticEncodedLoop() string { + return render(idiomaticEncodedLoopTpl, idEncodedLoopBuf, model.EncodedLoop) +} diff --git a/benchmarks/go/internal/stdlibtpl/render.go b/benchmarks/go/internal/stdlibtpl/render.go new file mode 100644 index 00000000..7127f82e --- /dev/null +++ b/benchmarks/go/internal/stdlibtpl/render.go @@ -0,0 +1,88 @@ +// Controlled-track render path for the stdlib surfaces: every render executes the parsed +// template into a reused per-cell bytes.Buffer, Reset() at the top of each call and +// pre-grown once to the workload's output size so neither growth policy nor the reset is a +// per-engine variable (harness-and-measurement.md §Benchmark shape), returning buf.String(). +package stdlibtpl + +import ( + "bytes" + "io" + + "heddle.dev/benchmarks/go/internal/model" +) + +// executer is the shared surface of *text/template.Template and *html/template.Template. +type executer interface { + Execute(wr io.Writer, data any) error +} + +// newBuf returns a buffer pre-grown to the workload's output size (encoded-loop's ~1.1 MB +// escaped output is the sizing case), so growth never lands in a timed region. +func newBuf(capacity int) *bytes.Buffer { + buf := new(bytes.Buffer) + buf.Grow(capacity) + return buf +} + +var ( + composedBuf = newBuf(64 << 10) + trivialBuf = newBuf(1 << 10) + largeLoopBuf = newBuf(256 << 10) + mixedBuf = newBuf(32 << 10) + conditionalBuf = newBuf(32 << 10) + fragmentBuf = newBuf(16 << 10) + fortunesBuf = newBuf(4 << 10) + encodedLoopBuf = newBuf(2 << 20) +) + +// render resets the cell's buffer, executes the cached template, and returns the output. +// A template execution error is a harness defect, never a gate outcome — panic loudly. +func render(t executer, buf *bytes.Buffer, data any) string { + buf.Reset() + if err := t.Execute(buf, data); err != nil { + panic("stdlibtpl: render failed: " + err.Error()) + } + return buf.String() +} + +// ---- the eight controlled cells (raw = stdlib-text, encoded = stdlib-html) ------------------- + +// RenderComposedPage renders workload 1 via text/template (stdlib-text). +func RenderComposedPage() string { + return render(composedTpl, composedBuf, model.Composed) +} + +// RenderTrivialSubstitution renders workload 2 via text/template (stdlib-text). +func RenderTrivialSubstitution() string { + return render(trivialSubstitutionTpl, trivialBuf, model.Substitution) +} + +// RenderLargeLoop renders workload 3 via text/template (stdlib-text). +func RenderLargeLoop() string { + return render(largeLoopTpl, largeLoopBuf, model.LargeLoop) +} + +// RenderMixedPage renders workload 4 via text/template (stdlib-text). +func RenderMixedPage() string { + return render(mixedPageTpl, mixedBuf, model.Mixed) +} + +// RenderConditionalHeavy renders workload 5 via text/template (stdlib-text). +func RenderConditionalHeavy() string { + return render(conditionalHeavyTpl, conditionalBuf, model.Conditional) +} + +// RenderFragmentHeavy renders workload 6 via text/template (stdlib-text). +func RenderFragmentHeavy() string { + return render(fragmentHeavyTpl, fragmentBuf, model.Fragment) +} + +// RenderFortunesEncoded renders workload 7 via html/template (stdlib-html). +func RenderFortunesEncoded() string { + return render(fortunesEncodedTpl, fortunesBuf, model.Fortunes) +} + +// RenderEncodedLoop renders workload 8 via html/template (stdlib-html). +func RenderEncodedLoop() string { + return render(encodedLoopTpl, encodedLoopBuf, model.EncodedLoop) +} diff --git a/benchmarks/go/internal/stdlibtpl/templates.go b/benchmarks/go/internal/stdlibtpl/templates.go new file mode 100644 index 00000000..0a9f45e8 --- /dev/null +++ b/benchmarks/go/internal/stdlibtpl/templates.go @@ -0,0 +1,112 @@ +// Package stdlibtpl hosts the stdlib-engine controlled ports: text/template for the six +// raw workloads and html/template for the two encoded workloads (README D1 / Q6.1 — both +// surfaces of the one credibility-pick stdlib engine; benchmark ids stdlib-text / +// stdlib-html). Template sources are Go raw string literals authored densely — byte-for-byte +// the pinned Heddle/twin shape with {{…}} actions in place of @(…) substitutions, no +// whitespace beyond what the pinned shape carries. Every template is parsed once in a +// package var block (the cached-template render path); renders execute into a reused +// pre-grown buffer (render.go). +// +// Normative texts: docs/spec/cross-stack-benchmarks/phase-6-go/port-mapping.md +// §Controlled track — stdlib surfaces (workload shapes from Phase 1 workloads.md). +package stdlibtpl + +import ( + htmltemplate "html/template" + texttemplate "text/template" +) + +// ---- workload 1 — composed-page (text/template) ---------------------------------------------- + +// composedHomeSrc is the home template (the include mapping of the twin table). +const composedHomeSrc = `{{template "layout" .}}` + +// composedLayoutSrc is the layout template — one line, no whitespace between actions, +// mirroring LiquidTemplates.LayoutTemplate's zero-separator concatenation. +const composedLayoutSrc = `{{define "layout"}}{{index .Section "meta"}}{{index .Section "social"}}{{index .Comp "assets_styles"}}{{index .Comp "custom_styles"}}{{index .Comp "head_scripts"}}{{index .Comp "body_scripts"}}{{range .AreaNames}}{{index $.Areas .}}{{end}}{{index .Comp "assets_scripts"}}{{index .Section "page_scripts"}}{{index .Section "endpage_scripts"}}{{index .Comp "body_end_scripts"}}{{end}}` + +// ---- workload 2 — trivial-substitution (text/template) --------------------------------------- + +// Dense one-line
card, ten {{.Member}} substitutions in pinned order, including +// the two attribute positions. +const trivialSubstitutionSrc = `

{{.Title}}

{{.Sku}}

{{.Price}}

{{.Brand}}

{{.Category}}

{{.Availability}}

{{.Summary}}

{{.Rating}}

` + +// ---- workload 3 — large-loop (text/template) ------------------------------------------------- + +// Value is an int; text/template renders ints via fmt (%v), identical bytes to strconv.Itoa. +const largeLoopSrc = `{{range .Items}}{{.Name}}{{.Value}}{{end}}` + +// ---- workload 4 — mixed-page (text/template) ------------------------------------------------- + +// The pinned skeleton transcribed line-for-line (line breaks between sibling elements are +// N2/N3-erased; the + + +
+

{{.StoreName}}

+ +
+
+{{if .ShowBanner}}{{end}} +
+

{{.HeroHeading}}

+

{{.HeroTagline}}

+
+
+{{range .Products}}

{{.Name}}

{{.Sku}}

{{.Price}}

{{if .OnSale}}

On sale

{{end}}

{{.Blurb}}

{{end}} +
+{{if .ShowDebugPanel}}
debug
{{end}} +
+
+

{{.FooterNote}}

+

{{.StoreName}} {{.Year}} {{.SupportEmail}}

+
+ +` + +// ---- workload 5 — conditional-heavy (text/template) ------------------------------------------ + +// The pinned single-line