diff --git a/.claude/rules/api-compatibility.md b/.claude/rules/api-compatibility.md new file mode 100644 index 000000000..2690a54fc --- /dev/null +++ b/.claude/rules/api-compatibility.md @@ -0,0 +1,18 @@ +--- +paths: + - "src/Heddle/**/*.cs" + - "src/Heddle.Tool/**/*.cs" +--- + +# API design and compatibility + +Details: [coding-standards.md § API](../../docs/spec/common/coding-standards.md#api-design-and-compatibility), [breaking-windows.md](../../docs/spec/common/breaking-windows.md). Anchored in the .NET Framework Design Guidelines. + +- Additive by default: new extensions, opt-in options, overloads. Existing templates and hosts compile and render byte-identically. +- Breaking changes land only in a ratified breaking window (D2), each with a migration note. Candidates go to the register in breaking-windows.md, not into code. +- Every new `TemplateOptions` property goes into the copy constructor (and `Equals`/`GetHashCode` when it keys identity) — missed before (`ProvideLanguageFeatures`); the completeness test enforces this. +- Public API additions carry XML docs + same-change updates to the affected published docs pages (`language-reference.md`, `custom-extensions.md`, `built-in-extensions.md`, `csharp-api.md`). +- Extend via the sanctioned seams (`[ExtensionName]` registry, options, function registry, output profiles, publish/read channel, resolver, encoder) — don't invent parallel mechanisms or new seams for single-implementation internals. +- The build names no extension: no compare against a built-in name in any form, no `"Heddle.Extensions."` type literal, no `"Heddle"` assembly-name compare, no name-keyed table. Read the binder, the attributes, or the hook. +- The build never predicts a compile-time hook either: the loader runs the extension's real `InitStart`/`CompleteInit` at materialization (once per request shape, over bodies served from the compiled form), so a bodied, hook-overriding, `[Prop]`-declaring or `[BranchRole]` custom extension precompiles in a default build. A call the build cannot serve costs **that template** (an `HED7031` decline with a machine-readable class), not the build — reach for a build error only where the template must not precompile silently. +- Extension instances are shared across concurrent renders: no mutable per-render state on them; per-render state lives in the `Scope` lineage. diff --git a/.claude/rules/coding-style.md b/.claude/rules/coding-style.md new file mode 100644 index 000000000..23354584a --- /dev/null +++ b/.claude/rules/coding-style.md @@ -0,0 +1,18 @@ +--- +paths: + - "**/*.cs" +--- + +# Coding style + +Authority order: (1) surrounding code in the file, (2) [coding-standards.md](../../docs/spec/common/coding-standards.md), (3) `.editorconfig` (editor guidance only, not a gate). + +- Block-scoped namespaces (`namespace X { … }`) — not file-scoped, even in new files. +- Allman braces predominantly — match the file. Braceless single-statement guards for early returns are idiomatic; multi-line/nested bodies always braced. +- `var` when the type is apparent; explicit types otherwise and for built-ins. +- Naming: `_camelCase` private instance fields; `PascalCase` private static readonly; `I`-prefixed interfaces; extension classes `Extension` with `[ExtensionName("name")]`. +- Existing quirks (space before parameter list, space after cast) are deliberate — do not "fix" them. +- Comments follow C1–C8: default to none; write one only when the code cannot carry the meaning; never cite specs, plans, findings, or reviews in code comments (C4). XML doc comments on public API; regression tests carry a doc comment explaining the pinned scenario. +- 4-space indent, UTF-8, final newline, ~120-column wrap. +- Never mass-reformat or run whole-file IDE cleanups; diffs contain only the lines the change needs. The established style is the maintainer's deliberate choice — consistency beats modernization. +- `src/Heddle.Language/generated/` is generated (ANTLR) — never hand-edited; grammar change = `.g4` edit → regen → commit both. diff --git a/.claude/rules/errors-diagnostics.md b/.claude/rules/errors-diagnostics.md new file mode 100644 index 000000000..fe6ebbe54 --- /dev/null +++ b/.claude/rules/errors-diagnostics.md @@ -0,0 +1,17 @@ +--- +paths: + - "src/Heddle/**/*.cs" + - "src/Heddle.Tool/**/*.cs" + - "src/Heddle.Language*/**/*.cs" +--- + +# Errors and diagnostics + +Details: [coding-standards.md § errors](../../docs/spec/common/coding-standards.md#error-handling-and-diagnostics), [cross-cutting-decisions.md D1 + registry](../../docs/spec/common/cross-cutting-decisions.md). + +- Compile-path problems are collected, not thrown: positioned `HeddleCompileError`/`HeddleCompileWarning` on the compile result, so template authors see all problems at once. The public API never surfaces raw exceptions for template mistakes. +- Host-programming errors throw at public API entry points (`ArgumentNullException` etc.). +- Every new diagnostic claims a stable `HED####` ID from the registry (feature-area blocks, D1) **in the same change**; IDs are never reused or renumbered. +- An ID whose condition becomes unreachable is **retired in place**, not deleted: the `HeddleDiagnosticIds` constant, the `HeddleDiagnosticCatalog` row, the registry row and a mention in the page the registry names as owner all stay, and the registry row says what stopped firing, why, and what replaced it (often nothing). `DiagnosticIdTests` gates all four; deleting any would free the number for a later, different fault. +- Messages name the construct, point at the remedy, and carry the template position. +- Security-sensitive failures never degrade to execution: a sandbox violation is a compile error, full stop. diff --git a/.claude/rules/performance.md b/.claude/rules/performance.md new file mode 100644 index 000000000..05c61f607 --- /dev/null +++ b/.claude/rules/performance.md @@ -0,0 +1,16 @@ +--- +paths: + - "src/Heddle/**/*.cs" + - "benchmarks/dotnet/**" +--- + +# Performance (render path is hot) + +Details: [coding-standards.md § performance](../../docs/spec/common/coding-standards.md#performance-rules-the-render-path-is-hot). + +- No new per-render allocations. `Scope` stays a `readonly struct` passed by `in`; optional per-render state is lazily created. +- Reflection at compile time only; render time executes pre-compiled delegates and direct calls. +- `[MethodImpl(AggressiveInlining)]` only on tiny, provably-hot transforms — with a benchmark justifying it. +- Perf claims are proven by BenchmarkDotNet (`benchmarks/dotnet`), never asserted. Hot-path changes run affected benchmarks before/after on the same machine: allocated bytes must not increase; mean must stay within reported error. Intentional trade-offs need maintainer ratification. +- Compile-time cost is "compile once, render many" — moderate compile-path allocation is fine, but startup/precompilation paths get measured too. +- No JIT-rescue assumptions for escaping allocations (D7). diff --git a/.claude/rules/spec-workflow.md b/.claude/rules/spec-workflow.md new file mode 100644 index 000000000..5070cf607 --- /dev/null +++ b/.claude/rules/spec-workflow.md @@ -0,0 +1,15 @@ +--- +paths: + - "docs/spec/**" +--- + +# Spec and decision workflow + +Details: [spec-conventions.md](../../docs/spec/common/spec-conventions.md), [cross-cutting-decisions.md](../../docs/spec/common/cross-cutting-decisions.md) (which also carries the condensed release and program records). + +- Specs live under `docs/spec/`, indexed from `docs/spec/README.md` (update the index in the same change); they are contributor material, never published (D9). +- Plan = what/why (ratified input); spec = exactly how. A finished spec has **no open questions** — every point is a closed decision with evidence, or a most-reversible default plus a named revisit trigger. +- Implementation follows the owning plan's declared item order (D5): assume 1..N−1 merged; never depend on later items. +- Changing an earlier recorded decision goes through the amendment mechanism (evidence → maintainer ratification → implemented by the amending effort); earlier spec text is never silently retrofitted. The accumulated E1–E28 ledger is closed — new amendments are recorded as dated notes in the owning spec (see cross-cutting-decisions.md § Cross-spec amendments ledger). +- Balanced-mode principle precedence on conflicts: correctness/back-compat → sandbox security → simplicity → measured hot-path performance → abstraction/extensibility. DRY consolidates knowledge (rule of three); YAGNI cuts presumptive features, never tests/benchmarks/docs. +- Writing style: sentence-case headings, en/em dashes, relative links only, cite `path` + member name over bare line numbers. diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md new file mode 100644 index 000000000..103ce6a66 --- /dev/null +++ b/.claude/rules/testing.md @@ -0,0 +1,22 @@ +--- +paths: + - "src/*Tests*/**" + - "src/TestCorpus/**" + - "samples/**" +--- + +# Testing + +Details: [testing-standards.md](../../docs/spec/common/testing-standards.md) — normative; this is the short form. + +- Loop: write the spec's tests failing first → implement to green → run the gate → fix forward. Goldens/baselines/spec tables change only with maintainer review, never to silence a red test. +- Gate (one combined run): `dotnet build -c Release` → `dotnet test --project src/Heddle.Tests/Heddle.Tests.csproj` (all TFMs, serially; both Debug and Release when `#if DEBUG` arms are touched) → no diff under `src/Heddle.Language/generated/` (unless the spec licenses a grammar change) → benchmarks when a hot path is touched → `npm run docs:build` when docs pages changed. +- xUnit v3 on MTP: `dotnet test --project ` is the canonical form; filters are MTP syntax after `--` (`--filter-method` etc.); a filter matching nothing exits 8; CI legs run through `.github/scripts/dotnet-test-guarded.sh `, which passes `--fail-skips on`. +- **Run a suite once and keep the whole output.** These suites take minutes; re-running one to see a detail you piped away is the single most expensive habit available. Always `2>&1 | tee` (or redirect) the full run to a file, then read that file — grep it for failures, open the failing case, count tests, whatever is needed. Never pipe a run through `tail`/`grep` as the only sink, and never re-run a suite to answer a question the first run already contained. Redirect per suite so runs stay separable, and prefer one combined script that captures every suite in a single pass over four separate invocations. +- **No test-count floors.** `--minimum-expected-tests` was tried on nine CI legs and withdrawn: it is satisfied by editing a digit, it names nothing when it reddens, it drifted (one commit raised `dotnet.yml` and left `lsp.yml`, and that leg then tolerated a six-test regression for four commits), and it fails a leg for the wrong reason whenever a test is legitimately quarantined. What a suite CONTAINS is gated inside the suite: `src//test-classes.txt`, one line per fact-declaring class, set-equality asserted by that suite's `TestClassInventoryTests` — a red gate names the class. Deliberately the same shape as corpus membership below. +- Fixtures: `.heddle` + sibling golden under `src/Heddle.Tests/TestTemplate`; every new golden/fixture directory is LF-pinned in `.gitattributes` **in the same change**. +- Negative/security tests assert positioned `HED*` diagnostics (ID + position) and prove the construct never executes. +- Descriptive PascalCase sentence test names; assert with context (`Assert.True(result.Success, result.ToString())`); tests are exempt from DRY. +- Concurrency: any state shareable across renders ships a parallel-render isolation test. +- Precompiled tier: fallback to dynamic is byte-identical, so an unpinned test proves nothing about the tier it claims to test. End-to-end tests pin the tier (`PrecompiledMismatchPolicy.Strict` + fallback sentinel); expected fallbacks are declared only via `FallbackGuard.Expect(key, reason)` (run-time) / `CompiledFormHarness.ExpectRefusal(key, class)` (build-time decline beyond the corpus row); quarantine guarded fixtures with an owning-item `Explicit = true` (reported *not run*, so it survives `--fail-skips`), never weaken them. +- Test-input single-sourcing: a template shape verified by more than one tier exists once, in the shared corpus, with declared (total) intent — duplicate copies drift silently and break the differential premise. Membership is gated by set equality — never a count or floor (a floor once hid 15 templates silently dropping out). diff --git a/.gitattributes b/.gitattributes index 0368cf010..306bae7cc 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,15 +13,41 @@ # output — failing on Windows while Linux (LF everywhere) passes. Pin every such root to LF so the # byte comparison holds identically on both platforms regardless of the developer's autocrlf setting. # All files under these roots are text (verified: no binaries). New golden/fixture roots MUST be -# added here in the same change that introduces them — see docs/spec/common/testing-standards.md. +# added here in the same change that introduces them. +# +# eol=lf pins line endings and says nothing about byte-order marks; the two are independent, and a +# BOM drift can pass every eol=lf check there is. Templates carrying a deliberate UTF-8 BOM declare +# it in src/TestCorpus/CorpusIntent.cs, gated by CorpusIntentGateTests. samples/**/golden/** text eol=lf src/Heddle.Tests/TestTemplate/** text eol=lf +src/Heddle.Tests/TestOutput/** text eol=lf src/Heddle.Tests/Streaming/** text eol=lf -src/Heddle.Generator.Tests/Snapshots/** text eol=lf -src/Heddle.Generator.IntegrationTests/Fixtures/** text eol=lf +src/Heddle.Build.Tests/Fixtures/** text eol=lf src/Heddle.LanguageServices.Tests/Corpus/** text eol=lf src/Heddle.Demo.Wasm/contract-fixtures/** text eol=lf +# The per-suite test-class inventories are read line by line and compared as a set on both platforms. +# Reading normalises CRLF, but pinning them keeps the diff a reviewer sees identical everywhere. +src/*/test-classes.txt text eol=lf + +# Golden oracle corpus: 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. +benchmarks/dotnet/GoldenCorpus/*.golden.html -text +benchmarks/dotnet/GoldenCorpus/*.verify.json text eol=lf +benchmarks/dotnet/GoldenCorpus/manifest.json text eol=lf +# Exported model fixtures are hash-recorded in the manifest like the goldens; LF text. +benchmarks/dotnet/GoldenCorpus/fixtures/** text eol=lf + +# Rust benchmark harness: composed-page fragment data must round-trip byte-exact (-text); template +# files are ordinary LF text (the harness erases their line endings, but LF keeps diffs clean). +benchmarks/rust/templates/** text eol=lf + +# Python benchmark harness: 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 @@ -41,3 +67,6 @@ src/Heddle.Demo.Wasm/contract-fixtures/** text eol=lf *.PDF diff=astextplain *.rtf diff=astextplain *.RTF diff=astextplain + +# The stored compiled-form fixture is bytes, not text: never normalised (P4-R3). +src/Heddle.Tests/TestTemplate/*.bin binary diff --git a/.github/rulesets/main-branch-protection.json b/.github/rulesets/main-branch-protection.json index 7eb7fa537..d7af86834 100644 --- a/.github/rulesets/main-branch-protection.json +++ b/.github/rulesets/main-branch-protection.json @@ -31,6 +31,7 @@ "required_status_checks": [ { "context": "build (ubuntu-latest)" }, { "context": "build (windows-latest)" }, + { "context": "release-tests / suites" }, { "context": "dco" } ] } diff --git a/.github/scripts/dotnet-test-guarded.sh b/.github/scripts/dotnet-test-guarded.sh new file mode 100755 index 000000000..f6b367fc3 --- /dev/null +++ b/.github/scripts/dotnet-test-guarded.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Runs one test project under Microsoft.Testing.Platform, with the two guards that are worth having. +# +# Usage: dotnet-test-guarded.sh [extra dotnet test args...] +# +# `--fail-skips on` turns a skipped test into a failed one. A skip is a test that stopped testing, and +# CI is the wrong place to find out quietly; the repository's protocol for a known-failing test is +# `[Fact(Explicit = true)]`, which is reported as NOT RUN rather than skipped and so survives this. +# +# Exit 8 -- a filter matched nothing, or discovery failed -- stays called out by name, because a stale +# filter that runs nothing used to pass. It is the platform's own signal, not a number anyone maintains. +# +# What this script no longer does is carry a per-suite `--minimum-expected-tests` floor. Nine of them +# were maintained by hand across two workflows and they failed at their own job: one commit added six +# tests, raised the floor in one workflow and left the other, and that leg then tolerated a six-test +# regression in silence for four commits. A floor is satisfied by editing a digit, it names nothing +# when it reddens, and it fails a leg for the wrong reason whenever a test is legitimately quarantined. +# Suite membership is gated instead by the checked-in test-class inventories (src//test-classes.txt, +# asserted by set equality inside each suite), which name the class that appeared or vanished. + +set -uo pipefail + +if [ "$#" -lt 1 ]; then + echo "usage: $0 [extra args...]" >&2 + exit 64 +fi + +project=$1 +shift 1 + +dotnet test --project "$project" "$@" -- --fail-skips on +status=$? + +if [ "$status" -eq 8 ]; then + echo "::error::Zero tests ran in ${project} -- a filter matched nothing, or discovery failed." +fi + +exit "$status" diff --git a/.github/scripts/resolve-release-tag-selftest.sh b/.github/scripts/resolve-release-tag-selftest.sh new file mode 100755 index 000000000..82b1fa394 --- /dev/null +++ b/.github/scripts/resolve-release-tag-selftest.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# Proves resolve-release-tag.sh decides what it claims to: which refs publish, which tag shapes are +# refused, and the Marketplace version a tag maps to - including the ordering property that mapping +# exists for: in release order, every tag outranks the one before it, a release over its own +# pre-releases included. The resolver runs only when someone pushes a tag, so without this its first +# execution is a real release. +set -u + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +resolver="$here/resolve-release-tag.sh" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +# The resolver asks git two things for a release tag: fetch main, and is this commit an ancestor of it. +# A shim answers both, so the table below tests the decision rather than this clone's history. +# MERGE_BASE_STATUS is the shim's answer to the ancestry question: 0 on main, 1 off it. +mkdir -p "$work/bin" +cat > "$work/bin/git" <<'SHIM' +#!/usr/bin/env bash +case "$1" in + fetch) exit 0 ;; + merge-base) exit "${MERGE_BASE_STATUS:-0}" ;; + *) exit 0 ;; +esac +SHIM +chmod +x "$work/bin/git" +PATH="$work/bin:$PATH" + +fail=0 +out="$work/out" +status=0 + +run() { # + : > "$out" + GITHUB_OUTPUT="$out" GITHUB_REF="$1" GITHUB_SHA=0000000000000000000000000000000000000000 \ + bash "$resolver" >/dev/null 2>&1 + status=$? +} + +value() { sed -n "s/^$1=//p" "$out"; } + +# Per-case reporting: `before` snapshots the failure count so each row says whether IT passed, rather +# than going silent because an earlier row failed. +check() { #