diff --git a/.github/workflows/opencode-review.yml b/.github/workflows/opencode-review.yml index 0be27fcad..3cd435c70 100644 --- a/.github/workflows/opencode-review.yml +++ b/.github/workflows/opencode-review.yml @@ -5,8 +5,11 @@ on: jobs: review: - # Third-party AI review is supplemental; Dependabot updates need no review and - # provider quota failures must not block the protected CI signal. + # Third-party AI review is supplemental and NOT a CI blocker (maintainer + # decision, 2026-09-09). Dependabot updates need no review. The action + # posts provider errors as PR comments while exiting 0, so the outcome + # step below still detects and reports a non-executed review — as a + # warning annotation, never a silent pass — without failing the job. if: github.actor != 'dependabot[bot]' runs-on: ubuntu-latest permissions: @@ -23,6 +26,9 @@ jobs: # Pinned to the `latest` tag as of 2026-08 (anomalyco/opencode@latest) # SHA: 2c14fc5586fe0b88e5c04732d2e846769cc35671 - uses: anomalyco/opencode/github@2c14fc5586fe0b88e5c04732d2e846769cc35671 + id: opencode + # Keeps running on error so the outcome step below always evaluates + # and reports what actually happened. continue-on-error: true env: # DeepSeek API key. Get one at https://platform.deepseek.com/api_keys @@ -39,3 +45,24 @@ jobs: - Verify TypeScript types, ESM imports, and Deno conventions. - Suggest concrete improvements with line-specific comments when possible. - Keep the summary brief; prioritize bugs > readability > style > nits. + + # v2.0.5 + - uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed + if: always() + with: + deno-version-file: .dvmrc + + - name: Report review outcome (non-blocking) + if: always() + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + RUN_ID: ${{ github.run_id }} + REVIEW_STEP_OUTCOME: ${{ steps.opencode.outcome }} + # The gate's verdict is downgraded to a warning annotation: a missing + # or errored review stays visible in the run log and annotations, but + # does not turn this supplemental job red. + run: | + deno run --allow-env --allow-net=api.github.com tools/check-review-outcome.ts || \ + echo "::warning::AI review did not execute for this run (see log above); reported, not counted as evidence." diff --git a/README.md b/README.md index 945ac1db7..c540595e9 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ and interactive regions activate selectively. The published stable line remains the 0.43 series on npm `latest`. `v0.44.0-beta.2` is the current public v0.44 prerelease — Beta.2, Productization + Governance Offload (ADR-0151) — published under dist-tag -`beta`. The next stage is Beta.3 (`v0.44.0-beta.3`). +`beta`. The accepted next work is Beta.2.1–Beta.2.3 convergence, followed by public +`v1.0.0-alpha.1` after admission ([roadmap](docs/roadmap/ROADMAP.md)). The `1.0.0` target remains unscheduled and requires separate evidence and approval. ```text @@ -21,6 +22,12 @@ current proven scope = static-first applications with fullstack output paths Source package line: `0.44.0-beta.2` (`v0.44.0-beta.2`). npm registry line: `v0.44.0-beta.2` (prerelease, dist-tag `beta`); npm `latest` remains the stable `0.43.3` line. +The accepted core products are **Element / Router**, with Route Mode +and Framework Mode sharing one Router core. UI is dogfood and a reference +implementation, not a third core product. [Product boundaries](docs/architecture/product-model.md) +and [the active plan](docs/current/VERSION_PLAN.md) distinguish planned convergence +from the currently shipped package surface. + ## Why OpenElement lets one Custom Element contract work in a standalone library and diff --git a/README.zh.md b/README.zh.md index e579db4dd..c2208d556 100644 --- a/README.zh.md +++ b/README.zh.md @@ -12,6 +12,12 @@ DOM 是默认服务端表示;交互区域按需升级。 npm registry 行为 `v0.44.0-beta.2`——预发布版本(dist-tag `beta`);npm `latest` 仍为 已发布的稳定 0.43 线。 +已确认的核心产品是 **Element / Router**,UI 用作 dogfood 和参考实现,不是第三条核心产品线。Route Mode 接受显式路由记录, +Framework Mode 默认由文件路由生成记录,两者共用内核。自维护 URLPatternList +是核心技术资产;每次替换同步清理旧实现。下一步为 Beta.2.1–Beta.2.3,验收后 +进入公开 `1.0.0-alpha.1`。详见[产品边界](docs/architecture/product-model.md)和 +[路线图](docs/roadmap/ROADMAP.md);以下包图描述当前已发布能力。 + ## 当前产品 ```text diff --git a/deno.json b/deno.json index 2fb6431d2..d1ad83851 100644 --- a/deno.json +++ b/deno.json @@ -42,8 +42,7 @@ "www:apply-seo": "deno run --allow-read --allow-write tools/apply-www-seo.ts", "www:pagefind": "cd www && deno run --config ../deno.json --allow-read --allow-write --allow-run --allow-env --allow-net --allow-ffi --allow-sys build-pagefind.ts", "preview": "cd www && deno run --allow-read --allow-write --allow-net --allow-env --allow-ffi npm:vite preview --config vite.config.ts", - "workflow:check": "deno run --allow-read --allow-run=git tools/check-project-workflow.ts && deno task v044:orchestration:check", - "v044:orchestration:check": "deno run --allow-read tools/check-v044-orchestration.ts", + "workflow:check": "deno run --allow-read --allow-run=git tools/check-project-workflow.ts", "v044:executor:check": "deno run --allow-read --allow-run=kimi tools/check-v044-executor.ts", "v044:role": "deno run --allow-read --allow-run tools/run-v044-role.ts", "docs:check-role-neutral": "deno run --allow-read tools/check-role-neutral-docs.ts", @@ -92,6 +91,7 @@ "dogfood:evidence": "deno run --allow-read --allow-write --allow-run tools/run-dogfood-evidence.ts", "package-artifacts:check": "deno run --allow-read --allow-write --allow-run --allow-net --allow-env tools/check-package-artifacts.ts", "consumer:packaged-ui": "deno run --allow-read --allow-write --allow-run --allow-env --allow-net tools/consumer-packaged-ui.ts", + "consumer:packaged-element": "deno run -A tools/consumer-packaged-element.ts", "pack": "deno task generate:ui-manifest && deno run --allow-read --allow-write --allow-run --allow-env tools/publish-npm.ts pack", "pack:dry-run": "deno task generate:ui-manifest && deno run --allow-read --allow-write --allow-run --allow-env tools/publish-npm.ts pack:dry-run", "publish:npm": "deno task generate:ui-manifest && deno run --allow-read --allow-write --allow-run --allow-env --allow-net tools/publish-npm.ts publish:npm", diff --git a/docs/adr/ADR-0151-v044-release-train-retopology.md b/docs/adr/ADR-0151-v044-release-train-retopology.md index 70555c939..4c0f9a84b 100644 --- a/docs/adr/ADR-0151-v044-release-train-retopology.md +++ b/docs/adr/ADR-0151-v044-release-train-retopology.md @@ -1,5 +1,7 @@ # ADR-0151: v0.44 release train retopology +> Future release scheduling is superseded by [ADR-0152](../adr/ADR-0152-product-router-and-alpha-convergence.md). Historical execution and release evidence below are retained; they do not schedule public 1.0 Alpha. + - Status: ACCEPTED (2026-09-02, maintainer directive) - Date: 2026-09-02 - Supersedes: the Beta-topology portion of ADR-0149 — the five-Beta mapping is diff --git a/docs/adr/ADR-0152-product-router-and-alpha-convergence.md b/docs/adr/ADR-0152-product-router-and-alpha-convergence.md new file mode 100644 index 000000000..0ac86f3e9 --- /dev/null +++ b/docs/adr/ADR-0152-product-router-and-alpha-convergence.md @@ -0,0 +1,212 @@ +# ADR-0152: Element / Router cores, reference UI and public 1.0 Alpha + +- Status: ACCEPTED (2026-09-07, explicit maintainer direction) +- Supersedes: ADR-0151's future release topology; upstream-only URLPatternList + policy in #1324; cleanup-only-at-Beta.2.3 sequencing; previous file-only Router + proposal. Historical release evidence remains unchanged. +- Preserves: ADR-0148 compiler/Vite boundary, exact-SHA release evidence, + protected promotion and release GO requirements, human RC/Stable authority. +- Tracking: [#1341](https://github.com/open-element/openelement/issues/1341). + +## Context + +Current package/tool boundaries obscure product responsibilities. Repeated route, +request, document and governance owners increase maintenance work. The maintainer +originally approved a three-working-day convergence target using existing implementations, +with continuous deletion and a real application slice before public Alpha admission. +The 2026-09-08 refinement below replaces that schedule with acceptance checkpoints. + +## Decision + +### Products and dependencies + +The core products are Element and Router. UI is dogfood and a reference implementation. Element owns compiled Web Components +execution, serialization and DOM claim/update. UI is a selected component reference +built on Element, independent of Router. It validates authoring, interoperability +and real application use; it does not promise a comprehensive design system. Router has two modes: + +- Route Mode consumes explicit records; its matching core is independent of + Element, Hono, Vite and filesystem access. +- Framework Mode defaults to file routing and adds page data, forms, Document, + Native or Lit rendering integration, navigation, SSR/SSG and official Vite integration. UI is optional; Lit support is an acceptance target, not shipped evidence. + +Explicit and generated records converge on one RouteTable/RouteResolution. +File paths own generated route paths; no duplicate `route.path` declaration. +Composition declares mount boundaries, deterministic order and collision behavior. +Explicit order is preserved; file generation owns its documented ordering policy. +A selected URL record owns its method map: unsupported methods return 405 rather +than falling through to a different URL pattern. Query parameters and path captures +stay separate. Public browser projections exclude server handlers and host bindings. + +Products are not package counts. Existing `app`, `adapter-vite` and `create` remain +implementation/distribution/tooling surfaces until a justified migration changes +exports. No mandatory package renaming or new broad UI design system is implied. + +### Maintainer refinement: two core products and independent Element delivery + +The 2026-09-07 follow-up refines the earlier three-product wording: Element and +Router are the core products; Framework Mode belongs to Router. UI is dogfood and +reference implementation, alongside the website/representative consumer application. +Its current package may remain available without an independent design-system +roadmap. Do not force two physical npm packages or replace all UI in this sprint. + +The private compiler has an explicit distribution path through Element tooling; +published artifacts cannot depend on unpublished workspaces. Compiler and runtime +meet at versioned generated artifacts. Router tooling reuses Element's build path, +never defines component semantics or ships a duplicate compiler. Separate build, +browser, server and declaration entry graphs; preserve source maps and compiler +source diagnostics. Public tooling subpaths are permitted without publishing a +stable compiler API or hiding Vite behind a second configuration system. + +Standalone qualification means author -> compile -> pack -> ordinary HTML consumer, +without Router or a workspace alias. A lightweight Element runtime dependency is +allowed; compiler/Node/Vite code must not leak into browser output or browser types. +Foreign CE property/attribute/event/upgrade boundaries need real browser evidence. +Internal foreign SSR/hydration remains component-specific, not a universal DSD +promise. Broader React/Vue/etc. integration matrices are Alpha follow-up work. + +### URLPatternList is an independently maintained generic fork + +Start from Justin Fagnani's `url-pattern-list` v0.5.0, source commit +`4911e649cc11860c7da90c9d0d9b05626c5cbb83`, with verified MIT attribution and a +compact provenance/divergence record. Own list indexing, ordered traversal, +differential tests and measured performance. Luca Casonato's proposals inform +structured matching and conservative fallback; they are not merged production +Deno implementations to transplant. + +URLPattern remains the platform/polyfill single-pattern grammar and capture +owner. Candidate pruning must not discard a valid earlier match; final `exec` +cannot recover a candidate already discarded by an incorrect index. Unproven +optimizations use a conservative candidate path merged in the same sequence order. +The linear reference oracle belongs in tests. No global API injection, claimed +standard polyfill, public tree/parser contract, or full replacement URLPattern +engine. Initial route tables may be rebuilt and atomically replaced; complex +incremental mutation and speculative native/WASM optimization are deferred. + +References: [Justin's source](https://github.com/justinfagnani/url-pattern-list), +[Luca's proposal](https://github.com/whatwg/urlpattern/pull/166), +[Deno experiment](https://github.com/denoland/deno/pull/14502). + +### Framework responsibility + +Hono owns HTTP Context/middleware/Response integration without a second page +winner. Useful page-data/form abstractions share one request lifecycle; preserve +validation errors, status, redirects and serialization boundaries. Document owns +resolved page meaning; each qualified rendering integration owns serialization. Native +reuses Element; Lit does not depend on the Native compiled-artifact protocol. Layouts compose presentation without +a second data scheduler. Vite owns the official build/development integration. + +SSG may read external build-time data; personalized results must not become public +static output. Sitemap/search use eligible public route identity/catalog data, +not an enumeration of private request-specific Documents. Navigation coordinates +abort, stale results, history and required browser fallbacks. + +### Continuous reduction + +Every replacement retires its displaced implementation, callers, compatibility +layers, duplicated facts and obsolete checks/docs in the same verified change. +Cleanup begins in Beta.2.1, continues in Beta.2.2 and closes in Beta.2.3. Measure +owners, execution hops, custom scripts/tasks/checkers and retained obligations; +line deletion is not a quota. Preserve required behavior with regression evidence, +not permanent duplicate paths. Git is the default operational-history archive. + +The obsolete `check-v044-orchestration` script/test/task is retired with this +planning change: it pins current documents to completed Alpha workspace IDs and +withdrawn Beta scheduling. Current workflow, version/release truth and exact-SHA +release gates remain; no replacement historical-topology checker is introduced. + +### Release topology and timebox + +```text +published v0.44.0-beta.2 + -> beta.2.1 Router/core + continuous cleanup + -> beta.2.2 Native/Lit Framework Mode + Document + continuous cleanup + -> beta.2.3 dual-mode hardening + cleanup closure + application admission + -> public v1.0.0-alpha.1 and subsequent Alpha iterations + -> evidence-gated v1.0.0-rc.1 + -> separately admitted Stable +``` + +The former Beta.3 lane becomes 1.0 Alpha; its unfinished work remains tracked. +Historic v0.44 alpha workspace IDs remain internal/unpublished. Public 1.0 Alpha +uses npm `alpha`; npm `latest` stays on the last admitted stable release. + +The 2026-09-08 maintainer refinement replaces the three-day target with acceptance +checkpoints: Beta.2.1 foundation/fork/repairs, Beta.2.2 Native/Lit Framework Mode, +Beta.2.3 dual-mode hardening and cleanup/admission. No automatic Beta.2.4 or deadline +waiver. The active version plan owns the detailed scope and matrix. + +### Maintainer refinement: fork governance and Lit (2026-09-08) + +Maintain `open-element/url-pattern-list` as a public source-traceable fork with its +own qualified releases. OE pins a built package version and removes its embedded +production duplicate. Generic fixes/tests/benchmarks go upstream without blocking +OE delivery; returning to the official dependency is optional. RouteRecord, HTTP, +loader/action and rendering lifecycle never enter this generic library. Ordered +first-match is a legitimate generic capability, not an OE-only concern. + +Native and Lit are first-class Router Framework Mode targets. Prove both complete +application flows before extracting a minimal shared execution protocol. Rendering, +SSR and client continuation remain integration-specific. Formal support requires +packed consumers, documentation and an explicit runtime matrix; DSD/CEM do not prove +hydration or provider compatibility. No third renderer or speculative public adapter +framework is required. Product independence is proven through dependency graphs. + +Native navigation owns only admitted application navigation: unowned POST forms, +fragments in history mode and reload remain browser-owned. SSG discovers canonical +records, not Hono's incidental route list. Every mounted lifecycle gets a fresh +controller. Final review must actually execute; the #1343 insufficient-balance +comment is missing review evidence even if its workflow wrapper is green. + +## Acceptance and consequences + +[#1340](https://github.com/open-element/openelement/issues/1340) owns Alpha admission: +matching/HTTP correctness, a packed real-app flow, SSR/SSG/claim/navigation/forms, +server/client separation, required runtime/browser coverage, cleanup and exact +candidate release evidence. Cloudflare is the first integration target; Node and +Deno retain full qualification in their agreed contract, Bun/Nitro their smoke +scope, Chromium/Firefox/WebKit their required browser coverage. Missing evidence +is never a pass. Broader benchmarks, prolonged application qualification and UI +expansion do not block entry unless they expose a core correctness failure. + +Release automation must implement the new successor/channel distinction (#1323, +#1334) before publication. This ADR and its documentation PR do not change package +versions, publish artifacts, grant Stable readiness, or claim runtime migration +has already happened. Planning may change now while code still implements the +published baseline; outstanding implementation is visible in the issue graph. + +## Alpha/RC refinement (2026-09-08) + +The maintainer approves evidence-driven Alpha architecture iteration followed by RC +contract freeze. #1179 real application qualification starts after Alpha admission, +not after RC; #1243 admits RC and #1178 freezes its artifacts. #1234 freezes each +qualification round's identity, not the entire Alpha design. Candidate changes require +explicit evidence invalidation/requalification; no mixed-artifact PASS claim. + +Native/Lit share application semantics but have separate renderer/runtime qualification. +Element JFB is not Router/Lit evidence. Independent onboarding, promised migrations and +fork maintenance are Alpha qualification obligations. One substantial application plus +equivalent journeys in the other mode suffices; two full SaaS products are not required. + +RC freezes the admitted public contracts, supported matrix and exact dependency set. +Keep fourteen-day soak, install/upgrade/security, triage and human GO. Architecture +changes return to Alpha; fixes/dependency updates create new qualified candidates. +Stable is 1.0.0 with the actual admitted packages. Old 0.44/five-package/0.41-waiver +wording does not govern this release; historical evidence remains unchanged. + +## Upstream-contribution priority in Beta.2.x (2026-09-08) + +The maintainer prioritizes the path most likely to yield sustained useful upstream +contributions over locally optimal speed or configuration size. Select WTR for browser +conformance because Lit/Modern Web share it; qualify with the existing compiled ESM +path and retain Playwright application E2E/Deno runtime tests. A lower-glue alternative +alone does not overturn this selection. Concrete correctness/distribution/integration +blockers require an explicit plan reassessment. + +Keep recipient-native harnesses: Node tests for URLPatternList, WTR for Lit/browser +reproductions and Oxc's tests for compiler fixes. Preserve fork provenance and minimize +unnecessary divergence. Record generic reproduction/patch boundaries and OE-only +semantics, with no PR quota, invented bug or dependency on upstream response. Local +truth consolidation may have no upstream patch. Beta.2.2 Oxc/TS7 work is feasibility; +full backend migration remains a separately qualified Alpha change. Existing #1343 +correctness/review blockers and release gates are unchanged. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 0c2a8c789..048a650b7 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -12,3 +12,17 @@ Current product architecture is organized by responsibility: Active ADRs record only unresolved or still-governing hard decisions. Stable facts graduate into these pages. Historical ADR identity is its original path plus Git blob SHA, recorded by the ADR migration manifest. + +## Planning and public metadata map + +- [Beta.2.x checkpoints and acceptance](../current/VERSION_PLAN.md) +- [Roadmap](../roadmap/ROADMAP.md) +- [Issue ownership map](../roadmap/v0.44.0-ISSUES.md) +- [Fork and Native/Lit decision](../adr/ADR-0152-product-router-and-alpha-convergence.md) + +Public docs sitemap/search are generated from eligible route/catalog metadata under +[#1327](https://github.com/open-element/openelement/issues/1327). Do not hand-maintain +XML or publish planned APIs as implemented documentation. SSG discovery repair is a +Beta.2.1 prerequisite; metadata convergence remains Beta.2.2. + +- [Infrastructure reduction audit and WTR pilot](./infrastructure-reduction.md) diff --git a/docs/architecture/infrastructure-reduction.md b/docs/architecture/infrastructure-reduction.md new file mode 100644 index 000000000..1eabfd9eb --- /dev/null +++ b/docs/architecture/infrastructure-reduction.md @@ -0,0 +1,129 @@ +# Infrastructure reduction: responsibility audit + +Maintainer-approved contribution priority and migration plan, 2026-09-08. Inspected planning checkout `75a05c3c`; this is a +bounded source audit, not completed migration or a whole-repository deletion count. +The separate #1343 implementation branch must be rechecked before applying changes. +Tracking: #1156 (generic infrastructure), #1333 (test/consumer consolidation), +#1332 (CI), #1334 (release truth), #1327 (public metadata), #1324 (matching fork). + +## Recommendation + +Select Web Test Runner (WTR) for browser conformance, qualified first through a bounded +pilot. The primary selection criterion is sustained useful upstream contribution, +not least local glue or fastest local execution. Retain Playwright Test for full +application/navigation/SSR E2E and Deno tests for pure algorithms/runtime contracts. +Additional integration effort may be justified by portable upstream reproductions; +correctness and sustainable integration remain requirements. Do not introduce a +second OE compiler or permanent runner framework. + +Lit upstream uses WTR with Playwright Chromium/Firefox/WebKit launchers and WTR SSR +integration middleware. Shared test style reduces the effort to send independent +browser regressions to Lit/Modern Web. It does not guarantee upstream acceptance. +Justin's URLPatternList uses Node's test runner; contributions there should use that +runner and a generic corpus, not require OE or WTR. Compiler contributions follow +Oxc's own harness. Choose the recipient's conventions for each contribution. + +Prefer small reproductions, correct attribution and narrowly scoped fixes over PR +count. Neither upstream adoption nor response is a prerequisite for OE delivery. + +## Source-backed deletion candidates + +Line counts describe inspected source, not promised deletions. No test coverage is +to be removed merely to hit a count. + +| Responsibility and source | Finding | Disposition and replacement | Retained acceptance / owner | +| ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `packages/element/__tests__/compiled-runtime/facade-dom.ts` (618 lines) | Simulates registry, upgrade, attributes, shadow roots, events and connection lifecycle | First WTR migration target; delete simulated platform and callers only after browser coverage replaces them | Real custom-element upgrade, composed events, reconnect, node identity; #1333 | +| `packages/app/__tests__/dom-stubs.ts` (223 lines) | Shared DOM implementation for Preact interop tests | Move existing interop assertions to browser; remove stub if no unique consumers remain | Preserve currently promised interop, do not expand Preact product scope; #1333 | +| `packages/element/__tests__/compiled-runtime/test-dom.ts` (327 lines) | DOM substitute also counts elements/writes/removals | Split responsibility: browser conformance in WTR; retain a minimal counting test double only for deterministic algorithm-cost assertions | Browser is platform authority; counting double is not DOM conformance; #1333 | +| `packages/adapter-vite/__fixtures__/ui-dogfood/` | Full application shell for many component behaviors | Move component focus/keyboard/ARIA/reconnect tests to package browser suites; remove redundant app shell only after coverage transfer | Keep adapter SSR/no-JS/claim integration and packed UI tests; #1333 | +| `tools/check-critical-path-tests.ts` (309 lines) | Runs other suites and interprets infrastructure absence, output and exit state | Prefer direct runner/CI projects with selected suites; retire wrapper and matching tests once failure behavior is equivalent | Missing browser, spawn error and failed test must fail required CI; #1332 | +| `tools/check-package-artifacts.ts` (300 lines) | Already invokes pinned publint and ATTW, also checks OE-specific output policy | Do not introduce duplicate tooling. Reduce overlapping generic scans/command wrappers around existing tools | Keep real tarball consumers and no compiler/host code in browser entries; #1156 | +| `tools/check-package-graph.ts` + `tools/lib/package-graph.ts` (640 lines) | Generic traversal/cycle logic mixed with Deno workspace exports, versions and publish ordering | Evaluate dependency-cruiser for traversal/rules only; migrate callers before deleting graph helpers | Prove import-map/generated-specifier resolution and retain publication/entry semantics; #1156/#1330 | +| `tools/check-www-links.ts` + `tools/lib/www-links.ts` (298 lines) | Built link checks mixed with SEO and generated API-anchor assertions | Evaluate lychee for ordinary links/fragments; move metadata truth to #1327 | Keep generated Pagefind/API anchors, locale/canonical and private-page exclusion checks until equivalent proof; #1156/#1327 | +| `tools/check-version-anchors.ts` (375 lines), `tools/check-docs-truth.ts` (949 lines) | Multiple current projections and prose checks; not all generic | Remove duplicated writable facts first; derive projections and retire redundant regex checks | Source/registry/in-flight release distinction, truthful planned/shipped docs and exact release evidence remain; #1334/#1188 | +| `tools/run-package-graph-task.ts` (112 lines) | Small export-aware Deno typecheck runner, not a test framework | Low-priority review with TS7/typecheck migration; retain until native commands preserve behavior | Deno imports/types and public export coverage; #1331 | + +The three DOM helpers total 1,168 lines. The nine inspected tools/helper files total +2,983 lines, including valuable OE-specific logic. Neither total is a deletion quota. +Dependencies, callers, platform requirements and generated artifacts must be checked +at implementation time; filenames alone do not establish redundancy. + +## WTR pilot and exit contract + +1. Compile the selected Native fixture with the existing official Element build path; + WTR consumes generated ESM. Do not write another TSX transform or compiler API. +2. Migrate one facade lifecycle/attribute test and one composed-event/shadow test from + the simulated DOM. Test node identity and observable behavior, not HTML alone. +3. Add an equivalent minimal Lit host/connection case using Lit's own build/runtime; + this is interoperability evidence, not a claim of full Lit Framework Mode support. +4. Run Chromium, Firefox and WebKit using the WTR Playwright launcher. Verify failing + assertions and missing browser/setup errors return nonzero in the required CI path. +5. Keep full-document DSD parsing/hydration, native POST, fragment/reload and history + E2E in Playwright. Setting innerHTML or mounting a helper is not equivalent to + browser parsing a server response. +6. Record setup/transform code, execution time, debugging/source-map quality, install + requirements and transitive dependency delta. Include a clean packed consumer. +7. Prefer standard DOM setup and explicit OE lifecycle waits. Do not add Lit runtime + to standalone Element merely for a fixture helper, or hide timing bugs with sleeps. +8. After parity, remove the migrated fake-platform code and redundant CI/task paths. + Short migration coexistence has an explicit completion boundary; no permanent + second browser-conformance runner or new runner abstraction. + +WTR is the selected direction. Less Vite configuration alone does not justify switching +to Vitest. Reconsider only after documenting a concrete correctness, distribution or +sustainable-integration blocker and revising the plan; do not permanently adopt both. +No pilot was executed in this audit; runner installation and migration remain open. + +## Contribution acceptance + +For each migration identify the upstream recipient, source version, its native test +harness, portable reproducer, proposed generic patch and OE-only remainder. Useful +fixes should be prepared for upstream contribution; no invented bug, cosmetic PR quota +or merge-count target. A contribution-ready patch can be reviewed independently of OE. +If no upstream change is necessary, record local deletion/standard-tool adoption as +such. Upstream response or acceptance does not block OE's release. + +WTR/Lit is the browser-test path; URLPatternList retains Node test conventions; Oxc +contributions use its own test suite; generic package/graph/link gaps go to the tool +that owns them. Version/metadata duplication may be entirely local cleanup. Do not +force every deletion to produce a public PR. + +## Priorities + +- Beta.2.1: keep #1343 correctness/review blockers first. Inventory overlapping generic + checks under #1156; do not make a compiler/test-stack rewrite a prerequisite for + repairing SSG or native navigation. +- Beta.2.2: run the WTR pilot alongside Native/Lit integration; start moving proven + component conformance away from simulated platform implementations. +- Beta.2.3: #1333 closes qualified test/fixture consolidation; #1332 removes obsolete + execution wrappers; #1334/#1327 remove duplicated truth before deleting checks. +- Beta.2.2: #1156 scopes Oxc/TS7 feasibility (decorators, diagnostics, source maps, + declarations and Deno resolution) without changing the production backend. +- Alpha: Oxc/TS7 backend migration follows its own correctness/diagnostics/artifact + evidence. Do not couple all infrastructure changes into one mandatory mega-migration. + +## Retain in OE + +Part Program semantics/validation, compiler diagnostics, Router winner/method/action +policy, lifecycle cancellation and qualified Native/Lit integration are product work. +Tests of these remain OE-owned. Small test doubles, fixture data and direct command +invocations can be justified; a custom platform, generic parser, runner or workflow +framework needs stronger evidence. Exact-artifact and actual-review requirements +survive removal of their obsolete implementations. + +For each migration record: replaced responsibility, production/test/config deletions, +new dependencies/glue, retained OE invariants, failure-path evidence and remaining +callers. Use existing issues/PRs, not another permanent checker or evidence database. + +## Primary sources + +- [WTR capabilities](https://modern-web.dev/docs/test-runner/overview/) +- [WTR Playwright launcher](https://modern-web.dev/docs/test-runner/browser-launchers/playwright/) +- [Lit shared WTR configuration](https://github.com/lit/lit/blob/main/packages/tests/src/web-test-runner.config.ts) +- [Lit SSR WTR integration](https://github.com/lit/lit/blob/main/packages/labs/ssr/web-test-runner.config.js) +- [URLPatternList Node test command](https://github.com/justinfagnani/url-pattern-list/blob/main/package.json) +- [Vitest Browser alternative](https://vitest.dev/guide/browser/) +- [publint](https://publint.dev/docs/) +- [Are the Types Wrong](https://github.com/arethetypeswrong/arethetypeswrong.github.io) +- [dependency-cruiser](https://github.com/sverweij/dependency-cruiser) diff --git a/docs/architecture/product-model.md b/docs/architecture/product-model.md index 979eab92d..263d15fb9 100644 --- a/docs/architecture/product-model.md +++ b/docs/architecture/product-model.md @@ -1,6 +1,67 @@ # Product model -OpenElement is a Web Components-native application framework. A standard Custom -Element is the durable component boundary. JSX is compiled authoring input, not a -public runtime virtual-DOM contract. The supported package graph contains `element`, -`app`, `adapter-vite`, `create`, and optional `ui`. +OpenElement has two core products: **Element** and **Router**. UI is dogfood +and a reference implementation, not a third core product. This is the +accepted target under [ADR-0152](../adr/ADR-0152-product-router-and-alpha-convergence.md); +Beta.2.x issues track implementation. It does not claim all target APIs already ship. + +| Product | Responsibility | Dependency boundary | +| ---------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | +| Element | Compiled Web Components, reactivity, lifecycle, server serialization, fresh DOM and existing-DOM claim/update, styles | Independent component execution foundation | +| UI (reference) | Dogfood components demonstrating interaction, accessibility, themes and composition | Element; Router optional | +| Router: Route Mode | Explicit records, matching/resolution, HTTP and browser integration entry points | Matching core independent of Element, Hono, Vite and filesystem | +| Router: Framework Mode | File routing by default, page data/forms/errors, layouts, Document, SSR/SSG, navigation and Vite integration | Same Router core plus Native or Lit integration; UI optional | + +A standard Custom Element is the durable component boundary. JSX is compiled +input, not a public runtime virtual-DOM contract. Element's compiler semantics +remain separate from Vite integration under ADR-0148. + +Explicit routes and file-generated routes share one RouteTable/RouteResolution. +File routes never require a second handwritten path declaration. The Router owns +composition/order/collision and HTTP policy; its self-maintained URLPatternList +owns candidate indexing and ordered matching. URLPattern owns single-pattern +syntax and captures. Public browser records exclude server-only code and bindings. + +Element execution and URLPatternList are strategic technical assets. Vite adapter, +CLI/create, deployment integration and compiler tooling are supporting surfaces, +not extra product lines. Current packages remain `element`, `app`, `adapter-vite`, +`create` and optional `ui` until an explicit export migration is implemented. +Product count is not npm package count. + +The initial application target is HTML-first content and dynamic business pages, +forms and local interactive components. UI stays a selected dogfood/reference surface; a full design system, +generic RPC/ORM/cache/queue framework and comprehensive offline application data +layer are outside this convergence sprint. + +## Standalone delivery and reference applications + +Element must be independently authored, built, packed and consumed from plain HTML +without Router or private workspace dependencies. Its compiler remains private, +with a versioned artifact contract; build integration delivers the compiler through +an explicit tooling entry. Runtime/browser/type entry graphs must exclude Vite, +TypeScript compiler and Node-only implementation. Native Framework tooling reuses the same +Element compilation path; Lit integration uses its own rendering lifecycle. Neither +changes the shared routing/application semantics. + +UI supplies selected dogfood components. The website and a representative consumer +supply reference application evidence. Their defects can reveal core blockers, but +component count, UI redesign, replacement with Web Awesome, and reference-site +expansion are not independent Beta.2.x release objectives. The existing ui package +may remain published; product demotion is not a package removal or migration claim. + +Foreign Custom Elements are opaque browser-standard boundaries. OE binds host +properties/attributes/events/slots without compiling their internals. Foreign SSR +and hydration require an explicit supported integration; DSD alone is not a shared +hydration protocol. No mandatory Web Awesome dependency or wrapper library. + +## Native and Lit Framework Mode target + +The 2026-09-08 plan makes Native and Lit first-class support targets. Beta.2.2 proves +SSR/client continuation/navigation/actions for both; Beta.2.3 hardens packed consumers +and documents runtime differences. This is not a claim of currently shipped Lit SSR. +Router owns application outcomes and cancellation; rendering integrations own their +serialization and client lifecycle. Extract only interfaces proven by both flows. + +The generic matching layer is maintained in the public URLPatternList fork, consumed +as a pinned qualified package. Upstream contributions are independent of OE delivery; +a return to upstream is optional. HTTP and framework semantics stay outside the fork. diff --git a/docs/current/DENO_DESKTOP_TARGET.md b/docs/current/DENO_DESKTOP_TARGET.md index e0498b90a..d2fee796f 100644 --- a/docs/current/DENO_DESKTOP_TARGET.md +++ b/docs/current/DENO_DESKTOP_TARGET.md @@ -67,7 +67,7 @@ plain modules is mechanical, but the `render()` bodies then fail OEC9007 re-authoring of nine large renders against the compiled grammar with no browser E2E safety net — not a bounded fix. The ruling: the desktop examples are **excluded from qualifying consumer evidence** and the re-authoring is -carried to Beta.3 (B3.8). +carried to public 1.0 Alpha (#1311); examples remain frozen during Beta.2.x. `deno task check` and `deno task smoke` stay green and CI-gated (`examples:check`), so the examples still qualify the SPA runtime, loaders, diff --git a/docs/current/PACKAGE_SURFACE.md b/docs/current/PACKAGE_SURFACE.md index 02a7c10bf..e1cd30895 100644 --- a/docs/current/PACKAGE_SURFACE.md +++ b/docs/current/PACKAGE_SURFACE.md @@ -12,13 +12,13 @@ authoring modes = Basic Element standalone + full application ## Current five-package surface -| Package | Responsibility | Supported public interface | -| --------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------- | -| `@openelement/element` | JSX, Custom Elements, DSD, hydration, signals and component runtime contracts | root, `jsx-runtime`, `jsx-dev-runtime`, `build-utils`, `sanitize` | -| `@openelement/app` | Pages, routes, loaders, actions, islands and normalized request semantics | root, `model`, `spa`, `preact` | -| `@openelement/adapter-vite` | Vite, content, SSG, generated data, Hono and Nitro build/deploy implementation | root, `nitro-mount`, `cli/build`, `cli/start`, `sitemap` | -| `@openelement/create` | Version-coherent starter generation and consumer lifecycle | CLI binary (root) | -| `@openelement/ui` | Optional, reusable and dogfood-proven Web Component primitives | root and retained primitive subpaths | +| Package | Responsibility | Supported public interface | +| --------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------- | +| `@openelement/element` | JSX, Custom Elements, DSD, hydration, signals and component runtime contracts | root, `jsx-runtime`, `jsx-dev-runtime`, `build-utils`, `sanitize` | +| `@openelement/app` | Pages, routes, loaders, actions, islands and normalized request semantics | root, `model`, `spa`, `preact`, `router`, `router/http` | +| `@openelement/adapter-vite` | Vite, content, SSG, generated data, Hono and Nitro build/deploy implementation | root, `element`, `nitro-mount`, `cli/build`, `cli/start`, `sitemap` | +| `@openelement/create` | Version-coherent starter generation and consumer lifecycle | CLI binary (root) | +| `@openelement/ui` | Optional, reusable and dogfood-proven Web Component primitives | root and retained primitive subpaths | Responsibility wording follows [`STACK_CONTRACT.md`](./STACK_CONTRACT.md), the source of truth for the five-package responsibility table. @@ -61,24 +61,63 @@ promise and are not application-authoring surface. @@ -86,7 +125,7 @@ promise and are not application-authoring surface. - `@openelement/element/build-utils` (alpha.17): build-time helpers (`transformIslandSource`, `formatJson`, `pathToTagName`, `normalizeSeparators`, `insertBeforeBodyClose`, - `normalizeRoutePatternForURLPattern`, `SsrRenderError`, + `SsrRenderError`, `createRuntimeAdapter`, `composeFetchMiddleware` and the runtime handler types) for build adapters. They were removed from the element root export; application code must not @@ -112,7 +151,7 @@ promise and are not application-authoring surface. supported implementations through named public modules and never name an `internal/` module specifier. - `@openelement/app/i18n` is the optional locale-expansion integration point. -- App's router implementation (`internal/router`) is not exported; the router +- `@openelement/app/router` exports pure Route Mode matching; `router/http` adds Hono execution. Private tree/parser details remain unexported. The browser router types (`RouteConfig`, `RouterInstance`, `RouterMode`) were removed from the app root export in alpha.17 — SPA consumers derive the instance from `ReturnType` and the options from @@ -264,7 +303,6 @@ classified name missing from the prose fails the gate. "createRuntimeAdapter": "internal-importable", "formatJson": "internal-importable", "insertBeforeBodyClose": "internal-importable", - "normalizeRoutePatternForURLPattern": "internal-importable", "normalizeSeparators": "internal-importable", "OpenElementRequestHandler": "internal-importable", "pathToTagName": "internal-importable", @@ -335,6 +373,18 @@ classified name missing from the prose fails the gate. "definePreactIsland": "stable-candidate", "PreactIslandConstructor": "stable-candidate", "PreactIslandOptions": "stable-candidate" + }, + "router": { + "RouteTable": "experimental", + "RouteRecord": "experimental", + "RouteMatch": "experimental", + "RouteResolution": "experimental", + "RouteTableOptions": "experimental", + "normalizeRoutePatternForURLPattern": "internal-importable" + }, + "router/http": { + "createRouteMiddleware": "experimental", + "HttpRouteRecord": "experimental" } }, "@openelement/adapter-vite": { @@ -396,6 +446,9 @@ classified name missing from the prose fails the gate. "cli/build": {}, "cli/start": { "extractServeMode": "internal-importable" + }, + "element": { + "element": "experimental" } }, "@openelement/create": { @@ -471,7 +524,7 @@ classified name missing from the prose fails the gate. | `jsx-runtime` | stable-candidate | `Fragment`, `jsx`, `JSX`, `jsxs` | | `jsx-dev-runtime` | stable-candidate | `Fragment`, `JSX`, `jsxDEV` | | `sanitize` | stable-candidate | `isSafeUrl`, `sanitizeHtml`, `SanitizeOptions` | -| `build-utils` | internal-importable | `composeFetchMiddleware`, `createRuntimeAdapter`, `formatJson`, `insertBeforeBodyClose`, `normalizeRoutePatternForURLPattern`, `normalizeSeparators`, `OpenElementRequestHandler`, `pathToTagName`, `RuntimeContext`, `SsrRenderError`, `transformIslandSource` | +| `build-utils` | internal-importable | `composeFetchMiddleware`, `createRuntimeAdapter`, `formatJson`, `insertBeforeBodyClose`, `normalizeSeparators`, `OpenElementRequestHandler`, `pathToTagName`, `RuntimeContext`, `SsrRenderError`, `transformIslandSource` | ### `@openelement/app` @@ -628,7 +681,7 @@ Earlier removed product experiments and adapters remain historical only: - Element owns the browser/runtime implementation and runtime contracts. - `@preact/signals-core` is Element's internal signal engine, not a consumer OpenElement package surface. -- App owns routing and application semantics; its router is internal. +- App owns routing and application semantics; its pure router and HTTP integration have separate entry points. - Adapter Vite owns content, static generation, deployment and build contracts. - Create templates and current docs may import only retained product packages. - Runtime-free packages (`element`, `app`, `ui`) contain no Deno or Node host API @@ -646,3 +699,43 @@ export stability classification above are checked together by `deno task package-surface:check`. Historical ADR and release evidence retain their original package names; they are not current usage documentation. + +## Beta.2.1 entry migration (ADR-0152) + +`@openelement/app/router` owns records, ordered resolution, separate pathname +captures and `URLSearchParams`. Its dependency graph excludes Element/Hono/Vite. +`@openelement/app/router/http` provides `createRouteMiddleware`, mounted after +host middleware/API routes; URL winner precedes method dispatch. Records group +method handlers, explicit HEAD wins, implicit HEAD uses GET, 405 lists only the +selected record's methods (sorted uppercase, with implicit HEAD). Missing URLs +continue to the host. `methods` defaults to GET in the pure table. + +The Element build-utils route normalizer moves to App/router. Generated SPA +manifests now export ordered `routeRecords` (id/path/methods/load), replacing the +path-keyed `routeManifest`. File identity is its normalized relative source path; +file routes sort static, parameter, catch-all, then code-point path/file order. +`definePage().route.path` is removed: filenames own Framework URLs. SPA loaders +and actions receive `searchParams` separately; params no longer project query +values onto a component. + +`@openelement/adapter-vite/element` exports `element()` for standalone authoring. +Use that plugin with Vite's normal library build, and register the exported class +in a separate ordinary JS entry. Compiler/runtime communicate through existing +versioned Part Programs; the semantic compiler remains private and is delivered +inside the tooling package. Router is an optional peer of adapter-vite; Framework +applications must install App themselves. Standalone authors install Element, +adapter-vite and Vite; bundled plain-HTML consumers need only the resulting JS. +The packed consumer proof checks registration, event updates, source maps and +browser JS/declaration graphs; this is not full Beta.2.2 qualification. + +Navigation API is used where present; history fallback remains for hosts without +it and file:// hash mode retains its demonstrated existing use. Matching is the +same RouteTable in every driver. Chromium 147, Firefox 148 and WebKit 26.4 are the +local test environments, not a newly promised minimum browser version. + +| Subpath | Classification | Named exports | +| ----------------- | ------------------- | --------------------------------------------------------------------------------- | +| App `router` | experimental | `RouteTable`, `RouteRecord`, `RouteMatch`, `RouteResolution`, `RouteTableOptions` | +| App `router` | internal-importable | `normalizeRoutePatternForURLPattern` | +| App `router/http` | experimental | `createRouteMiddleware`, `HttpRouteRecord` | +| Adapter `element` | experimental | `element` | diff --git a/docs/current/SEMANTIC_OWNERSHIP.md b/docs/current/SEMANTIC_OWNERSHIP.md index 80e95de91..4f2267c58 100644 --- a/docs/current/SEMANTIC_OWNERSHIP.md +++ b/docs/current/SEMANTIC_OWNERSHIP.md @@ -16,39 +16,40 @@ evolves specific rows of this table through its issue tree (#1209-#1220). The table remains the Alpha.9 baseline until it is re-baselined at Alpha.10 closure. -| Concept | Canonical owner and representation | Executors, alternatives, and accepted decision | Parity evidence and known divergence | Forbidden duplicate ownership; issue | -| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | -| Component identity | Bundler-neutral compiler semantic core; decorator-derived tag/class records in Part Program metadata | Vite discovers modules and registers the compiled class; Element consumes identity | Compiler boundary, deterministic fixture, CEM records; closed | No Vite regex identity grammar or runtime tag inference; #1208 | -| OpenElement language semantics | Compiler semantic core; admitted decorator, property, computed, TSX, Part and Region grammar | Vite hook is integration only; unsupported syntax fails closed | `compiler-semantic-core-boundary.test.ts`, `compiler-fail-closed-matrix.test.ts`, `compiled-element-v1.test.ts`; closed | No bundler-owned parser or runtime JSX fallback; #1208, #1204 | -| Part Program v1 | Compiler `semantic-core/program.ts`; exact versioned serialized wire artifact | Element has an independent wire validator and all executors consume the validated artifact | Bidirectional validator corpus and deterministic fixture; competing runtime-only grammar removed | No Element-only v1 instruction or compatibility grammar; #1208 | -| RuntimeProgramIR | Element `runtime-program.ts`; normalized, recursively frozen representation created only after exact wire validation | Fresh DOM, claim, reactive runtime and server serializers | `part-program-validation-adversarial.test.ts`, compiled runtime/server suites; closed | No direct cast or widening before validation; #1208 | -| Part, Region, anchor, dependency and location identity | Part Program v1 ownership/location tables | Element indexes those records but does not rediscover them | Adversarial duplicate, missing, misplaced and malformed-record matrix; closed | No executor-generated identity or alternate condition representation; #1208 | -| Signals and reactive invalidation | Element signal engine; dependency records map signals to owning Parts/Regions | Fresh and claimed DOM executors subscribe through the same runtime context | Part/Region update, keyed collection and disposal suites; closed | No VNode walk or adapter-level reactive graph; #1208 | -| Context discovery and transport | Community Context Protocol `context-request` composed/bubbling event | Element Signal bridge provides/consumes values; Lit provider/consumer is the conforming alternative | `signal-context.test.ts` including Lit interop, reconnect and disposal; private ancestry walk removed | No parentNode/root-host discovery or global context registry; #1205 | -| DOM context propagation and lifecycle | Platform event propagation plus Element connection-scoped subscriptions | Shadow/light DOM and reconnect are ordinary protocol cases | Compiled context lifecycle matrix; closed | No transport tied to one renderer or permanent disconnected subscription; #1205 | -| SSR initial DOM, fresh DOM, claim and activation | Element compiled server/runtime/claim semantics over one program | Server string serializer, fresh builder and existing-DOM claimant are alternative executors | Part Program conformance, compiled server/fresh/claim/update suites; closed | No second renderer, generic hydration or binding discovery; #1203 | -| Nested component composition, slots and DSD | Element compiled serializer; structured nested records and admitted tags | Vite supplies registry modules and immutable admission list only | `compiled-composition.test.ts`, `nested-static-ssr.test.ts`; duplicate Vite renderer removed; accepted divergence: components embedded in opaque Trusted HTML content stay SSR-inert and upgrade client-side (#1203) | No adapter reparsing or independent slot/DSD semantics; #1203 | -| Trusted HTML | Caller/compiler creates `TrustedHtml` identity capability; Element sink validates identity | SSR, fresh DOM, claim and public light-child projection consume the same capability | Trusted sink matrix and forged-projection rejection; raw-string projection leak fixed | No string branding, Vite sanitizer, or HTML-based component discovery; #1203 | -| Document, head and body serialization | Element `wrapInDocument`; structured options to deterministic document HTML | Generated Hono and SSG entries call it | `html-escape.test.ts`, entry generation and request-time suites; duplicate entry wrapper removed | No generated document renderer; #1203 | -| Compiled serializer text-node escaping | Element `internal/compiled/escape-text.ts`; reduced contract (`&`, `<`, `>` only — quotes pass through in text content) | Runtime seed serializer and server serializer both call the shared helper; the two private copies converged | `compiled-escape-parity.test.ts` byte-level text corpus (static text nodes and text Parts across both serializers); closed. The entity-preserving `escapeText` in `sanitize.ts` is a deliberately different contract, not this surface | No per-serializer private copy or quote-escaping text contract; #1272 | -| CSS and visual theme | CSS cascade, Custom Properties, authored styles and scoped compiled light CSS | Shadow/light style installation is an Element projection; Context may carry theme identity only | StyleSheet, compiled style and website browser suites; closed | No Context recreation of color, spacing, typography, radii or visual-token inheritance; #1201 | -| Route pathname grammar | Native `URLPattern`, or admitted `urlpattern-polyfill` with the same corpus | App `RouteTable` compiles route declarations once | Native/polyfill adversarial `client-router.test.ts`; legacy regex/trie matcher removed | No SPA-only pathname parser; #1204 | -| Route matching, order, params, query, base and trailing policy | App `RouteTable`; ordered route records and URLPattern results | SPA, generated Hono/request-time, SSG and Nitro project the same route records | Route parity corpus plus request-time browser matrix; closed | No transport-specific precedence or unsafe-param map; #1204, #1206 | -| Route file-convention grammar and declaration ordering | Adapter `route-scanner.ts`; `parseRouteFilePath` (`[id]`→`:id`, `[...path]`→`:path{.+}`, index stripping, `api/` classification) and the static-first sort (special files last, then static-before-dynamic, then lexicographic) | App `RouteTable` honors the declaration order through index-based precedence; SSG, request-time codegen and the SPA route manifest consume the same scanned records | `route-scanner-*.test.ts`, `route-manifest.test.ts`, `request-time-parity.test.ts` (dev-vs-build semantic parity); closed | No second file-convention grammar or transport-specific declaration ordering; #1270 | -| Renderer scope matching | Adapter `entry-route-helpers.ts` `rendererScopeMatches`; case-sensitive exact-or-`scope/` boundary-prefix predicate | Generated `__matchingRenderers` re-expresses the predicate inside self-contained generated entries, which cannot import adapter internals (derived projection) | `renderer-scope-parity.test.ts` binding corpus evaluates the generated matcher verbatim against the predicate (exact, prefix, nested, non-match, boundary separators, case); closed | No second runtime scope grammar or per-transport scope policy; #1271 | -| Request and Response semantics | Web platform `Request`, `Response`, `Headers`, `FormData`, `URL` | Hono executes requests; Nitro adapts deployment transports | HTTP bridge, malformed-body, headers, abort/cancel and Nitro proof suites; closed | No framework-private HTTP value model; #1206 | -| Loader | App authoring contract and outcome classification | SPA and generated Hono handlers project the result | App authoring/SPA/request-time parity; closed | No adapter-specific loader result grammar; #1206 | -| Action | App `classifyActionResult`; `ActionOutcome` discriminated representation | SPA and generated Hono action runtime consume the classifier | App authoring/SPA/entry-renderer/request-time suites; prior `fail()` shape divergence fixed | No Hono-only `.data` interpretation or raw `Response` action result; #1206 | -| Redirect, NotFound, expected failure and Problem Details | App authoring control/outcome semantics | SPA navigation and Hono response projection are alternative executors | App outcome matrix, action protocol and request-time parity; closed | No transport-owned outcome classification; #1206 | -| Hono execution | Hono request/middleware execution model | Generated entry binds App-owned route/outcome semantics to Hono | Request-time fixture and browser suites; closed | Hono does not own App classification; #1206 | -| Nitro deployment | Nitro adapters and generated deployment artifacts | Node server and Cloudflare module are environment alternatives | `nitro:proof:node`, `nitro:proof:workers`; closed | Nitro does not redefine routes, outcomes or documents; #1206 | -| Compiler-known interaction facts | Compiler semantic analysis and emitted event records | Client admission aggregation reads facts directly | Module-analysis, scanner and compiler determinism suites; closed | No Vite AST rediscovery of compiler-owned behavior; #1202, #1207 | -| Explicit Island and imperative behavior policy | Author declaration validated by adapter protocol | Client admission aggregation and delivery strategy projection | Island scanner/delivery and exact client-output tests; closed | No assumption that imperative browser behavior is compiler-knowable; #1202 | -| Third-party delivery capability | Package manifest and CEM classification | Adapter aggregates package islands and foreign-tag admission | CEM compatibility, foreign-tag and package-island tests; closed | No guessing third-party behavior from OpenElement compiler data; #1202 | -| Client reachability, activation and zero-JS | Adapter `client-admission.ts`; deterministic union of route reachability, compiler facts, explicit policy and third-party declarations | Vite chunking/client entry generation implements the plan | Admission, generator, exact-output, static-only zero-runtime and three-browser suites; false zero-JS for interactive static components fixed | No hidden 0.43 island fallback or independent compiler-event scan; #1202 | -| Vite build, HMR, resolution and source-map composition | Vite integration layer; module graph, cache and generated source maps | Bundler-neutral compiler returns code, diagnostics and source records | HMR/delivery, direct-core, source-map and build suites; closed | Compiler core must not import Vite or invent a generic bundler abstraction; #1207 | -| Source maps and diagnostics | Compiler semantic core owns source spans/records; Vite composes standard module maps | Browser/tooling consumes generated maps | Frozen source-map fixture, located fail-closed diagnostics and HMR tests; closed | No adapter recreation of semantic locations; #1207 | -| Public metadata and package interface | Package root exports, package manifests and checked interface snapshot | Docs, packed packages, Starter and third-party consumers | Interface snapshot, packed dry run and packed-consumer qualification; pending only the final exact-head rerun | No unpublished deep-import contract or stale provisional name; #1201 | +| Concept | Canonical owner and representation | Executors, alternatives, and accepted decision | Parity evidence and known divergence | Forbidden duplicate ownership; issue | +| ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| Component identity | Bundler-neutral compiler semantic core; decorator-derived tag/class records in Part Program metadata | Vite discovers modules and registers the compiled class; Element consumes identity | Compiler boundary, deterministic fixture, CEM records; closed | No Vite regex identity grammar or runtime tag inference; #1208 | +| OpenElement language semantics | Compiler semantic core; admitted decorator, property, computed, TSX, Part and Region grammar | Vite hook is integration only; unsupported syntax fails closed | `compiler-semantic-core-boundary.test.ts`, `compiler-fail-closed-matrix.test.ts`, `compiled-element-v1.test.ts`; closed | No bundler-owned parser or runtime JSX fallback; #1208, #1204 | +| Part Program v1 | Compiler `semantic-core/program.ts`; exact versioned serialized wire artifact | Element has an independent wire validator and all executors consume the validated artifact | Bidirectional validator corpus and deterministic fixture; competing runtime-only grammar removed | No Element-only v1 instruction or compatibility grammar; #1208 | +| RuntimeProgramIR | Element `runtime-program.ts`; normalized, recursively frozen representation created only after exact wire validation | Fresh DOM, claim, reactive runtime and server serializers | `part-program-validation-adversarial.test.ts`, compiled runtime/server suites; closed | No direct cast or widening before validation; #1208 | +| Part, Region, anchor, dependency and location identity | Part Program v1 ownership/location tables | Element indexes those records but does not rediscover them | Adversarial duplicate, missing, misplaced and malformed-record matrix; closed | No executor-generated identity or alternate condition representation; #1208 | +| Signals and reactive invalidation | Element signal engine; dependency records map signals to owning Parts/Regions | Fresh and claimed DOM executors subscribe through the same runtime context | Part/Region update, keyed collection and disposal suites; closed | No VNode walk or adapter-level reactive graph; #1208 | +| Context discovery and transport | Community Context Protocol `context-request` composed/bubbling event | Element Signal bridge provides/consumes values; Lit provider/consumer is the conforming alternative | `signal-context.test.ts` including Lit interop, reconnect and disposal; private ancestry walk removed | No parentNode/root-host discovery or global context registry; #1205 | +| DOM context propagation and lifecycle | Platform event propagation plus Element connection-scoped subscriptions | Shadow/light DOM and reconnect are ordinary protocol cases | Compiled context lifecycle matrix; closed | No transport tied to one renderer or permanent disconnected subscription; #1205 | +| SSR initial DOM, fresh DOM, claim and activation | Element compiled server/runtime/claim semantics over one program | Server string serializer, fresh builder and existing-DOM claimant are alternative executors | Part Program conformance, compiled server/fresh/claim/update suites; closed | No second renderer, generic hydration or binding discovery; #1203 | +| Nested component composition, slots and DSD | Element compiled serializer; structured nested records and admitted tags | Vite supplies registry modules and immutable admission list only | `compiled-composition.test.ts`, `nested-static-ssr.test.ts`; duplicate Vite renderer removed; accepted divergence: components embedded in opaque Trusted HTML content stay SSR-inert and upgrade client-side (#1203) | No adapter reparsing or independent slot/DSD semantics; #1203 | +| Trusted HTML | Caller/compiler creates `TrustedHtml` identity capability; Element sink validates identity | SSR, fresh DOM, claim and public light-child projection consume the same capability | Trusted sink matrix and forged-projection rejection; raw-string projection leak fixed | No string branding, Vite sanitizer, or HTML-based component discovery; #1203 | +| Document, head and body serialization | Element `wrapInDocument`; structured options to deterministic document HTML | Generated Hono and SSG entries call it | `html-escape.test.ts`, entry generation and request-time suites; duplicate entry wrapper removed | No generated document renderer; #1203 | +| Compiled serializer text-node escaping | Element `internal/compiled/escape-text.ts`; reduced contract (`&`, `<`, `>` only — quotes pass through in text content) | Runtime seed serializer and server serializer both call the shared helper; the two private copies converged | `compiled-escape-parity.test.ts` byte-level text corpus (static text nodes and text Parts across both serializers); closed. The entity-preserving `escapeText` in `sanitize.ts` is a deliberately different contract, not this surface | No per-serializer private copy or quote-escaping text contract; #1272 | +| CSS and visual theme | CSS cascade, Custom Properties, authored styles and scoped compiled light CSS | Shadow/light style installation is an Element projection; Context may carry theme identity only | StyleSheet, compiled style and website browser suites; closed | No Context recreation of color, spacing, typography, radii or visual-token inheritance; #1201 | +| Route pathname grammar | Native `URLPattern`, or admitted `urlpattern-polyfill` with the same corpus | App `RouteTable` compiles route declarations once | Native/polyfill adversarial `client-router.test.ts`; legacy regex/trie matcher removed | No SPA-only pathname parser; #1204 | +| Route identity, URL winner before method, separate captures/query, base and trailing policy | App `RouteTable`; ordered records resolved through the owned URLPatternList (ADR-0152) | SPA, generated Hono/request-time, SSG and Nitro project the same route records | Route resolution and differential corpus; server/browser convergence tracked in #1325 | No transport-specific precedence or unsafe-param map; #1204, #1206 | +| Ordered pattern collection/index | OE URLPatternList, derived from pinned url-pattern-list 0.5.0 | Literal pathname prefix tree plus sequence-merged conservative candidates; URLPattern exec owns results | url-pattern-list.test.ts native/polyfill differential corpus; #1324 | No second production winner scan or exposed parser/tree | +| Route file-convention grammar and declaration ordering | Adapter `route-scanner.ts`; `parseRouteFilePath` (`[id]`→`:id`, `[...path]`→`:path{.+}`, index stripping, `api/` classification) and the deterministic sort (special files last; static, parameters, catch-all; code-point path/file ties) | App `RouteTable` honors the declaration order through index-based precedence; SSG, request-time codegen and the SPA route manifest consume the same scanned records | `route-scanner-*.test.ts`, `route-manifest.test.ts`, `request-time-parity.test.ts` (dev-vs-build semantic parity); closed | No second file-convention grammar or transport-specific declaration ordering; #1270 | +| Renderer scope matching | Adapter `entry-route-helpers.ts` `rendererScopeMatches`; case-sensitive exact-or-`scope/` boundary-prefix predicate | Generated `__matchingRenderers` re-expresses the predicate inside self-contained generated entries, which cannot import adapter internals (derived projection) | `renderer-scope-parity.test.ts` binding corpus evaluates the generated matcher verbatim against the predicate (exact, prefix, nested, non-match, boundary separators, case); closed | No second runtime scope grammar or per-transport scope policy; #1271 | +| Request and Response semantics | Web platform `Request`, `Response`, `Headers`, `FormData`, `URL` | Hono executes requests; Nitro adapts deployment transports | HTTP bridge, malformed-body, headers, abort/cancel and Nitro proof suites; closed | No framework-private HTTP value model; #1206 | +| Loader | App authoring contract and outcome classification | SPA and generated Hono handlers project the result | App authoring/SPA/request-time parity; closed | No adapter-specific loader result grammar; #1206 | +| Action | App `classifyActionResult`; `ActionOutcome` discriminated representation | SPA and generated Hono action runtime consume the classifier | App authoring/SPA/entry-renderer/request-time suites; prior `fail()` shape divergence fixed | No Hono-only `.data` interpretation or raw `Response` action result; #1206 | +| Redirect, NotFound, expected failure and Problem Details | App authoring control/outcome semantics | SPA navigation and Hono response projection are alternative executors | App outcome matrix, action protocol and request-time parity; closed | No transport-owned outcome classification; #1206 | +| Hono execution | Hono request/middleware execution model | Generated entry binds App-owned route/outcome semantics to Hono | Request-time fixture and browser suites; closed | Hono does not own App classification; #1206 | +| Nitro deployment | Nitro adapters and generated deployment artifacts | Node server and Cloudflare module are environment alternatives | `nitro:proof:node`, `nitro:proof:workers`; closed | Nitro does not redefine routes, outcomes or documents; #1206 | +| Compiler-known interaction facts | Compiler semantic analysis and emitted event records | Client admission aggregation reads facts directly | Module-analysis, scanner and compiler determinism suites; closed | No Vite AST rediscovery of compiler-owned behavior; #1202, #1207 | +| Explicit Island and imperative behavior policy | Author declaration validated by adapter protocol | Client admission aggregation and delivery strategy projection | Island scanner/delivery and exact client-output tests; closed | No assumption that imperative browser behavior is compiler-knowable; #1202 | +| Third-party delivery capability | Package manifest and CEM classification | Adapter aggregates package islands and foreign-tag admission | CEM compatibility, foreign-tag and package-island tests; closed | No guessing third-party behavior from OpenElement compiler data; #1202 | +| Client reachability, activation and zero-JS | Adapter `client-admission.ts`; deterministic union of route reachability, compiler facts, explicit policy and third-party declarations | Vite chunking/client entry generation implements the plan | Admission, generator, exact-output, static-only zero-runtime and three-browser suites; false zero-JS for interactive static components fixed | No hidden 0.43 island fallback or independent compiler-event scan; #1202 | +| Vite build, HMR, resolution and source-map composition | Vite integration layer; module graph, cache and generated source maps | Bundler-neutral compiler returns code, diagnostics and source records | HMR/delivery, direct-core, source-map and build suites; closed | Compiler core must not import Vite or invent a generic bundler abstraction; #1207 | +| Source maps and diagnostics | Compiler semantic core owns source spans/records; Vite composes standard module maps | Browser/tooling consumes generated maps | Frozen source-map fixture, located fail-closed diagnostics and HMR tests; closed | No adapter recreation of semantic locations; #1207 | +| Public metadata and package interface | Package root exports, package manifests and checked interface snapshot | Docs, packed packages, Starter and third-party consumers | Interface snapshot, packed dry run and packed-consumer qualification; pending only the final exact-head rerun | No unpublished deep-import contract or stale provisional name; #1201 | ## Boundary rules diff --git a/docs/current/VERSION_PLAN.md b/docs/current/VERSION_PLAN.md index 0650851ed..45a786cf4 100644 --- a/docs/current/VERSION_PLAN.md +++ b/docs/current/VERSION_PLAN.md @@ -1,150 +1,252 @@ -# v0.44 version plan +# Active version plan: Beta.2.x convergence to 1.0 Alpha OpenElement = Web Components-native fullstack application framework. -The published stable line remains the 0.43 series on npm `latest`. -`v0.44.0-beta.2` is the current public v0.44 prerelease (Beta.2 — -Productization + Governance Offload) under dist-tag `beta`, succeeding the -published Beta.1. The Beta.2 stage continues with three patch checkpoints per -ADR-0151 (as amended 2026-09-05): `v0.44.0-beta.2.1` (Router convergence) -> -`v0.44.0-beta.2.2` (Document convergence) -> `v0.44.0-beta.2.3` (repository / -governance closure), before Beta.3 (`v0.44.0-beta.3`, real workload / formal -benchmark / hardening). - -ADR-0147 defines the Alpha workspace train, which is complete through Alpha.9. -ADR-0151 retopologizes the release train and supersedes the ADR-0149 five-Beta -mapping; the remainder of ADR-0149 and ADR-0150 that is not about Beta topology -is unaffected. ADR-0146 remains the release-role authority and activates at -Beta.1. - -- Repository package line: `v0.44.0-beta.2` -- npm registry line: `v0.44.0-beta.2` (prerelease, dist-tag `beta`; npm `latest` remains the stable 0.43 line) -- Current source package line: `v0.44.0-beta.2` -- Current npm registry line: `v0.44.0-beta.2` -- Latest landed train: `v0.44.0-beta.2` -- Active internal target: none — internal Alpha checkpoints closed at Alpha.10 (verifier PASS, #1150); the active line is the public Beta train (ADR-0151) -- Active release target: `v0.44.0-beta.2` -- Next planned public train: `v0.44.0-beta.3` -- Next public prerelease: `v0.44.0-beta.3` - -The coherent five-package distribution contract follows -[PACKAGE_SURFACE.md](./PACKAGE_SURFACE.md) and ADR-0114. The supported server -integration remains `nitro-mount`. - -- Browser matrix: Chromium, Firefox and WebKit. - -## Alpha is an internal workspace train - -`alpha.0` is the common foundation. `alpha.1` through `alpha.10` are internal work -identifiers, not npm versions and not release candidates. No Alpha work package -creates a tag, npm publication, GitHub Release, dist-tag change, `main` promotion, -fresh release-verifier run, or unanimous three-role release GO. - -The three-role release loop is disabled throughout Alpha. It begins at Beta.1, after -the Alpha integration workspace has produced one coherent framework SHA. - -## Start condition - -Before cloning implementation workspaces: - -1. #1193 minimum `dev`/`main` history safety is active. -2. `v0.44.0-ALPHA-CONTRACT.md` freezes the minimum semantic seams already accepted - by ADR-0143 and proved by #1160. -3. `tools/config/v044-alpha-workspaces.json` records disjoint write ownership. -4. #1193 closure evidence records the exact fast-forwarded common base SHA. - -This is a short shared freeze, not a governance wave. - -## Internal Alpha workspaces - -Alpha.1 through Alpha.9 are complete. Alpha.10 is the active internal -checkpoint. - -| Internal ID | Workspace | Issues | Agent ownership | -| ----------- | --------------------------- | -------------------------------------------- | ---------------------------------------------------------------------- | -| alpha.1 | Compiler / Part Program | #1161 #1162 #1163 compiler slice | code, tests, fixtures and branch end-to-end | -| alpha.2 | Element Runtime / Signals | #1164 #1165 #1166 #723 #1167 | code, tests, fixtures and branch end-to-end | -| alpha.3 | SSR / DOM Claim | #1168 #1169 #1170 | code, tests, fixtures and branch end-to-end | -| alpha.4 | App / Islands / Delivery | #1088 #1171 #1172 #1173 #1163 delivery slice | code, tests, fixtures and branch end-to-end | -| alpha.5 | Replacement / Migration | #1174 | legacy-absence inventory, migration and removal changes | -| alpha.6 | Interoperability | #1175 | external-component corpus and fixes | -| alpha.7 | Performance / Qualification | #1176 | budgets, benchmarks, browser/runtime and packed-consumer qualification | -| alpha.8 | Final Integration | #1181 | absorb all accepted workspace SHAs and produce one coherent candidate | -| alpha.9 | Semantic Convergence | Alpha.9 umbrella and workstreams | prove one semantic owner before Beta.1 admission | -| alpha.10 | Truth Closure | #1209-#1220 under umbrella #1155 | close remaining truth drift; hard-block Beta.1 admission | - -The first seven workspaces begin from that exact base and run concurrently. A workspace -may use contract fixtures or mocks for another workspace, but may not introduce a -fallback architecture, private cross-package import, workspace alias, compatibility -shim, or unilateral shared-contract change. - -Each workspace has one agent that owns implementation, focused RED/GREEN tests, -targeted gates, its commits and its branch. There is no thinker/implementer handoff -inside Alpha and no release verifier. Cross-workspace questions go to the frozen -contract; genuine contract changes are recorded once and broadcast to every affected -workspace. - -## Alpha integration and semantic convergence - -The alpha.8 integration workspace is the only Alpha workspace that aggregates other -branches. Its agent: - -1. consumes the exact accepted source head SHA from alpha.1 through alpha.7; -2. integrates their reviewed commit series in dependency order through linear - cherry-picks and records the source-to-integrated SHA mapping; -3. resolves interface and integration failures in the integration workspace; -4. proves compiler -> Part Program -> SSR/fresh DOM/existing-DOM claim -> signals -> - islands -> Vite -> Nitro/server -> real application; -5. opens the single integration pull request to `dev`; and -6. accepts only the exact-SHA PR full CI result. - -Alpha.9 continues in the same integration worktree, branch and PR after the explicit -Alpha.8 checkpoint. It closes the ADR-0150 semantic workstreams, records current -ownership, and produces the one final exact-head candidate. Workspace branches run -targeted gates. They do not each run the full repository matrix. Failed integration -or convergence creates another PR #1199 candidate SHA, not a release-verifier session. - -Alpha.8 and Alpha.9 are complete: the integrated candidate landed on `dev` and the -ADR-0150 semantic workstreams closed with their recorded ownership evidence in -[SEMANTIC_OWNERSHIP.md](./SEMANTIC_OWNERSHIP.md). - -## Alpha.10 truth closure - -Alpha.10, the current internal checkpoint (ADR-0151), closes the remaining -release-truth drift left after Alpha.9 semantic convergence and is a hard blocker -for Beta.1 admission: Beta.1 may not begin until every Alpha.10 issue carries -exact closure evidence. Alpha.10 is governed by umbrella issue #1155 and the -"v0.44 Alpha.10 — internal (unpublished truth closure)" milestone; its work -issues are #1209 through #1220. It creates no tag, npm publication, GitHub -Release, dist-tag change or `main` promotion, and no Alpha.10 artifact is -published. - -## Beta, RC and the Stable/1.0 decision - -ADR-0151 defines the public train. Each phase answers one question: - -| Phase | Question | Responsibility | -| ---------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------ | -| Beta.1 | Is the framework itself trustworthy? | Framework qualification and governance freeze; first public v0.44 prerelease; three-role loop on | -| Beta.2 | Can external users really use and maintain it? | Productization and governance offload | -| Beta.3 | Can real workloads break the architecture? | Final hardening, formal benchmark and real SaaS qualification | -| RC1 | Did we misjudge the candidate? | Frozen candidate and soak | -| Stable/1.0 | — | Decided on Beta.3 evidence only; never pre-declared | - -The RC1 version string is `v1.0.0-rc.1` when the Beta.3 v1-admission assessment -passes, otherwise `v0.44.0-rc.1`. An unproven surface is never relabeled as 1.0. - -At Beta.1 and later public boundaries, the configured thinker, implementer and fresh -release verifier rules apply. PR CI remains the sole authoritative full matrix for an -exact SHA. `latest` remains on stable 0.43.x until an explicitly approved 0.44 Stable -release. - -Beta.1 branch convergence is fail closed: every remote head is classified, every open -PR is resolved or explicitly carried forward, and only an explicit reviewed deletion -list may run. Unknown-owned branches and local user worktrees are never bulk deleted. - -RC1 is the immutable frozen candidate. Any code, dependency, lockfile or artifact-byte -change after candidate freeze creates a new candidate and repeats qualification. RC -promotion does not rebuild the artifacts and requires explicit human GO. - -`internal alpha.1-alpha.9 workspaces (complete) -> internal alpha.10 truth closure (complete) -> beta.1 framework qualification + governance freeze (published) -> beta.2 productization + governance offload (published) -> beta.2.1 router convergence -> beta.2.2 document convergence -> beta.2.3 repository/governance closure (cumulative Beta.2 verifier) -> beta.3 final hardening + formal benchmark + real SaaS qualification -> RC1 frozen candidate / soak -> Stable/1.0 decision on Beta.3 evidence` +Current source package line: `v0.44.0-beta.2` +Current npm registry line: `v0.44.0-beta.2` (prerelease, dist-tag `beta`; npm `latest` remains the stable 0.43 line) +Latest landed train: `v0.44.0-beta.2` +Active release target: `v0.44.0-beta.2.1` +Active internal target: none — internal Alpha checkpoints closed at Alpha.10 (verifier PASS, #1150); the active line is Beta.2.x convergence before public 1.0 Alpha (ADR-0152) +Next planned public train: `v0.44.0-beta.2.1` + +The existing five-package distribution follows [PACKAGE_SURFACE.md](./PACKAGE_SURFACE.md) +and ADR-0114; the shipped `nitro-mount` integration remains until its replacement +is qualified. These are baseline facts, not restrictions on ADR-0152's target. +Browser qualification covers Chromium, Firefox and WebKit. + +Authority: [ADR-0152](../adr/ADR-0152-product-router-and-alpha-convergence.md). +Live train: [#1155](https://github.com/open-element/openelement/issues/1155). +Execution: [Project 3](https://github.com/orgs/open-element/projects/3). +Version anchors above remain synchronized projections required by existing tooling; +#1334 owns their generated-state consolidation. Actual version/publication state is in [release-state.json](../release/release-state.json) +and immutable release records; this plan describes intended work, not release proof. + +## Objective and scope + +Maintainer-approved refinement, 2026-09-08: Element and Router remain the two core +products; UI is dogfood/reference. Router offers explicit Route Mode and file-based +Framework Mode. Native and Lit are first-class Framework Mode targets, qualified +through separate rendering integrations and one application protocol. This is a +plan, not a claim that Lit Framework Mode already ships. + +OpenElement directly maintains a public fork of Justin's URLPatternList. Generic +fixes are contributed upstream without blocking OE delivery. Returning to the +upstream package is optional, not a required exit. Product count does not mandate +package count. Existing implementation is reused only where its ownership fits. + +## Tasks and checkpoints + +Checkpoints are acceptance boundaries, replacing the former three-working-day +schedule. No elapsed deadline completes work, and no automatic Beta.2.4 exists. + +| Checkpoint | Scope | Owners | +| --------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | +| Beta.2 baseline | Preserve published evidence and identify inherited failures | #1155 / #1288 | +| Beta.2.1 | Router correctness, independently maintained fork, SSG/navigation repair, standalone Element and prerelease tooling | #1320 / #1323 / #1324 / #1325 / #1338 | +| Beta.2.2 | Native/Lit application flow, minimal shared protocol, Document and public metadata | #1321 / #1326 / #1327 / #1339 | +| Beta.2.3 | Dual-mode hardening, independent consumers, supported matrix, cleanup and Alpha admission | #1322 / #1328-#1334 / #1340 | + +### Beta.2.1: routing and delivery foundation + +- Fork `justinfagnani/url-pattern-list` into `open-element/url-pattern-list`, retaining + history, MIT attribution and a compact divergence record. Verify the fork/package + exists before claiming migration; package naming is an implementation decision. +- Independently test/build/release the fork; OE consumes an exact qualified package + version with a lockfile. Delete the embedded production duplicate after migration. +- Preserve ordered first-match semantics. Explicit records keep registration order; + file generation orders static, parameter and catch-all routes deterministically. +- Choose one URL record before method dispatch. Group methods on that record; + unsupported methods return 405/Allow, HEAD fallback stays within the winner. + Keep query/captures, base path, slash and full URL component policies explicit. +- SSG discovers eligible pages from canonical route metadata and renders through + the real dispatcher, without recreating a Hono page matcher. +- Generic native navigation leaves POST forms to browser/server unless an explicit + action protocol owns them. History/native fragment navigation and reload default + to browser ownership; hash-router semantics remain distinct. Apply ownership + checks before cancellation side effects. Each mount gets a fresh execution signal. +- Finish standalone Element tooling using the existing private compiler, packed + plain-HTML consumers, source maps and separated browser/build/declaration graphs. +- Complete lossless multi-identifier prerelease comparison and release-tool consumers. + Legacy oversized Number projection is P3 hardening, not a fabricated blocker. + +### Beta.2.2: Native and Lit Framework Mode + +Prove the same list/detail/form/validation/success/error application with Native and +Lit: file routes -> loader -> SSR -> client continuation -> navigation -> action -> +data refresh, including redirects, cancellation, back/forward and remount. + +Extract shared protocol from both implementations rather than freezing a speculative +FrameworkAdapter API. Router owns route identity, data/action outcomes, errors, +redirects and cancellation; server Request context and browser context need not be +identical. Native owns compiled rendering/claim; Lit owns its SSR/hydration/update +integration. Neither integration introduces a second router or data scheduler. + +Resolve Document metadata before serialization. Native reuses its existing serializer; +Lit uses its qualified rendering integration. Public sitemap/search consume eligible +route/catalog identity and metadata, never personalized request Documents. Remove +superseded post-build SEO mutation with equivalent metadata/output evidence (#1327). +Explicitly select the rendering integration; CEM classification is not automatic +SSR-provider detection. DSD alone is not hydration compatibility. + +Use allowed fixtures or independent consumers; examples/** remains frozen (#1311). +Lit starts with an explicitly qualified reference server runtime; publish a truthful +runtime/feature matrix before first-class availability claims. Existing Native +runtime commitments are not silently reduced. + +### Beta.2.3: product and release qualification + +Qualify packed consumers for standalone Element, Router Route Mode without Element +runtime, Router Lit Framework Mode without Native compiler/runtime, and Native +Framework Mode. Split physical packages only if entry/graph boundaries require it. + +Prove SSR/SSG, client continuation, JS-disabled pages/forms, HTTP semantics, resource +cleanup, navigation, fresh install, declarations, source maps and browser dependency +graphs. Measure build time, bundle size and matching memory/performance. Complete +support matrices, migration instructions and runnable documentation. No third +rendering framework is added in this checkpoint. + +Cleanup accompanies every replacement; #1328-#1334, #1156, #1188/#1189 and #1192 +retain their distinct obligations. Do not silently defer an existing acceptance +criterion or close an umbrella because one slice passed. + +## Public 1.0 Alpha: validate the product contract + +After #1340 admission, Alpha uses an independent real application (#1179) to test +whether product boundaries and APIs survive actual use. Alpha may revise architecture +and public interfaces with migration guidance; it is not an early RC freeze. Use one +substantial application in one mode and equivalent critical journeys in the other; +two complete SaaS implementations are not required. + +#1234 identifies immutable artifacts/hashes/dependencies for each qualification round. +Fixes create new rounds and an evidence-impact assessment; invalidate affected results +and rerun required checks. Never combine different artifacts into a fictitious passing +candidate. Final RC admission evidence must cover the actual admission candidate. + +- #1240 qualifies independent install/start/develop/build/deploy/upgrade and diagnostics + for Element standalone, Router Route Mode, Native and Lit Framework Mode, building on + Beta fixtures without substituting them for independent-repository evidence. +- #1241 owns mode-by-capability-by-runtime support; Native evidence does not qualify Lit. +- #1239/#1242 cover shared execution errors/cancellation and each renderer's long-running + cleanup; retain leak bounds for DOM, listeners, effects, subscriptions and caches. +- #1235 is Element JFB evidence only; #1236 separates Router/application overhead from + Native and Lit rendering/SSR/build costs. #1237 optimizes measured bottlenecks only. +- #1238 rechecks build/static-serving audit findings against current artifacts and + preserves containment, concurrent-build integrity and predictable failure behavior. +- #1243 requires a fork maintenance exercise: generic regression -> fix -> fork package + release -> OE dependency upgrade -> regression qualification. Upstream response is + not a gate. #1187 binds release evidence to exact OE and dependency artifacts. + +## RC and Stable: freeze and qualify the admitted contract + +Dependency chain: #1340 -> #1179 and required #1234-#1242/#1187 -> #1243 RC admission +-> #1178 artifact freeze -> #1244 soak / #1245 install-security / #1246 triage +-> #1180 human GO -> #37 Stable closure. Alpha application validation never waits for RC. + +#1178 freezes Element APIs/generated-artifact compatibility, Router/application protocol, +Native/Lit entry points, supported capability/runtime matrix, package hashes and the +qualified matching-fork/Lit dependency set. Independent dependency releases do not force +lockstep OE publication. Updating a candidate dependency creates a new artifact and +invalidates affected qualification. Architecture/public-contract changes return to Alpha. + +Preserve at least fourteen days of RC soak with no unresolved P0/P1 regression, using +#1179's application on frozen RC artifacts and equivalent journeys in the other mode. +Record candidate changes and their effect on soak; no silent reuse of invalid evidence. +#1245 retains fresh install, promised stable/Beta/Alpha migrations, stable-to-compiled +rehearsal, exports/types/maps, production/static output, integrity/security/provenance. +#1246 classifies every RC issue; minor cleanup stays post-1.0, supported-mode blockers +cannot be waived because the other renderer passes. + +#1180/#37 require human GO and consistent npm/tag/Release/provenance before closure. +The intended Stable target is 1.0.0 and the actually admitted package set, not legacy +0.44.0/five-package wording. Historical 0.41 exceptions and a seven-day post-release +watch do not substitute for current RC qualification. RC/Stable remain unscheduled. + +## Non-goals + +A third Framework Mode, automatic framework detection/plugin marketplace, another +public compiler API, full URLPattern grammar/parser, native/WASM acceleration, +complex incremental route mutation, UI expansion/replacement, generic RPC/ORM/cache/ +queue infrastructure, mandatory package renaming or universal runtime SSR promises. + +## Acceptance + +- One production route authority across explicit/file records, browser, HTTP and SSG. +- Fork differential tests agree with the same-constructor ordered linear oracle; + unsafe pruning uses conservative candidates merged in original sequence. +- Construction/hit/miss/memory baselines cover actual route distributions and the old + implementation, not only linear scans. Record regressions as well as improvements. +- POST fallback, fragment/reload policy, dispose/remount and stale-result adversarial + tests pass alongside full website and packed-consumer output checks. +- Native and Lit prove the shared flow before first-class support is advertised. +- Standalone product claims match installed artifacts and transitive runtime/types. +- All applicable final-SHA CI, fresh verifier and standing release GO requirements pass. + +## Test matrix + +Use focused tests for changes; exact-candidate PR CI is the full-matrix authority. +Chromium, Firefox and WebKit cover navigation and each admitted rendering integration. +Native retains Cloudflare-first, agreed Node/Deno qualification and Bun/Nitro smoke +scope. Lit runtime support is earned separately; unsupported/unverified cells remain +explicit. Real website, public-route IA, static output, JS-disabled forms and packed +consumers supplement unit tests. Missing evidence is not success. + +## Release evidence requirements + +`beta.2.1 -> beta.2.2 -> beta.2.3 -> 1.0.0-alpha.1 -> Alpha iterations -> RC -> Stable`. +Development may consume accepted contracts before intermediate npm publication. +Publication retains exact-SHA, provenance, protected promotion and release GO gates. +No automatic Beta.2.4; a failed checkpoint remains open. Public 1.0 Alpha uses `alpha`; +historic v0.44 alpha.0-alpha.10 remain unpublished workspace history. `latest` stays +stable until separately admitted Stable publication; RC/Stable remain unscheduled. + +The [PR #1343 review failure](https://github.com/open-element/openelement/pull/1343#issuecomment-5579272257) +reports `APIError: Insufficient Balance`. A successful wrapper job is not completed +review evidence. #1332 owns restoring effective review and fail-closed error reporting; +this must be resolved before merging #1343, not postponed until Beta.2.3. Do not +silently change model/provider or treat an unavailable review as GO. Final HEAD needs +actual review, full CI and fresh verifier evidence. Confirming #1342 ancestry does +not mean that baseline CI passed. Planning edits do not repair #1343's product defects. + +## Infrastructure reduction proposal + +The [source audit](../architecture/infrastructure-reduction.md) identifies deletion +candidates under #1156/#1333/#1332/#1334. WTR is the selected browser-conformance direction, qualified through a bounded +pilot, with Playwright E2E and Deno pure/runtime tests retained. Lit +upstream alignment helps browser reproductions; URLPatternList keeps its upstream +Node test conventions. This proposal does not claim a runner/backend migration has +passed or require Oxc/TS7 before current correctness fixes. + +## Beta.2.x upstream-contribution priority (maintainer-approved) + +Tool selection prioritizes a credible, repeatable path to useful upstream contributions, +not minimum local configuration, maximum speed or maximum deletion count. Additional +local integration effort is acceptable when it produces recipient-native reproductions +and maintainable generic patches. Correctness, supported behavior, provenance and release +requirements remain hard constraints. No PR-count target or upstream-response gate. + +| Checkpoint | Upstream-oriented work | Ownership and acceptance | +| ---------- | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Beta.2.1 | Preserve source/history/test conventions in the matching fork; inventory generic infrastructure already adopted | #1324 keeps Node-native upstream tests and independent releases; #1156 separates generic tooling from OE rules. Current #1343 repairs/review remain first. | +| Beta.2.2 | WTR component conformance and Native/Lit integration reproductions | #1333 pilots WTR with compiled ESM and all three browsers; #1339 separates generic Lit/WTR failures from Router integration defects. Oxc/TS7 feasibility is scoped under #1156, with no production backend migration implied. | +| Beta.2.3 | Consolidate browser fixtures/runner wrappers and retire replaced generic infrastructure | #1333/#1332 preserve coverage and fail-closed CI; #1156/#1330/#1331 retain thin OE rules only; #1327/#1334 remove duplicate writable truth before retiring checks. | + +WTR is selected for alignment with Lit/Modern Web. Vitest is not an equal-default +alternative chosen merely for less glue; reconsider only if a concrete correctness, +distribution or sustainable-integration blocker is documented and the plan is revised. +Retain Playwright full-document E2E and Deno pure/runtime tests. Do not force WTR onto +URLPatternList's Node tests or Oxc's own harness. Reuse official compilation; no new +OE compiler API or permanent browser-runner abstraction. + +Each migration identifies the recipient project, upstream source/version, generic +reproduction in its native harness, intended patch boundary and OE-only remainder. +Actual generic defects should be prepared for contribution under the repository's +contribution/authorization process. Do not invent defects or send cosmetic churn to +satisfy a gate. When no upstream defect exists, record why local cleanup is sufficient. +Upstream acceptance/timing never blocks OE release; sustained useful contributions, +not merged-PR totals, are the objective. + +Oxc/TS7 work during Beta is feasibility and boundary validation: decorators, diagnostics, +source maps, Deno resolution, declaration delivery and official build integration. +Full backend replacement remains separately evidence-gated in Alpha; it must not +expand #1343 or delay an existing correctness repair. No tool/fork is claimed installed +or qualified by this planning change. diff --git a/docs/current/v0.44.0-AUTONOMOUS-GOAL.md b/docs/current/v0.44.0-AUTONOMOUS-GOAL.md index c1d3375d6..9e360b658 100644 --- a/docs/current/v0.44.0-AUTONOMOUS-GOAL.md +++ b/docs/current/v0.44.0-AUTONOMOUS-GOAL.md @@ -1,5 +1,7 @@ # Goal: complete v0.44 Alpha integration and semantic convergence +> Future release scheduling is superseded by [ADR-0152](../adr/ADR-0152-product-router-and-alpha-convergence.md). Historical execution and release evidence below are retained; they do not schedule public 1.0 Alpha. + Finish the v0.44 framework architecture by integrating the parallel Alpha workspaces at Alpha.8, then completing internal Alpha.9 semantic convergence in the same worktree, branch and PR. Do not activate the three-role release loop during Alpha. diff --git a/docs/current/v0.44.0-EXECUTION-PLAN.md b/docs/current/v0.44.0-EXECUTION-PLAN.md index b19686ca0..3dd9937ab 100644 --- a/docs/current/v0.44.0-EXECUTION-PLAN.md +++ b/docs/current/v0.44.0-EXECUTION-PLAN.md @@ -1,84 +1,9 @@ -# v0.44 internal Alpha integration and semantic-convergence execution plan +# Execution plan -The Alpha train uses parallel development in independent workspaces, then Alpha.8 -integration and Alpha.9 semantic convergence in one final integration workspace and -PR. It does not use the three-role release loop. +The internal v0.44 Alpha workspace train is historical and complete. Its exact +execution record remains in Git and the linked issues. It must not schedule the +current public release train. -## Foundation - -| Internal phase | Work | Exit | -| -------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------- | -| `alpha.0` | #1160 accepted -> #1182 accepted -> PR #1190 accepted -> #1193 minimum safety -> contract/path freeze | exact common workspace base; no publication | - -ADR-0149 replaces the old post-Alpha numbering. #1156 and #1187 move to Beta.2, -#1150 to Beta.3, #1157 #1158 #1159 #1177 to Beta.4, and #1188 #1189 remain under -#1192 for Beta.5 final hardening and independent SaaS qualification. - -## Workspace topology - -| Internal ID | Independent workspace | Issues | Owned result | Targeted gate | -| ----------- | --------------------------- | ----------------------------- | ----------------------------------------------- | --------------------------------------------- | -| alpha.1 | Compiler / Part Program | #1161 #1162 #1163 | deterministic program, codegen and diagnostics | compiler tests, transform fixtures, typecheck | -| alpha.2 | Runtime / Signals | #1164 #1165 #1166 #723 #1167 | Parts, Regions, subscriptions and cleanup | runtime, signal and DOM-update tests | -| alpha.3 | SSR / Claim | #1168 #1169 #1170 | server HTML, markers, claim and continuity | SSR fixtures, claim parity and negative cases | -| alpha.4 | App / Delivery | #1088 #1171 #1172 #1173 #1163 | islands, Vite, Nitro/server and consumer path | build/server/island/consumer smoke | -| alpha.5 | Replacement / Migration | #1174 | legacy absence and migration | absence searches and migration fixtures | -| alpha.6 | Interoperability | #1175 | external-component compatibility | interop corpus | -| alpha.7 | Performance / Qualification | #1176 | budgets, browsers/runtimes and packed consumers | benchmarks and qualification subsets | -| alpha.8 | Final Integration | #1181 | exact accepted SHAs combined into one framework | cross-workspace harness and PR CI | -| alpha.9 | Semantic Convergence | Alpha.9 umbrella/workstreams | one owner for admission-critical semantics | conformance, parity and final exact-head CI | - -## Agent ownership - -One agent owns each alpha.1-alpha.7 workspace end-to-end: implementation, tests, -focused RED/GREEN evidence, commits and branch. Agents do not hand code back through a -thinker/implementer loop and do not invoke a release verifier. - -All seven begin from the exact #1193 closure SHA and execute concurrently. -`v0.44.0-ALPHA-CONTRACT.md` freezes their semantic seams and -`tools/config/v044-alpha-workspaces.json` freezes write ownership. Each workspace -publishes its exact accepted source head SHA and targeted gate results to its issue. - -The Alpha integration agent is the only aggregator. It verifies accepted source -SHAs, linearly cherry-picks reviewed commit series, records source-to-integrated SHA -mappings, resolves integration conflicts, runs bounded cross-workspace tests, and -produces the Alpha.8 checkpoint, completes ADR-0150 Alpha.9 work in the same PR, and -produces new exact-head candidates until green. - -## CI evidence tier - -1. **Workspace tier:** each agent runs owned RED/GREEN tests, type/package checks and - the fast push tier. -2. **Integration tier:** alpha.8 runs create/serialize/claim/update and real - app/Vite/Nitro/consumer harnesses. -3. **Authoritative tier:** PR #1199 runs the only full matrix - for its exact SHA. Missing, stale or mismatched evidence fails closed. - -No internal Alpha ID causes a tag, npm publication, GitHub Release, dist-tag change, -`main` promotion, three-role GO or fresh release-verifier run. - -## Alpha.8 integration order - -```text -alpha.1 compiler ----┐ -alpha.2 runtime -----┼--> alpha.8 integration workspace --> exact-SHA PR CI --> dev -alpha.3 SSR/claim ---┤ -alpha.4 delivery ----┤ -alpha.5 migration ---┤ -alpha.6 interop -----┤ -alpha.7 qualification┘ -``` - -## After Alpha - -- Beta.1: independently qualify and publish the integrated framework, activate the - three-role release loop, preserve exact-SHA evidence and converge remote branches. -- Beta.2: #1156 #1187 release/governance foundations. -- Beta.3: #1150 integrated UI qualification. -- Beta.4: #1157 #1158 #1159 #1177 website/content/API/Starter qualification. -- Beta.5: #1192 #1188 #1189 final hardening and independent SaaS qualification of - immutable candidate artifacts. -- RC: #1178 admits the identical Beta.5 SHA and bytes only after explicit human GO. - -GitHub issues, branches, pull requests and check runs hold operational history. Raw -agent transcripts and per-attempt packets are not committed. +The active execution contract is [VERSION_PLAN.md](./VERSION_PLAN.md), accepted +by [ADR-0152](../adr/ADR-0152-product-router-and-alpha-convergence.md). The live +work map is [v0.44.0-ISSUES.md](../roadmap/v0.44.0-ISSUES.md). diff --git a/docs/governance/GOVERNANCE_CONSTITUTION.md b/docs/governance/GOVERNANCE_CONSTITUTION.md index 804ff3cb1..befe46492 100644 --- a/docs/governance/GOVERNANCE_CONSTITUTION.md +++ b/docs/governance/GOVERNANCE_CONSTITUTION.md @@ -13,7 +13,7 @@ product code; where it and an accepted ADR disagree, the ADR wins until amended. Authority chain, highest first: 1. accepted current ADRs (ADR-0146 three-role control plane, ADR-0148 compiler - boundary, ADR-0151 release-train topology, and their predecessors); + boundary, ADR-0152 release-train topology, and their predecessors); 2. this constitution; 3. `docs/current/VERSION_PLAN.md` and the governance SOPs (`PROJECT_WORKFLOW.md`, `RELEASE_POLICY.md`, `V044_ISSUE_SOP.md`, @@ -22,12 +22,10 @@ Authority chain, highest first: ## §1 Purpose and scope -The v0.44 train exists to prove one question per phase (ADR-0151): Beta.1 asks -whether the framework itself is trustworthy. Trust requires that every semantic -surface has exactly one owner, that every duplicate-looking implementation can -defend itself, and that every contribution carries evidence a fresh reviewer -can rerun. This constitution freezes those rules at Beta.1 so that the Beta.1 -hostile audit (#1222) and every later phase audit against a stable text. +The active train is governed by ADR-0152: Beta.2.1 converges Router/core, +Beta.2.2 converges Framework/Document, and Beta.2.3 closes remaining cleanup and +Alpha admission. Cleanup starts with each replacement. Public 1.0 Alpha owns +extended real-application qualification; RC/Stable remain evidence-gated. This document governs process and semantics. It authorizes no tag, release, package publication, dist-tag change or `main` promotion; release authority @@ -196,7 +194,7 @@ the primary enforcers, and audit issues such as #1222 apply this text to the exact candidate SHA. A phase that closes with an unresolved §4.3 failure or a §5.4 violation has not closed. -§6.3. Beta.1 freezes this text. Beta.2 and Beta.3 may amend it only through +§6.3. Beta.1 freezes this text. Beta.2.x and public 1.0 Alpha may amend it only through the §6.1 path; the full #1188 consolidation (ADR classification, contributor -information architecture, path migration) remains Beta.3 scope and does not +information architecture, path migration) is continuous Beta.2.x cleanup scope and does not relax any rule here in the meantime. diff --git a/docs/governance/PROJECT_WORKFLOW.md b/docs/governance/PROJECT_WORKFLOW.md index 98691fd07..88364f067 100644 --- a/docs/governance/PROJECT_WORKFLOW.md +++ b/docs/governance/PROJECT_WORKFLOW.md @@ -1,5 +1,8 @@ # openElement AutoWorkflow +Current source package line `v0.44.0-beta.2`; +npm registry line `v0.44.0-beta.2` (prerelease, dist-tag `beta`). + > Status: Mandatory project workflow. Every human maintainer and AI assistant > must read this document before planning, implementing, reviewing, or releasing > work in this repository. @@ -11,28 +14,15 @@ complete because an issue, chat message, or SOP says it is complete. It is complete only when the repository contains the decision, the execution package, the implementation, and the gates that prove the claim. -Current execution anchor: - -- source package line `v0.44.0-beta.2`; -- npm registry line `v0.44.0-beta.2` (prerelease, dist-tag `beta`; npm `latest` - remains the stable 0.43 line); -- active target `v0.44.0-beta.1`; -- current internal checkpoint: none — the internal Alpha checkpoint train closed - at Alpha.10 (Truth Closure; verifier PASS, #1150); -- next public prerelease: `v0.44.0-beta.2`. - -ADR-0143 explicitly reopens the minor train after the 0.43 maintenance freeze. -The compiled OpenElement execution order — internal Alpha workspaces (complete -through Alpha.10) → Beta.1 framework qualification and -governance freeze → Beta.2 productization and governance offload → Beta.3 final -hardening, formal benchmark and real SaaS qualification → RC1 frozen candidate -and soak → Stable/1.0 decision on Beta.3 evidence — lives in -`docs/current/VERSION_PLAN.md` (ADR-0151); 0.43.x remains the stable -maintenance fallback until 0.44 reaches stable. -OpenElement is one Web Components-native, -static-first application framework: Basic Element is an authoring mode, not a -second product. Beta names product-qualification boundaries, not a second -architecture or product line. +Current execution direction is owned by +[VERSION_PLAN.md](../current/VERSION_PLAN.md) and +[ADR-0152](../adr/ADR-0152-product-router-and-alpha-convergence.md): Beta.2.x +convergence -> public 1.0 Alpha -> evidence-gated RC/Stable. Actual version and +publication facts remain in `docs/release/release-state.json`. + +The core products are Element and Router. UI is dogfood and a reference implementation. Supporting packages are not additional +product lines. Cleanup is part of every replacement, beginning in Beta.2.1. +Historic v0.44 Alpha workspace instructions do not govern public 1.0 Alpha. ## Required Reading Order @@ -131,7 +121,7 @@ Use this order for a minor release: 6. run release gates including publish dry-run; 7. push `dev`; 8. wait for all `dev` CI jobs; -9. merge `dev` into `main`; +9. promote the exact accepted SHA to `main` through the protected release workflow; 10. wait for all `main` CI jobs; 11. create and push the release tag; 12. publish the GitHub release note; diff --git a/docs/governance/RELEASE_CONTRACT.md b/docs/governance/RELEASE_CONTRACT.md index 82472d849..dd4048d68 100644 --- a/docs/governance/RELEASE_CONTRACT.md +++ b/docs/governance/RELEASE_CONTRACT.md @@ -3,7 +3,7 @@ > Status: Mandatory POLICY for the v0.44 train, binding from Beta.1 (Framework > Qualification + Governance Freeze) onward. Adopted under ADR-0151 as the > Beta.1 release-contract instrument. Part of #1187 (Beta.1 slice per the -> thinker's 2026-09-03 scope rulings; the Beta.3 hardening remainder stays +> thinker's 2026-09-03 scope rulings; the 1.0 Alpha hardening remainder stays > open). Refs stage #1150, umbrella #1155. This contract specifies the release-binding rules that @@ -103,8 +103,8 @@ floor. Flaky or unavailable infrastructure is BLOCKED, never PASS. enforced procedurally through the closure report on the stage issue: the thinker verifies the three GOs and the §3.2 fields against durable records before preparing closure. The machine-readable closure-evidence gate (the -PR #1191 lineage) is explicitly Beta.3 scope: PR #1191 stays unmerged and is -carried to Beta.3 unchanged per its own deferral condition and the thinker's +PR #1191 lineage) is explicitly 1.0 Alpha scope: PR #1191 stays unmerged and is +carried to 1.0 Alpha unchanged per its own deferral condition and the thinker's 2026-09-03 amendment ruling on #1187. Nothing in this contract merges, rebases or modifies that tooling. @@ -112,8 +112,8 @@ rebases or modifies that tooling. §5.1. `v0.44.0-beta.1` publishes under a prerelease dist-tag (for example `beta`), never under `latest`. The `latest` dist-tag stays on the stable -0.43.x line until a Stable/1.0 decision is earned on Beta.3 evidence -(ADR-0151 §5). +0.43.x line until a Stable/1.0 decision is earned on 1.0 Alpha evidence +(ADR-0152). §5.2. The git tag and the GitHub Release notes for every v0.44 prerelease must state that the release is a prerelease published under a prerelease diff --git a/docs/governance/RELEASE_POLICY.md b/docs/governance/RELEASE_POLICY.md index 7676cb5f6..b931b98a1 100644 --- a/docs/governance/RELEASE_POLICY.md +++ b/docs/governance/RELEASE_POLICY.md @@ -17,13 +17,27 @@ and GitHub Rulesets hold merge authority. Tags, Releases, assets, attestations, provenance are durable published-version proof; temporary Actions artifacts are not the sole future proof. -All Alpha identifiers are internal and unpublished: they receive no tag, npm -publication, GitHub Release, dist-tag or `main` promotion. ADR-0149 makes Beta.1 the -first public v0.44 framework qualification and assigns Beta.2 the final Trusted -Publishing, provenance and release-protection foundation. +Historic v0.44 alpha.0-alpha.10 identifiers were internal/unpublished and retain +that status. Under [ADR-0152](../adr/ADR-0152-product-router-and-alpha-convergence.md), +Beta.2.1/2.2/2.3 convergence is followed by public `1.0.0-alpha.1`, using npm +`alpha`. The `latest` dist-tag remains on the last admitted stable release. +Release automation must implement this distinction before publication (#1323/#1334). -Beta.5 is the immutable RC candidate. Independent SaaS qualification binds to its -exact commit SHA, package bytes, integrity records and provenance. RC may admit only -those identical artifacts after explicit human GO. Any code, dependency, lockfile or -artifact change creates a new Beta.5 candidate and invalidates the previous SaaS -qualification for promotion. +The three-working-day implementation target is not a release waiver. A blocked +candidate stays unpublished with explicit remaining issues. Public Beta checkpoints +and public 1.0 Alpha retain the existing exact-SHA and release GO requirements. + +RC admission follows sufficient 1.0 Alpha evidence (#1243), with no fixed date. +Independent application qualification binds to the candidate's exact SHA, package +bytes, integrity records and provenance. Any candidate-byte change invalidates its +qualification for promotion and requires a new qualified candidate. Stable admission +requires the separate human GO and final gate #37; Alpha is not a stability claim. + +Version parsing and comparison live in `tools/lib/version.ts`. It accepts +SemVer prerelease identifiers in sequence (numeric identifiers compare +numerically); build metadata and v-prefixed input remain unsupported and core +numbers must be safe integers. Formatting retains every identifier. Checkpoint +succession stops at beta.2.3; the separate admitted product-stage successor is +1.0.0-alpha.1. Planning this successor never executes a bump or publication. +Historical 0.44.0-alpha.0 through alpha.10 remain unpublishable; public 1.0 Alpha +still requires all standing exact-SHA release gates. diff --git a/docs/governance/V044_AGENT_LOOP_SOP.md b/docs/governance/V044_AGENT_LOOP_SOP.md index 1f56577ca..624610fce 100644 --- a/docs/governance/V044_AGENT_LOOP_SOP.md +++ b/docs/governance/V044_AGENT_LOOP_SOP.md @@ -68,7 +68,8 @@ The thinker performs these checks before selecting work: 1. Confirm the task model is the configured thinker model and reasoning effort is `low`. If the host cannot prove this, report the configuration requirement and stop. 2. Read the files in the execution plan's required order. -3. Run `deno task v044:orchestration:check`. +3. Run `deno task workflow:check` and the current release/version checks. The + completed Alpha workspace topology is no longer a current workflow gate. 4. Run `deno task v044:executor:check`. 5. Inspect `git status --short`, current branch, recent commits and open milestone issues. 6. Compare the JSON execution state with GitHub issue state and latest evidence. @@ -279,7 +280,7 @@ After an ordinary loop PASS, the thinker records: - immutable check-run or release links when they exist. Do not commit raw execution transcripts, conversation-derived summaries, or a growing -per-attempt evidence tree. Beta.3 may migrate durable historical records only after it +per-attempt evidence tree. Beta.2.x cleanup may migrate durable historical records only after it proves the replacement source is complete and records any required before/after blob identity without copying prohibited identifiers into current documentation. @@ -292,13 +293,12 @@ The bootstrap prompt may explicitly authorize the thinker to create scoped branc local commits, pushes, PRs and issue comments/closure after gates pass. Executor roles never perform those actions. -The active bootstrap authorizes the full prerelease release flow for `beta.1` through -`beta.3` — `dev`→`main` integration, version tag, -npm publication, dist-tag change, -GitHub Release, evidence and issue updates, and execution-cursor advancement — after a -unanimous implementer/release-verifier/thinker GO against the exact candidate SHA with -every deterministic gate green. The internal Alpha workspace train stays strictly -unpublished and outside this SOP: no tag, no publish, no release entry. +ADR-0152's public train includes `beta.2.N` and `1.0.0-alpha.N`. When release +execution is authorized, promotion, tags, npm publication, dist-tag changes and +GitHub Releases require unanimous implementer/release-verifier/thinker GO against +the exact candidate SHA with every required gate green. A planning update does +not itself authorize or perform publication. Historic v0.44 internal Alpha +workspace IDs stay unpublished and outside this public-release SOP. Exact-SHA integration topology: PR CI proves the exact PR head SHA, and that SHA is the candidate. `dev` advances only by fast-forward to the proved PR head (`git merge diff --git a/docs/release/public-interface-snapshot.json b/docs/release/public-interface-snapshot.json index 3fd1d7cc6..d7f2227c4 100644 --- a/docs/release/public-interface-snapshot.json +++ b/docs/release/public-interface-snapshot.json @@ -92,7 +92,7 @@ ] }, "./build-utils": { - "publicShapeSha256": "429b8fc3c1240c2d81d2cd2a414e9365e0f3fbb3fb23b566ecc439ebc448e9e6", + "publicShapeSha256": "ef569a279e1bdcc1c16571a3bba98996f94768489d523d9ca87d000ad6adc856", "publicSymbols": [ "OpenElementRequestHandler=type:{call:(request:Request,context:union(undefined|{env?:union(Env|undefined);params?:union(Record|undefined);platform?:unknown}))=>union(Promise|Response)}", "RuntimeContext=type:{env?:union(Env|undefined);params?:union(Record|undefined);platform?:unknown}", @@ -101,7 +101,6 @@ "createRuntimeAdapter=value:{call: =Record>(options:{fetch:{call:(request:Request,context:union(undefined|{env?:union(Env|undefined);params?:union(Record|undefined);platform?:unknown}))=>union(Promise|Response)};name:string;prerender?:union(undefined|{call:()=>union(AsyncIterable|Iterable)})})=>{fetch:{call:(request:Request,context:union(undefined|{env?:union(Env|undefined);params?:union(Record|undefined);platform?:unknown}))=>union(Promise|Response)};name:string;prerender?:union(undefined|{call:()=>union(AsyncIterable|Iterable)})}}", "formatJson=value:{call:(value:unknown)=>string}", "insertBeforeBodyClose=value:{call:(html:string,content:string)=>string}", - "normalizeRoutePatternForURLPattern=value:{call:(path:string)=>string}", "normalizeSeparators=value:{call:(path:string,sep:union(\"-\"|\"/\"))=>string}", "pathToTagName=value:{call:(filePath:string)=>string}", "transformIslandSource=value:{call:(source:string,options:{filePath:string;islandsDir:string})=>{code:string;islands:array({filePath:string;tagName:string});map?:union(string|undefined)}}" @@ -141,11 +140,13 @@ "./i18n": "./src/i18n.ts", "./model": "./src/model.ts", "./preact": "./src/preact.ts", + "./router": "./src/router.ts", + "./router/http": "./src/router-http.ts", "./spa": "./src/spa.ts" }, "declarations": { ".": { - "publicShapeSha256": "a8e913e30fa95c22a52b1bf7dbf9cb57849eb1ed7109b85a495a9600159a78ce", + "publicShapeSha256": "bf3288657d9fabbc50ee7f2013c137664182132b8dd19da6bbacaba43d720c81", "publicSymbols": [ "ACTION_FETCH_HEADER=value:any", "Action=value:any", @@ -159,11 +160,11 @@ "LoaderContext=value:any", "OpenElementActionFailure=type:{readonly data:Data;readonly name:\"OpenElementActionFailure\";readonly status:number}|value:{construct:(status:number,data:Data)=>{readonly data:Data;readonly name:\"OpenElementActionFailure\";readonly status:number}}", "OpenElementNotFound=type:{readonly status:404}|value:{construct:(message:string)=>{readonly status:404}}", - "OpenElementPageDescriptor=type:{error?:union(undefined|{call:(error:unknown,context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});head?:union(undefined|{dangerouslyHeadFragments?:union(array(string)|undefined);description?:union(string|undefined);meta?:union(array(Record)|undefined);title?:union(string|undefined)});kind:\"page\";props?:union(undefined|{call:(context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});renderIntent:{mode:union(\"dynamic\"|\"static\")};route?:union(undefined|{id?:union(string|undefined);params?:union(array(string)|undefined);path?:union(string|undefined)})}", + "OpenElementPageDescriptor=type:{error?:union(undefined|{call:(error:unknown,context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});head?:union(undefined|{dangerouslyHeadFragments?:union(array(string)|undefined);description?:union(string|undefined);meta?:union(array(Record)|undefined);title?:union(string|undefined)});kind:\"page\";props?:union(undefined|{call:(context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});renderIntent:{mode:union(\"dynamic\"|\"static\")};route?:union(undefined|{id?:union(string|undefined);params?:union(array(string)|undefined)})}", "OpenElementRedirect=type:{readonly location:string;readonly status:number}|value:{construct:(location:union(URL|string),status:number)=>{readonly location:string;readonly status:number}}", "OpenElementRequestContext=type:{env?:union(Env|undefined);method:string;params:Record;path:string;platform?:unknown;request:Request;searchParams:URLSearchParams;url:URL}", "PROBLEM_JSON_MEDIA_TYPE=value:any", - "PageComponentConstructor=type:intersection(CustomElementConstructor&{openElementPage:{error?:union(undefined|{call:(error:unknown,context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});head?:union(undefined|{dangerouslyHeadFragments?:union(array(string)|undefined);description?:union(string|undefined);meta?:union(array(Record)|undefined);title?:union(string|undefined)});kind:\"page\";props?:union(undefined|{call:(context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});renderIntent:{mode:union(\"dynamic\"|\"static\")};route?:union(undefined|{id?:union(string|undefined);params?:union(array(string)|undefined);path?:union(string|undefined)})}})", + "PageComponentConstructor=type:intersection(CustomElementConstructor&{openElementPage:{error?:union(undefined|{call:(error:unknown,context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});head?:union(undefined|{dangerouslyHeadFragments?:union(array(string)|undefined);description?:union(string|undefined);meta?:union(array(Record)|undefined);title?:union(string|undefined)});kind:\"page\";props?:union(undefined|{call:(context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});renderIntent:{mode:union(\"dynamic\"|\"static\")};route?:union(undefined|{id?:union(string|undefined);params?:union(array(string)|undefined)})}})", "PageErrorProjector=type:{call:(error:unknown,context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record}", "PagePropsContext=type:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}}", "PagePropsProjector=type:{call:(context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record}", @@ -172,14 +173,14 @@ "ServerRouteMetadata=value:any", "SpaAction=value:any", "SpaActionContext=value:any", - "SpaAppInstance=type:{dispose:{call:()=>void};mount:{call:(selector:string)=>void};readonly router:union(null|{currentPath:string;currentRoute:union(null|{action?:union(undefined|{call:(ctx:SpaActionContext)=>Promise});guard?:union(undefined|{call:()=>Promise});loader?:union(undefined|{call:(ctx:SpaLoaderContext)=>Promise});path:string;tagName:string});dispose:{call:()=>void};navigate:{call:(path:string)=>Promise};params:Record;replace:{call:(path:string)=>Promise}})}", + "SpaAppInstance=type:{dispose:{call:()=>void};mount:{call:(selector:string)=>void};readonly router:union(null|{currentPath:string;currentRoute:union(null|{action?:union(undefined|{call:(ctx:any)=>Promise});guard?:union(undefined|{call:()=>Promise});id?:union(string|undefined);loader?:union(undefined|{call:(ctx:any)=>Promise});methods?:union(array(string)|undefined);path:string;pattern?:union(URLPatternInit|undefined);tagName:string});dispose:{call:()=>void};navigate:{call:(path:string)=>Promise};params:Record;readonly searchParams:URLSearchParams;replace:{call:(path:string)=>Promise}})}", "SpaLoader=value:any", "SpaLoaderContext=value:any", "classifyActionResult=value:{call:(result:Data)=>union({data:Data;kind:\"success\"}|{data:unknown;kind:\"failure\";status:number})}", "createRequestContext=value:{call: =Record>(options:{env?:union(Env|undefined);params?:union(Record|undefined);platform?:unknown;request:Request})=>{env?:union(Env|undefined);method:string;params:Record;path:string;platform?:unknown;request:Request;searchParams:URLSearchParams;url:URL}}", - "defineApp=value:{call:(options:{mode:\"spa\";routerMode?:union(\"auto\"|\"hash\"|\"history\"|undefined);routes?:union(array({action?:union(undefined|{call:(ctx:SpaActionContext)=>Promise});guard?:union(undefined|{call:()=>Promise});loader?:union(undefined|{call:(ctx:SpaLoaderContext)=>Promise});path:string;tagName:string})|undefined)})=>{dispose:{call:()=>void};mount:{call:(selector:string)=>void};readonly router:union(null|{currentPath:string;currentRoute:union(null|{action?:union(undefined|{call:(ctx:SpaActionContext)=>Promise});guard?:union(undefined|{call:()=>Promise});loader?:union(undefined|{call:(ctx:SpaLoaderContext)=>Promise});path:string;tagName:string});dispose:{call:()=>void};navigate:{call:(path:string)=>Promise};params:Record;replace:{call:(path:string)=>Promise}})}}", + "defineApp=value:{call:(options:{mode:\"spa\";routerMode?:union(\"auto\"|\"hash\"|\"history\"|undefined);routes?:union(array({action?:union(undefined|{call:(ctx:any)=>Promise});guard?:union(undefined|{call:()=>Promise});id?:union(string|undefined);loader?:union(undefined|{call:(ctx:any)=>Promise});methods?:union(array(string)|undefined);path:string;pattern?:union(URLPatternInit|undefined);tagName:string})|undefined)})=>{dispose:{call:()=>void};mount:{call:(selector:string)=>void};readonly router:union(null|{currentPath:string;currentRoute:union(null|{action?:union(undefined|{call:(ctx:any)=>Promise});guard?:union(undefined|{call:()=>Promise});id?:union(string|undefined);loader?:union(undefined|{call:(ctx:any)=>Promise});methods?:union(array(string)|undefined);path:string;pattern?:union(URLPatternInit|undefined);tagName:string});dispose:{call:()=>void};navigate:{call:(path:string)=>Promise};params:Record;readonly searchParams:URLSearchParams;replace:{call:(path:string)=>Promise}})}}", "defineIslandConfig=value:{call:(config:{dsd?:union(false|true|undefined);exportNames?:union(Readonly>|undefined);hydrate?:any;media?:union(string|undefined);ssr?:union(false|true|undefined);tagNames?:union(array(string)|undefined);tags?:union(array(string)|undefined)})=>{dsd?:union(false|true|undefined);exportNames?:union(Readonly>|undefined);hydrate?:any;media?:union(string|undefined);ssr?:union(false|true|undefined);tagNames?:union(array(string)|undefined);tags?:union(array(string)|undefined)}}", - "definePage=value:{call: =Record>(componentClass:CustomElementConstructor,descriptor:union(undefined|{error?:union(undefined|{call:(error:unknown,context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});head?:union(undefined|{dangerouslyHeadFragments?:union(array(string)|undefined);description?:union(string|undefined);meta?:union(array(Record)|undefined);title?:union(string|undefined)});props?:union(undefined|{call:(context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});renderIntent?:union(undefined|{mode?:union(\"dynamic\"|\"static\"|undefined)});route?:union(undefined|{id?:union(string|undefined);params?:union(array(string)|undefined);path?:union(string|undefined)})}))=>intersection(CustomElementConstructor&{openElementPage:{error?:union(undefined|{call:(error:unknown,context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});head?:union(undefined|{dangerouslyHeadFragments?:union(array(string)|undefined);description?:union(string|undefined);meta?:union(array(Record)|undefined);title?:union(string|undefined)});kind:\"page\";props?:union(undefined|{call:(context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});renderIntent:{mode:union(\"dynamic\"|\"static\")};route?:union(undefined|{id?:union(string|undefined);params?:union(array(string)|undefined);path?:union(string|undefined)})}})}", + "definePage=value:{call: =Record>(componentClass:CustomElementConstructor,descriptor:union(undefined|{error?:union(undefined|{call:(error:unknown,context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});head?:union(undefined|{dangerouslyHeadFragments?:union(array(string)|undefined);description?:union(string|undefined);meta?:union(array(Record)|undefined);title?:union(string|undefined)});props?:union(undefined|{call:(context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});renderIntent?:union(undefined|{mode?:union(\"dynamic\"|\"static\"|undefined)});route?:union(undefined|{id?:union(string|undefined);params?:union(array(string)|undefined)})}))=>intersection(CustomElementConstructor&{openElementPage:{error?:union(undefined|{call:(error:unknown,context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});head?:union(undefined|{dangerouslyHeadFragments?:union(array(string)|undefined);description?:union(string|undefined);meta?:union(array(Record)|undefined);title?:union(string|undefined)});kind:\"page\";props?:union(undefined|{call:(context:{actionData:unknown;data:union(Data|undefined);locale?:union(string|undefined);meta:{index:string=>unknown};params:Params;request?:union(Request|undefined);route:{filePath?:union(string|undefined);path?:union(string|undefined)}})=>Record});renderIntent:{mode:union(\"dynamic\"|\"static\")};route?:union(undefined|{id?:union(string|undefined);params?:union(array(string)|undefined)})}})}", "fail=value:{call:(status:number,data:Data)=>{readonly data:Data;readonly name:\"OpenElementActionFailure\";readonly status:number}}", "isActionFailure=value:{call:(error:unknown)=>union(false|true)}", "isOpenElementNotFound=value:{call:(error:unknown)=>union(false|true)}", @@ -214,11 +215,29 @@ "definePreactIsland=value:{call:unknown} ={index:string=>unknown}>(tagName:string,Component:{call:(props:Props)=>union(VNode|bigint|false|null|number|object|string|true|undefined)},options:{props?:union(undefined|{index:string=>unknown});ssr?:union(false|true|undefined)})=>{construct:(...params:array(any))=>HTMLElement;renderSsr:{call:(props:union(undefined|{index:string=>unknown}))=>string}}}" ] }, + "./router": { + "publicShapeSha256": "792f56d62a1ec7c69641be87a0972f73d5a73a369584575213ee2ef688d02dde", + "publicSymbols": [ + "RouteMatch=type:{id:string;params:Record;patternResult:URLPatternResult;route:T;searchParams:URLSearchParams}", + "RouteRecord=type:{id?:union(string|undefined);methods?:union(array(string)|undefined);path:string;pattern?:union(URLPatternInit|undefined)}", + "RouteResolution=type:union(intersection({id:string;params:Record;patternResult:URLPatternResult;route:T;searchParams:URLSearchParams}&{kind:\"match\";method:string})|{allow:array(string);kind:\"method-not-allowed\"}|{kind:\"not-found\"})", + "RouteTable=type:{candidateCount:{call:(input:union(URL|string))=>number};match:{call:(input:union(URL|string),search:string)=>union(null|{id:string;params:Record;patternResult:URLPatternResult;route:T;searchParams:URLSearchParams})};readonly options:{basePath?:union(string|undefined);trailingSlash?:union(\"ignore\"|\"strict\"|undefined)};readonly routes:array(T);resolve:{call:(input:union(URL|string),search:string,method:string)=>union(intersection({id:string;params:Record;patternResult:URLPatternResult;route:T;searchParams:URLSearchParams}&{kind:\"match\";method:string})|{allow:array(string);kind:\"method-not-allowed\"}|{kind:\"not-found\"})}}|value:{construct:(routes:array(T),Pattern:{construct:(init:URLPatternInit)=>URLPattern},options:{basePath?:union(string|undefined);trailingSlash?:union(\"ignore\"|\"strict\"|undefined)})=>{candidateCount:{call:(input:union(URL|string))=>number};match:{call:(input:union(URL|string),search:string)=>union(null|{id:string;params:Record;patternResult:URLPatternResult;route:T;searchParams:URLSearchParams})};readonly options:{basePath?:union(string|undefined);trailingSlash?:union(\"ignore\"|\"strict\"|undefined)};readonly routes:array(T);resolve:{call:(input:union(URL|string),search:string,method:string)=>union(intersection({id:string;params:Record;patternResult:URLPatternResult;route:T;searchParams:URLSearchParams}&{kind:\"match\";method:string})|{allow:array(string);kind:\"method-not-allowed\"}|{kind:\"not-found\"})}}}", + "RouteTableOptions=type:{basePath?:union(string|undefined);trailingSlash?:union(\"ignore\"|\"strict\"|undefined)}", + "normalizeRoutePatternForURLPattern=value:{call:(path:string)=>string}" + ] + }, + "./router/http": { + "publicShapeSha256": "8a8e56188d517d7aa62abdd1028fa6d096383a69869eb2446b87267f1e3f5a47", + "publicSymbols": [ + "HttpRouteRecord=type:{handlers:Readonly>;id?:union(string|undefined);path:string;pattern?:union(URLPatternInit|undefined)}", + "createRouteMiddleware=value:{call:(records:array({handlers:Readonly>;id?:union(string|undefined);path:string;pattern?:union(URLPatternInit|undefined)}),options:intersection({basePath?:union(string|undefined);trailingSlash?:union(\"ignore\"|\"strict\"|undefined)}&{methodNotAllowed?:union(undefined|{call:(context:Context,allow:array(string))=>union(Promise|Response)})}))=>MiddlewareHandler}" + ] + }, "./spa": { - "publicShapeSha256": "f9be0b01b6f69ef3e03c01b6d04e90f58564c0b8495495c79a4dbaba06a64d9a", + "publicShapeSha256": "43a82110011e3fab7b8eaa5dad3694ca05eeec97b8253509e091f947d8369611", "publicSymbols": [ - "SpaAppInstance=type:{dispose:{call:()=>void};mount:{call:(selector:string)=>void};readonly router:union(null|{currentPath:string;currentRoute:union(null|{action?:union(undefined|{call:(ctx:SpaActionContext)=>Promise});guard?:union(undefined|{call:()=>Promise});loader?:union(undefined|{call:(ctx:SpaLoaderContext)=>Promise});path:string;tagName:string});dispose:{call:()=>void};navigate:{call:(path:string)=>Promise};params:Record;replace:{call:(path:string)=>Promise}})}", - "defineApp=value:{call:(options:{mode:\"spa\";routerMode?:union(\"auto\"|\"hash\"|\"history\"|undefined);routes?:union(array({action?:union(undefined|{call:(ctx:SpaActionContext)=>Promise});guard?:union(undefined|{call:()=>Promise});loader?:union(undefined|{call:(ctx:SpaLoaderContext)=>Promise});path:string;tagName:string})|undefined)})=>{dispose:{call:()=>void};mount:{call:(selector:string)=>void};readonly router:union(null|{currentPath:string;currentRoute:union(null|{action?:union(undefined|{call:(ctx:SpaActionContext)=>Promise});guard?:union(undefined|{call:()=>Promise});loader?:union(undefined|{call:(ctx:SpaLoaderContext)=>Promise});path:string;tagName:string});dispose:{call:()=>void};navigate:{call:(path:string)=>Promise};params:Record;replace:{call:(path:string)=>Promise}})}}" + "SpaAppInstance=type:{dispose:{call:()=>void};mount:{call:(selector:string)=>void};readonly router:union(null|{currentPath:string;currentRoute:union(null|{action?:union(undefined|{call:(ctx:any)=>Promise});guard?:union(undefined|{call:()=>Promise});id?:union(string|undefined);loader?:union(undefined|{call:(ctx:any)=>Promise});methods?:union(array(string)|undefined);path:string;pattern?:union(URLPatternInit|undefined);tagName:string});dispose:{call:()=>void};navigate:{call:(path:string)=>Promise};params:Record;readonly searchParams:URLSearchParams;replace:{call:(path:string)=>Promise}})}", + "defineApp=value:{call:(options:{mode:\"spa\";routerMode?:union(\"auto\"|\"hash\"|\"history\"|undefined);routes?:union(array({action?:union(undefined|{call:(ctx:any)=>Promise});guard?:union(undefined|{call:()=>Promise});id?:union(string|undefined);loader?:union(undefined|{call:(ctx:any)=>Promise});methods?:union(array(string)|undefined);path:string;pattern?:union(URLPatternInit|undefined);tagName:string})|undefined)})=>{dispose:{call:()=>void};mount:{call:(selector:string)=>void};readonly router:union(null|{currentPath:string;currentRoute:union(null|{action?:union(undefined|{call:(ctx:any)=>Promise});guard?:union(undefined|{call:()=>Promise});id?:union(string|undefined);loader?:union(undefined|{call:(ctx:any)=>Promise});methods?:union(array(string)|undefined);path:string;pattern?:union(URLPatternInit|undefined);tagName:string});dispose:{call:()=>void};navigate:{call:(path:string)=>Promise};params:Record;readonly searchParams:URLSearchParams;replace:{call:(path:string)=>Promise}})}}" ] } } @@ -229,6 +248,7 @@ ".": "./src/index.ts", "./cli/build": "./src/cli/build.ts", "./cli/start": "./src/cli/start.ts", + "./element": "./src/element.ts", "./nitro-mount": "./src/nitro-mount.ts", "./sitemap": "./src/sitemap.ts" }, @@ -292,6 +312,12 @@ "extractServeMode=value:{call:(argv:array(string))=>{mode:union(\"preview\"|\"start\");rest:array(string)}}" ] }, + "./element": { + "publicShapeSha256": "20f99852cc54efbb297149f6a1b776bec63821b4760ba00381e5691cef3e3d89", + "publicSymbols": [ + "element=value:{call:()=>Plugin}" + ] + }, "./nitro-mount": { "publicShapeSha256": "7b4a0b7d3228db51275a8c198bffb84291345f126243e02a97a1c9ef8f5d4523", "publicSymbols": [ diff --git a/docs/release/release-state.json b/docs/release/release-state.json index 72562266a..905fcb148 100644 --- a/docs/release/release-state.json +++ b/docs/release/release-state.json @@ -3,7 +3,7 @@ "sourceVersion": "0.44.0-beta.2", "publishedVersion": "0.44.0-beta.2", "latestLandedTrain": "v0.44.0-beta.2", - "activeTarget": "v0.44.0-beta.2", - "nextPlannedTrain": "v0.44.0-beta.3", + "activeTarget": "v0.44.0-beta.2.1", + "nextPlannedTrain": "v0.44.0-beta.2.1", "maturity": "beta" } diff --git a/docs/roadmap/ROADMAP.md b/docs/roadmap/ROADMAP.md index 0cfc39b69..34a5a32ce 100644 --- a/docs/roadmap/ROADMAP.md +++ b/docs/roadmap/ROADMAP.md @@ -5,48 +5,66 @@ OpenElement = Web Components-native fullstack application framework. Source package line: `v0.44.0-beta.2`. npm registry line: `v0.44.0-beta.2` (prerelease, dist-tag `beta`). The npm `latest` dist-tag remains on the published stable 0.43 line. -Active execution target: `v0.44.0-beta.2`. +Active execution target: `v0.44.0-beta.2.1`. Latest landed train: `v0.44.0-beta.2`. -Next planned train: `v0.44.0-beta.3`. -Next public prerelease: `v0.44.0-beta.3`. -Long-term stable product target: `1.0.0` (unscheduled). - -Execution follows -[PROJECT_WORKFLOW.md](../governance/PROJECT_WORKFLOW.md). - -## Current: Beta.2 productization + governance offload - -The internal Alpha workspace train is complete through Alpha.10. Alpha.0 supplied -the accepted compiler proof, exact-SHA CI foundation and minimum history safety; -alpha.1 through alpha.7 ran as independent parallel workspaces; Alpha.8 was the -sole aggregation workspace; Alpha.9 closed semantic convergence before Beta.1 -admission; Alpha.10 (Truth Closure, umbrella issue #1155, work issues #1209 -through #1220) closed with verifier PASS at #1150 and admitted Beta.1. - -Alpha identifiers were internal work identifiers, not package releases, and the -three-role loop was off for all Alpha work. - -Beta.1 (`v0.44.0-beta.1`) is published as the first public v0.44 prerelease -under dist-tag `beta`; npm `latest` stays on the stable 0.43 line. Beta.2 -(`v0.44.0-beta.2`) is the active public prerelease line; the next stage is -Beta.3 (`v0.44.0-beta.3`). - -## Release train - -ADR-0151 defines the canonical train and supersedes the ADR-0149 five-Beta -mapping. Each phase answers one question: - -1. Beta.1 — Framework Qualification + Governance Freeze: is the framework itself - trustworthy? First public v0.44 prerelease (`v0.44.0-beta.1`), published - under dist-tag `beta`; the three-role release loop activated here. -2. Beta.2 — Productization + Governance Offload: can external users really use - and maintain it? -3. Beta.3 — Final Hardening + Formal Benchmark + Real SaaS Qualification: can - real workloads break the architecture? -4. RC1 — Frozen Candidate / Soak: did we misjudge the candidate? The RC1 version - string is `v1.0.0-rc.1` if the Beta.3 v1-admission assessment passes, - otherwise `v0.44.0-rc.1`. -5. Stable/1.0 is decided on Beta.3 evidence only and is never pre-declared; an - unproven surface is never relabeled as 1.0. - -The detailed mapping lives in `docs/roadmap/v0.44.0-ISSUES.md`. +Next planned train: `v0.44.0-beta.2.1`. +Stable `1.0.0` remains unscheduled. + +Execution follows [PROJECT_WORKFLOW.md](../governance/PROJECT_WORKFLOW.md). + +OpenElement's core products are **Element / Router**; UI is dogfood and a reference implementation. See the +[product model](../architecture/product-model.md) and accepted +[ADR-0152](../adr/ADR-0152-product-router-and-alpha-convergence.md). + +## Active direction + +The published Beta.2 baseline is followed by three convergence checkpoints: + +1. **Beta.2.1:** independently maintained URLPatternList fork, one Router resolution, + SSG/navigation repairs, standalone Element delivery and prerelease tooling. +2. **Beta.2.2:** first-class Native/Lit Framework Mode target: shared application + flow, renderer-specific SSR/client continuation, Document and public metadata. +3. **Beta.2.3:** dual-mode hardening, independent packed consumers, runtime matrices, + documentation, remaining cleanup and Alpha admission. +4. **Public 1.0 Alpha:** real application feedback may revise APIs/architecture; + qualify independent consumers, Native/Lit matrices, performance and fork maintenance + against identified artifact rounds. +5. **1.0 RC / Stable:** freeze admitted contracts/dependencies, reuse the real product + for at least fourteen days of RC soak, verify install/upgrade/security and obtain + human GO; separately evidence-gated and unscheduled. + +Acceptance checkpoints replace the former three-day schedule. Lit is planned support, +not an already-qualified feature. No automatic Beta.2.4 or deadline waiver. +The [active plan](../current/VERSION_PLAN.md) owns scope and acceptance; +[the issue map](./v0.44.0-ISSUES.md) links execution work. Every replacement includes +removing displaced code, callers, obsolete checks and duplicated current facts. + +## Tracking and state + +- [Train root #1155](https://github.com/open-element/openelement/issues/1155) +- [Approved plan #1341](https://github.com/open-element/openelement/issues/1341) +- [Execution Project](https://github.com/orgs/open-element/projects/3) +- [Release state](../release/release-state.json): actual versions and planned target +- [Release policy](../governance/RELEASE_POLICY.md): publication evidence + +The old Beta.3 lane is superseded; its open qualification work moves to 1.0 Alpha. +Historical internal v0.44 Alpha workspaces remain unpublished historical work IDs. +Upcoming 1.0 Alpha is public and uses npm `alpha`; `latest` remains the admitted +stable line. Changing this roadmap does not publish a version or complete a task. + +Alpha product validation (#1179) precedes RC admission (#1243) and freeze (#1178). +RC soak (#1244) reuses that application; it does not postpone first product use until +RC. The [active plan](../current/VERSION_PLAN.md) defines artifact-round and migration +requirements. Historical 0.44 RC and 0.41 Stable exceptions are not current gates. + +## Beta.2.x contribution strategy + +Prefer infrastructure that enables sustained upstream contributions, even when local +setup is not minimal. Beta.2.1 establishes the source-traceable matching fork and generic +tooling inventory; Beta.2.2 uses WTR for Native/Lit browser conformance and bounded +Oxc/TS7 feasibility; Beta.2.3 removes replaced mocks, fixtures and generic wrappers +with behavior preserved. Full compiler-backend migration remains Alpha work. + +Follow each upstream's harness, keep OE-specific semantics local, and prepare real +reproductions/small patches. No PR-count target or upstream-merge release dependency. +See the [deletion and contribution map](../architecture/infrastructure-reduction.md). diff --git a/docs/roadmap/v0.44.0-ISSUES.md b/docs/roadmap/v0.44.0-ISSUES.md index 21ffa9855..d892a1453 100644 --- a/docs/roadmap/v0.44.0-ISSUES.md +++ b/docs/roadmap/v0.44.0-ISSUES.md @@ -1,49 +1,52 @@ -# v0.44 issue graph - -GitHub issue [#1155](https://github.com/open-element/openelement/issues/1155) is the -live train root. - -## Internal Alpha workspace train - -```text -alpha.0 foundation: #1160 #1182 PR #1190 #1193 + contract/path freeze - | - exact common base SHA - | -alpha.1 Compiler #1161 #1162 #1163(part) ----\ -alpha.2 Runtime #1164 #1165 #1166 #723 #1167 --\ -alpha.3 SSR/Claim #1168 #1169 #1170 -------------> alpha.8 integration #1181 -alpha.4 App #1088 #1171 #1172 #1173 #1163 --/ | -alpha.5 Migration #1174 --------------------------/ | -alpha.6 Interop #1175 -------------------------/ exact-SHA PR CI -> dev -alpha.7 Quality #1176 ------------------------/ -``` - -`alpha.0` through `alpha.8` are internal identifiers. None is publishable: no tag, -no npm publication, no GitHub Release, no dist-tag, no `main` promotion and no -external release action. - -Each alpha.1-alpha.7 workspace has one end-to-end agent. Alpha.8 has one integration -agent and is the only aggregator. The three-role loop is disabled for the complete -Alpha train. - -## Product and release ladder - -```text -internal alpha.8 integration - -> Beta.1 framework qualification, publication, three-role activation and branch convergence - -> Beta.2 release/governance foundations (#1156 #1187) - -> Beta.3 UI qualification (#1150) - -> Beta.4 website/content/API/Starter (#1157 #1158 #1159 #1177) - -> Beta.5 final hardening and immutable-artifact SaaS qualification (#1192 #1188 #1189) - -> exact-artifact RC admission with human GO (#1178) - -> Stable -``` - -Workspace agents run targeted validation. The alpha.8 PR owns the exact-SHA full -matrix. GitHub holds operational history; the repository does not duplicate raw -transcripts. - -#1193 owns the immutable common-base evidence. The collaboration contract lives in -`docs/current/v0.44.0-ALPHA-CONTRACT.md`; executable write ownership lives in -`tools/config/v044-alpha-workspaces.json`. +# Beta.2.x to 1.0 Alpha issue map + +[ADR-0152](../adr/ADR-0152-product-router-and-alpha-convergence.md) owns the +accepted direction. GitHub owns live task status; this is a responsibility map, +not a manually duplicated completion ledger. + +- [#1155](https://github.com/open-element/openelement/issues/1155): train root. +- [#1341](https://github.com/open-element/openelement/issues/1341): planning/docs/Project reconciliation. +- [#1288](https://github.com/open-element/openelement/issues/1288): Beta.2 umbrella. +- [Project 3](https://github.com/orgs/open-element/projects/3): execution board. + +| Phase | Parent | Work | +| ------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Beta.2.1 | #1320 | #1338 core products/record model/standalone Element contract; #1324 independent public URLPatternList fork; #1325 shared resolution; #1323 version and channel semantics | +| Beta.2.2 | #1321 | #1339 Native/Lit Framework reference flows; #1326 Document; #1327 public metadata projections | +| Beta.2.3 | #1322 | #1328 lifecycle census; #1329 debris; #1330 tools; #1331 tasks; #1332 CI; #1333 dual-mode packed consumers; #1334 release-state authority; #1340 Alpha admission | +| Continuous cleanup | #1322 | #1156 mature tooling; #1188 governance/docs; #1189 repository weight; #1192 distinct residual closure obligations | +| 1.0 Alpha | #1155 | #1179 real SaaS; #1234-#1242 artifact/benchmark/performance/error/consumer/runtime/lifecycle qualification; #1187 release-hardening remainder; #1311 deferred examples | +| RC admission | #1243 | Assess Alpha evidence and either admit 1.0 RC or continue Alpha | +| RC / Stable | #1178 | #1244 soak; #1245 regression; #1246 triage; #1180 human GO; #37 final Stable gate | + +Dependencies: core contract -> Framework integration -> Alpha admission. The +Cloudflare/Vite spike starts during core work. Cleanup accompanies each replacement; +only its final residual closure waits for Beta.2.3. Intermediate npm publication +is not a prerequisite for implementing the next accepted contract. + +Historic Alpha workspace graphs and obsolete Beta numbering remain in Git/issue +history. Do not reopen completed work or close an unfinished feature because its +milestone was renamed. Acceptance checkpoints replace the three-day target. #1332 also owns the immediate +#1343 review availability/error-reporting blocker; its Beta.2.3 cleanup stage is not +a deferral of that merge prerequisite. #1327 owns docs SEO/search/sitemap projections. + +## Alpha and RC dependencies + +#1340 -> #1179 plus required #1234-#1242/#1187 -> #1243 -> #1178 -> +#1244/#1245/#1246 -> #1180 -> #37. No Alpha application dependency on RC freeze. +#1234 identifies per-round artifacts; #1235 benchmarks Element, #1236 measures +application/Native/Lit costs, #1240 proves independent onboarding, #1241 qualifies +mode/runtime capabilities and #1243 includes fork maintenance evidence. RC retains +fourteen-day soak and explicit human GO. None of these edits complete a task. + +## Beta.2.x upstream-oriented reduction + +- #1324: maintained fork with upstream-native Node tests and contribution-ready generic fixes. +- #1156: generic tooling offload and bounded Beta.2.2 Oxc/TS7 feasibility; no backend replacement in #1343. +- #1333: selected WTR browser-conformance path; first pilot Beta.2.2, consolidation Beta.2.3. +- #1339: recipient-native Lit/WTR reproductions alongside paired application flows. +- #1332/#1330/#1331: remove replaced runner/tool/task wrappers, preserve failing CI semantics. +- #1327/#1334: consolidate writable metadata/release truth before deleting redundant checks. + +Contribution portability is the primary tool-selection goal. No forced upstream PR +for local-only cleanup, no acceptance quota, and no inferred implementation completion. diff --git a/docs/status/STATUS.md b/docs/status/STATUS.md index 650df9584..c8ded8899 100644 --- a/docs/status/STATUS.md +++ b/docs/status/STATUS.md @@ -1,45 +1,31 @@ # OpenElement status -Updated: 2026-09-02 - -- Repository package line: `v0.44.0-beta.2` -- npm registry line: `v0.44.0-beta.2` (prerelease, dist-tag `beta`) -- Latest landed train: `v0.44.0-beta.2` -- Active release target: `v0.44.0-beta.2` -- Next planned train: `v0.44.0-beta.3` -- Next public prerelease: `v0.44.0-beta.3` -- Published stable package line: `v0.43.3` on npm `latest` -- Current development mode: public Beta train — Beta.1 published as a - prerelease under dist-tag `beta`; Beta.2 in flight, next stage Beta.3 - (ADR-0151) -- Minimum branch rules: active on `dev` and `main` through ruleset `21775463` -- Deferred hardening: #1192 Beta.3 with #1156 #1187 #1188 #1189 -- Long-term `1.0.0` target: unscheduled - -Contributor execution follows -[PROJECT_WORKFLOW.md](../governance/PROJECT_WORKFLOW.md). The authoritative active -contract is [VERSION_PLAN.md](../current/VERSION_PLAN.md). - -## Current position - -PR #1194 landed the earlier acceleration baseline at exact SHA -`e3e7b8ae5ddc7faddb8267c36494be73f18701e8` with 9/9 PR checks green. ADR-0147 -supersedes that baseline's Alpha execution topology with the workspace train. - -PR #1195 landed the corrected workspace train at exact SHA -`cdfcb5433e58f9fde68377afc12643b045bfd385`. The exact #1193 closure SHA freezes the -collaboration contract and executable ownership map and is the alpha.1-alpha.7 common -base. - -Alpha.1 through Alpha.10 are complete: the parallel workspaces, the Alpha.8 -integration, the ADR-0150 Alpha.9 semantic convergence and the Alpha.10 Truth -Closure checkpoint (umbrella issue #1155, issue tree #1209 through #1220, -verifier PASS at #1150) all closed with their recorded evidence. Alpha -checkpoints were internal-only: no tag, npm publication, GitHub Release, -dist-tag change, `main` promotion or external release claim. - -Beta.1 (`v0.44.0-beta.1`) is published as the first public v0.44 prerelease -under dist-tag `beta` — the first phase of the -thinker/implementer/fresh-verifier release loop. The npm `latest` -dist-tag stays stable at -`0.43.3` until an explicitly approved 0.44 Stable release. +Repository package line: `v0.44.0-beta.2` +npm registry line: `v0.44.0-beta.2` (prerelease, dist-tag `beta`) +Active release target: `v0.44.0-beta.2.1` +Latest landed train: `v0.44.0-beta.2` +Next planned train: `v0.44.0-beta.2.1` +The npm `latest` dist-tag stays stable at `0.43.3`; Stable `1.0.0` is unscheduled. + +Execution follows [PROJECT_WORKFLOW.md](../governance/PROJECT_WORKFLOW.md). + +Current version and publication facts are owned by +[release-state.json](../release/release-state.json) and the corresponding immutable +release records. Package manifests own source package versions. + +The accepted development direction is Beta.2.1 Router/core, Beta.2.2 +Native/Lit Framework Mode and Beta.2.3 dual-mode hardening/cleanup closure, followed by public 1.0 Alpha. +See [VERSION_PLAN.md](../current/VERSION_PLAN.md), +[ADR-0152](../adr/ADR-0152-product-router-and-alpha-convergence.md) and +[Project 3](https://github.com/orgs/open-element/projects/3) for scope and live work. +This planning update is not an implementation-completion or publication claim. + +Historical v0.44 internal Alpha workspaces are complete; their evidence stays in +issues and release history. Upcoming public 1.0 Alpha is a separate release phase. +Existing exact-SHA CI, provenance, protected promotion and release GO requirements +continue under [RELEASE_POLICY.md](../governance/RELEASE_POLICY.md). + +Public Alpha permits application-driven API/architecture iteration against identified +qualification rounds. RC admission follows that evidence, then freezes exact public +contracts/dependencies and requires at least fourteen days of soak plus upgrade/security +qualification and human GO. See the active plan; no RC date or Stable readiness is claimed. diff --git a/examples/supabase-cloudflare-starter/deno.lock b/examples/supabase-cloudflare-starter/deno.lock index 4b396292d..e45db94fa 100644 --- a/examples/supabase-cloudflare-starter/deno.lock +++ b/examples/supabase-cloudflare-starter/deno.lock @@ -21,6 +21,7 @@ "npm:pdf-parse@^1.1.1": "1.1.4", "npm:pdfjs-dist@4.8.69": "4.8.69", "npm:preact@^10.28.0": "10.29.8_preact-render-to-string@6.7.0", + "npm:typescript@^5.9.0": "5.9.3", "npm:urlpattern-polyfill@10.1.0": "10.1.0", "npm:valibot@^1.1.0": "1.4.2_typescript@5.9.3", "npm:vite@8.0.16": "8.0.16_esbuild@0.25.12_jiti@2.7.0_yaml@2.9.0", diff --git a/packages/adapter-vite/README.md b/packages/adapter-vite/README.md index f24e96f41..4bef8ef85 100644 --- a/packages/adapter-vite/README.md +++ b/packages/adapter-vite/README.md @@ -99,7 +99,7 @@ Phase 2: client island entry and browser chunks - Otherwise the adapter writes a _fallback shell_: a bare `
` plus a `console.info` placeholder script. It is a build marker so the output directory is servable — not a runnable app. Either way, a route - manifest module (`route-manifest.ts`, exporting `routeManifest`) is + manifest module (`route-manifest.ts`, exporting ordered `routeRecords`) is written next to it for client-side routing; import it from your bootstrap as `../dist/route-manifest.ts`. @@ -150,3 +150,16 @@ and only then update config and generated registration. ## License MIT + +### Standalone Element authoring + +Install Element, Vite and this adapter; import `element` from +`@openelement/adapter-vite/element` and put `element()` in Vite's `plugins`. +This entry does not load Router, SSG or deployment tooling. Router is an optional +peer; Framework applications install `@openelement/app` explicitly. + +Keep the decorated component in a `.tsx` module and register its exported class +from a separate JS entry. Use a Vite library build to bundle that entry, then +load the resulting JS from ordinary HTML. The consumer needs the bundled Element +runtime, not the compiler. `deno run -A tools/consumer-packaged-element.ts` in the +repository exercises this path using packed artifacts. diff --git a/packages/adapter-vite/__fixtures__/request-time/e2e/live.spec.ts b/packages/adapter-vite/__fixtures__/request-time/e2e/live.spec.ts index cd8756691..0f263dc88 100644 --- a/packages/adapter-vite/__fixtures__/request-time/e2e/live.spec.ts +++ b/packages/adapter-vite/__fixtures__/request-time/e2e/live.spec.ts @@ -309,7 +309,7 @@ test.describe('protocol hardening (ADR-0121, 0.42.0-alpha.5)', () => { test('non-GET/POST methods are a defined 405 with Allow (#572)', async ({ request }) => { const response = await request.put('/form', { data: 'x=1' }); expect(response.status()).toBe(405); - expect(response.headers()['allow']).toBe('GET, POST'); + expect(response.headers()['allow']).toBe('GET, HEAD, POST'); }); test('POST takes the same error-boundary channel as GET (#551)', async ({ request }) => { diff --git a/packages/adapter-vite/__tests__/entry-descriptor.test.ts b/packages/adapter-vite/__tests__/entry-descriptor.test.ts index f82d7f865..335d065b8 100644 --- a/packages/adapter-vite/__tests__/entry-descriptor.test.ts +++ b/packages/adapter-vite/__tests__/entry-descriptor.test.ts @@ -234,10 +234,10 @@ Deno.test('renderEntry: page routes use SSR helper and wrapInDocument', () => { const desc = buildEntryDescriptor(sampleRoutes); const code = renderEntry(desc); - assertStringIncludes(code, 'app.get("/",'); + assertStringIncludes(code, '__pageHandlers["/"].GET = ['); // v0.5.0: __ssr takes route params as second arg for SSR-time data access assertStringIncludes(code, '__ssr(tag'); - assertStringIncludes(code, 'c.req.param()'); + assertStringIncludes(code, "c.get('routeResolution').params"); // v0.3.4: SSR automatically registers page components for Shadow DOM rendering assertStringIncludes(code, 'customElements.define('); // v0.5.0: DSD renderer uses customElements.get(tag) to find component class @@ -296,8 +296,8 @@ Deno.test('buildEntryDescriptor + renderEntry: end-to-end produces runnable code assertStringIncludes(code, "import { Hono } from 'hono'"); assertStringIncludes(code, 'export default app'); assertStringIncludes(code, 'app.route("/api/hello"'); - assertStringIncludes(code, 'app.get("/",'); - assertStringIncludes(code, 'app.get("/about",'); + assertStringIncludes(code, '__pageHandlers["/"].GET = ['); + assertStringIncludes(code, '__pageHandlers["/about"].GET = ['); // No process.env call in non-comment lines const codeLines = code.split('\n').filter((l) => !l.trimStart().startsWith('//')); assertEquals(codeLines.some((l) => l.includes('process.env')), false); diff --git a/packages/adapter-vite/__tests__/entry-renderer.test.ts b/packages/adapter-vite/__tests__/entry-renderer.test.ts index 5f951d468..5c4b62854 100644 --- a/packages/adapter-vite/__tests__/entry-renderer.test.ts +++ b/packages/adapter-vite/__tests__/entry-renderer.test.ts @@ -916,7 +916,7 @@ Deno.test('renderEntry: ADR-0121 hardening is present in the action codegen', () // #568: action POSTs carry a default body limit. assertStringIncludes(code, '__bodyLimit({ maxSize: 10 * 1024 * 1024'); // #572: non-GET/POST methods on page routes are a defined 405. - assertStringIncludes(code, "c.text('Method Not Allowed', 405, { Allow: 'GET, POST' })"); + assertStringIncludes(code, "app.all('*', __createRouteMiddleware(["); }); Deno.test('renderEntry: action protocol is emitted once for many routes (#1098)', () => { diff --git a/packages/adapter-vite/__tests__/foreign-tag-scanner.test.ts b/packages/adapter-vite/__tests__/foreign-tag-scanner.test.ts index 1c0e7691e..873a790e0 100644 --- a/packages/adapter-vite/__tests__/foreign-tag-scanner.test.ts +++ b/packages/adapter-vite/__tests__/foreign-tag-scanner.test.ts @@ -51,7 +51,6 @@ const PAGE_SOURCE = ` import { definePage } from '@openelement/app'; export default definePage({ - route: { path: '/' }, render() { return (
diff --git a/packages/adapter-vite/__tests__/request-time-admission-parity.test.ts b/packages/adapter-vite/__tests__/request-time-admission-parity.test.ts index 999ca759c..45ccb91fd 100644 --- a/packages/adapter-vite/__tests__/request-time-admission-parity.test.ts +++ b/packages/adapter-vite/__tests__/request-time-admission-parity.test.ts @@ -21,8 +21,9 @@ import { assert, assertEquals, assertStringIncludes } from '@std/assert'; import { join } from '@std/path'; import { Hono } from 'hono'; +import { createRouteMiddleware } from '../../app/src/router-http.ts'; import { RouteTable } from '../../app/src/internal/router/route-table.ts'; -import { normalizeRoutePatternForURLPattern } from '@openelement/element/build-utils'; +import { normalizeRoutePatternForURLPattern } from '@openelement/app/router'; import { renderRequestTimeServerModule } from '../src/internal/ssg/ssg-helpers.ts'; interface CorpusRoute { @@ -167,7 +168,7 @@ const CORPUS: CorpusCase[] = [ pathname: '/item/42', search: '?id=query-loses&extra=query-wins', winner: '/item/:id', - params: { id: '42', extra: 'query-wins' }, + params: { id: '42' }, }, ], }, @@ -236,17 +237,21 @@ function derivedAdmission(routes: CorpusRoute[], pathname: string): boolean { */ function honoEntryFor(routes: CorpusRoute[]): Hono { const app = new Hono(); - for (const route of routes) { - const methods = route.methods ?? ['GET']; - const handler = (c: { req: { param: () => Record } }) => - new Response(JSON.stringify({ path: route.path, params: c.req.param() }), { - headers: { 'content-type': 'application/json' }, - }); - app.on([...methods], route.path, handler); - if (methods.includes('POST')) { - app.all(route.path, () => new Response('Method Not Allowed', { status: 405 })); - } - } + app.all( + '*', + createRouteMiddleware(routes.map((route) => ({ + path: route.path, + handlers: Object.fromEntries( + (route.methods ?? ['GET']).map(( + method, + ) => [ + method, + (c: import('hono').Context) => + c.json({ path: route.path, params: c.get('routeResolution').params }), + ]), + ), + }))), + ); return app; } diff --git a/packages/adapter-vite/__tests__/route-manifest.test.ts b/packages/adapter-vite/__tests__/route-manifest.test.ts index 358e124b1..d29e7f71f 100644 --- a/packages/adapter-vite/__tests__/route-manifest.test.ts +++ b/packages/adapter-vite/__tests__/route-manifest.test.ts @@ -56,7 +56,7 @@ Deno.test({ const manifestPath = join(dir.path, '.openelement/route-manifest.ts'); const content = await generateContent(routesDir, manifestPath); - assertStringIncludes(content, '"/": () => import('); + assertStringIncludes(content, "path: \"/\", methods: ['GET', 'POST'], load: () => import("); assertMatch(content, /\/routes\/index\.tsx/); } finally { dir.cleanup(); @@ -76,7 +76,10 @@ Deno.test({ const manifestPath = join(dir.path, '.openelement/route-manifest.ts'); const content = await generateContent(routesDir, manifestPath); - assertStringIncludes(content, '"/products": () => import('); + assertStringIncludes( + content, + "path: \"/products\", methods: ['GET', 'POST'], load: () => import(", + ); } finally { dir.cleanup(); } @@ -95,7 +98,10 @@ Deno.test({ const manifestPath = join(dir.path, '.openelement/route-manifest.ts'); const content = await generateContent(routesDir, manifestPath); - assertStringIncludes(content, '"/products/:id": () => import('); + assertStringIncludes( + content, + "path: \"/products/:id\", methods: ['GET', 'POST'], load: () => import(", + ); } finally { dir.cleanup(); } @@ -114,7 +120,10 @@ Deno.test({ const manifestPath = join(dir.path, '.openelement/route-manifest.ts'); const content = await generateContent(routesDir, manifestPath); - assertStringIncludes(content, '"/products": () => import('); + assertStringIncludes( + content, + "path: \"/products\", methods: ['GET', 'POST'], load: () => import(", + ); } finally { dir.cleanup(); } @@ -133,7 +142,10 @@ Deno.test({ const manifestPath = join(dir.path, '.openelement/route-manifest.ts'); const content = await generateContent(routesDir, manifestPath); - assertStringIncludes(content, '"/products/reviews": () => import('); + assertStringIncludes( + content, + "path: \"/products/reviews\", methods: ['GET', 'POST'], load: () => import(", + ); } finally { dir.cleanup(); } @@ -154,7 +166,10 @@ Deno.test({ // scanRoutes (#556) converts a catch-all segment to the Hono named // regex parameter :slug{.+} (matches across '/'). - assertStringIncludes(content, '"/products/:slug{.+}": () => import('); + assertStringIncludes( + content, + "path: \"/products/:slug{.+}\", methods: ['GET', 'POST'], load: () => import(", + ); } finally { dir.cleanup(); } @@ -175,7 +190,7 @@ Deno.test({ const manifestPath = join(dir.path, '.openelement/route-manifest.ts'); const content = await generateContent(routesDir, manifestPath); - assertStringIncludes(content, 'export const routeManifest = {} as const;'); + assertStringIncludes(content, 'export const routeRecords = [] as const;'); assertStringIncludes(content, 'No page routes found'); } finally { dir.cleanup(); @@ -194,7 +209,7 @@ Deno.test({ const manifestPath = join(dir.path, '.openelement/route-manifest.ts'); const content = await generateContent(routesDir, manifestPath); - assertStringIncludes(content, '{} as const;'); + assertStringIncludes(content, '[] as const;'); } finally { dir.cleanup(); } @@ -215,7 +230,7 @@ Deno.test({ const manifestPath = join(dir.path, '.openelement/route-manifest.ts'); const content = await generateContent(routesDir, manifestPath); - assertStringIncludes(content, '"/": () => import('); + assertStringIncludes(content, "path: \"/\", methods: ['GET', 'POST'], load: () => import("); assertEquals(content.includes('_renderer'), false); assertEquals(content.includes('_middleware'), false); } finally { @@ -237,7 +252,7 @@ Deno.test({ const manifestPath = join(dir.path, '.openelement/route-manifest.ts'); const content = await generateContent(routesDir, manifestPath); - assertStringIncludes(content, '"/": () => import('); + assertStringIncludes(content, "path: \"/\", methods: ['GET', 'POST'], load: () => import("); assertEquals(content.includes('/api/posts'), false); } finally { dir.cleanup(); @@ -262,7 +277,7 @@ Deno.test({ // Verify structural elements assertStringIncludes(content, 'Auto-generated'); - assertStringIncludes(content, 'export const routeManifest'); + assertStringIncludes(content, 'export const routeRecords'); assertStringIncludes(content, 'as const;'); // Must start with comment/export assertMatch(content, /^\/\//); @@ -312,9 +327,15 @@ Deno.test({ // Verify the file was written const written = await Deno.readTextFile(join(outDir, 'route-manifest.ts')); - assertStringIncludes(written, '"/": () => import('); - assertStringIncludes(written, '"/about": () => import('); - assertStringIncludes(written, '"/products": () => import('); + assertStringIncludes(written, "path: \"/\", methods: ['GET', 'POST'], load: () => import("); + assertStringIncludes( + written, + "path: \"/about\", methods: ['GET', 'POST'], load: () => import(", + ); + assertStringIncludes( + written, + "path: \"/products\", methods: ['GET', 'POST'], load: () => import(", + ); } finally { dir.cleanup(); } @@ -359,10 +380,19 @@ Deno.test({ const manifestPath = join(dir.path, '.openelement/route-manifest.ts'); const content = await generateContent(routesDir, manifestPath); - assertStringIncludes(content, '"/": () => import('); - assertStringIncludes(content, '"/products": () => import('); - assertStringIncludes(content, '"/products/:id": () => import('); - assertStringIncludes(content, '"/about": () => import('); + assertStringIncludes(content, "path: \"/\", methods: ['GET', 'POST'], load: () => import("); + assertStringIncludes( + content, + "path: \"/products\", methods: ['GET', 'POST'], load: () => import(", + ); + assertStringIncludes( + content, + "path: \"/products/:id\", methods: ['GET', 'POST'], load: () => import(", + ); + assertStringIncludes( + content, + "path: \"/about\", methods: ['GET', 'POST'], load: () => import(", + ); } finally { dir.cleanup(); } @@ -383,7 +413,10 @@ Deno.test({ const manifestPath = join(dir.path, '.openelement/route-manifest.ts'); const content = await generateContent(routesDir, manifestPath); - assertStringIncludes(content, '"/about": () => import('); + assertStringIncludes( + content, + "path: \"/about\", methods: ['GET', 'POST'], load: () => import(", + ); } finally { dir.cleanup(); } @@ -406,9 +439,26 @@ Deno.test({ // Bare interpolation used to emit import('../routes/it's.tsx') — a // syntax error. Literals now go through codegen-literals quoting. - assertStringIncludes(content, '"/it\'s": () => import("../routes/it\'s.tsx")'); + assertStringIncludes( + content, + "path: \"/it's\", methods: ['GET', 'POST'], load: () => import(\"../routes/it's.tsx\")", + ); } finally { dir.cleanup(); } }, }); + +Deno.test('file records order parameters before catch-all regardless of parameter names', async () => { + const dir = tempDir(); + try { + await writeRoute(dir.path, 'products/[...a].tsx'); + await writeRoute(dir.path, 'products/[z].tsx'); + await writeRoute(dir.path, 'products/new.tsx'); + const { scanRoutes } = await import('../src/internal/ssg/route-scanner.ts'); + const records = await scanRoutes(dir.path); + assertEquals(records.map((r) => r.path), ['/products/new', '/products/:z', '/products/:a{.+}']); + } finally { + dir.cleanup(); + } +}); diff --git a/packages/adapter-vite/__tests__/ssg-helpers.test.ts b/packages/adapter-vite/__tests__/ssg-helpers.test.ts index d769576d0..33fd3d5e9 100644 --- a/packages/adapter-vite/__tests__/ssg-helpers.test.ts +++ b/packages/adapter-vite/__tests__/ssg-helpers.test.ts @@ -3,7 +3,6 @@ import { renderRequestTimeServerModule, renderStandaloneServerModule, resolveDynamicRoutePath, - routePatternToURLPatternPath, } from '../src/internal/ssg/ssg-helpers.ts'; import { parseRouteFilePath } from '../src/internal/ssg/route-scanner.ts'; import { cacheControlFor, contentTypeFor } from '../src/internal/static-serve.ts'; @@ -43,14 +42,6 @@ Deno.test('resolveDynamicRoutePath rejects traversal segments inside catch-all v assertThrows(() => resolveDynamicRoutePath('/docs/:path{.+}', ['path'], { path: '..' })); }); -Deno.test('routePatternToURLPatternPath covers exact, param and catch-all patterns (#556, #856)', () => { - // Exact and plain param segments are already valid URLPattern pathnames. - assertEquals(routePatternToURLPatternPath('/form'), '/form'); - assertEquals(routePatternToURLPatternPath('/item/:id'), '/item/:id'); - // The Hono-style `:name{regex}` catch-all rewrites to URLPattern `:name(regex)`. - assertEquals(routePatternToURLPatternPath('/docs/:path{.+}'), '/docs/:path(.+)'); -}); - Deno.test('request-time client injection embeds portable tolerant helper and preserves statusText (#1103)', () => { const code = renderRequestTimeServerModule([]); assertStringIncludes(code, 'function insertBeforeBodyClose(html, fragment)'); diff --git a/packages/adapter-vite/__tests__/ssg-render.test.ts b/packages/adapter-vite/__tests__/ssg-render.test.ts index f22241257..a74652f67 100644 --- a/packages/adapter-vite/__tests__/ssg-render.test.ts +++ b/packages/adapter-vite/__tests__/ssg-render.test.ts @@ -26,6 +26,10 @@ async function pathExists(path: string): Promise { function createMockBundle(overrides: Partial = {}): SsrBundle { const app = new Hono(); app.get('/', (c) => c.text('ok')); + // Mock dispatcher must serve every static routeInfo path: SSG discovers + // from routeInfo and renders through the real dispatcher (Beta.2.1), so a + // routeInfo path missing from the app is a real 404, not a silent skip. + app.get('/about', (c) => c.text('about ok')); return { default: app, routeInfo: [ @@ -668,3 +672,49 @@ export default app; await Deno.remove(dir, { recursive: true }).catch(() => {}); } }); + +Deno.test('SSG discovers static pages from route records behind the unified HTTP middleware', async () => { + const { createRouteMiddleware } = await import('@openelement/app/router/http'); + const root = await Deno.makeTempDir({ prefix: 'oe-record-ssg-' }); + const app = new Hono(); + let dynamicCalls = 0; + app.all( + '*', + createRouteMiddleware([ + { id: 'index.tsx', path: '/', handlers: { GET: (c) => c.html('
record home
') } }, + { + id: 'live.tsx', + path: '/live', + handlers: { + GET: (c) => { + dynamicCalls++; + return c.html('live'); + }, + }, + }, + ]), + ); + try { + await ssgRender( + createMockBundle({ + default: app, + routeInfo: [ + { path: '/', tagName: 'home-page', isDynamic: false, paramNames: [] }, + { + path: '/live', + tagName: 'live-page', + isDynamic: false, + paramNames: [], + rendering: 'dynamic', + }, + ], + }), + { root, outDir: 'dist' }, + ); + assertStringIncludes(await Deno.readTextFile(`${root}/dist/index.html`), 'record home'); + assertEquals(dynamicCalls, 0); + assertEquals(await pathExists(`${root}/dist/live/index.html`), false); + } finally { + await Deno.remove(root, { recursive: true }); + } +}); diff --git a/packages/adapter-vite/deno.json b/packages/adapter-vite/deno.json index 8c5149a77..e2408d6b7 100644 --- a/packages/adapter-vite/deno.json +++ b/packages/adapter-vite/deno.json @@ -3,6 +3,7 @@ "version": "0.44.0-beta.2", "exports": { ".": "./src/index.ts", + "./element": "./src/element.ts", "./nitro-mount": "./src/nitro-mount.ts", "./sitemap": "./src/sitemap.ts", "./cli/build": "./src/cli/build.ts", diff --git a/packages/adapter-vite/src/element.ts b/packages/adapter-vite/src/element.ts new file mode 100644 index 000000000..97aa27ad8 --- /dev/null +++ b/packages/adapter-vite/src/element.ts @@ -0,0 +1,2 @@ +/** Standalone Element authoring plugin. No Router, SSG or deployment imports. */ +export { compiledElementPlugin as element } from './internal/compiler/plugin.ts'; diff --git a/packages/adapter-vite/src/generated-export-files.ts b/packages/adapter-vite/src/generated-export-files.ts index a832ef0bc..8e3f3443c 100644 --- a/packages/adapter-vite/src/generated-export-files.ts +++ b/packages/adapter-vite/src/generated-export-files.ts @@ -6,6 +6,7 @@ export const OPENELEMENT_EXPORT_FILES: Record> = '.': 'src/index.ts', 'cli/build': 'src/cli/build.ts', 'cli/start': 'src/cli/start.ts', + 'element': 'src/element.ts', 'nitro-mount': 'src/nitro-mount.ts', 'sitemap': 'src/sitemap.ts', }, @@ -14,6 +15,8 @@ export const OPENELEMENT_EXPORT_FILES: Record> = 'i18n': 'src/i18n.ts', 'model': 'src/model.ts', 'preact': 'src/preact.ts', + 'router': 'src/router.ts', + 'router/http': 'src/router-http.ts', 'spa': 'src/spa.ts', }, 'create': { diff --git a/packages/adapter-vite/src/internal/ssg/entry-codegen.ts b/packages/adapter-vite/src/internal/ssg/entry-codegen.ts index 0b98c6573..768a3f254 100644 --- a/packages/adapter-vite/src/internal/ssg/entry-codegen.ts +++ b/packages/adapter-vite/src/internal/ssg/entry-codegen.ts @@ -76,10 +76,10 @@ function renderRouteHandlerPreamble(lines: string[], ctx: RouteHandlerEmitContex // ADR-0121 (#568): conservative default body limit on action POSTs; // larger uploads belong on API routes with explicit limits. lines.push( - `app.post(${pathLiteral}, __bodyLimit({ maxSize: 10 * 1024 * 1024, onError: (c) => { c.header('Cache-Control', 'no-store'); c.header('Vary', __actionFetchHeader); return c.text('Payload Too Large', 413); } }), async (c) => {`, + `__pageHandlers[${pathLiteral}].POST = [__bodyLimit({ maxSize: 10 * 1024 * 1024, onError: (c) => { c.header('Cache-Control', 'no-store'); c.header('Vary', __actionFetchHeader); return c.text('Payload Too Large', 413); } }), async (c) => {`, ); } else { - lines.push(`app.get(${pathLiteral}, async (c) => {`); + lines.push(`__pageHandlers[${pathLiteral}].GET = [async (c) => {`); } // ADR-0129: one mutable response-header channel per request, shared by the // loader and the action (the spread into the action context carries the @@ -105,7 +105,7 @@ function renderRouteHandlerPreamble(lines: string[], ctx: RouteHandlerEmitContex lines.push(` const __actionState = { isFetch: false };`); } lines.push(` try {`); - lines.push(` __params = c.req.param() || {}`); + lines.push(` __params = c.get('routeResolution').params`); lines.push(` const __loadContext = {`); lines.push(` params: __params,`); lines.push(` request: c.req.raw,`); @@ -321,7 +321,7 @@ function renderRouteResponseAndCatch(lines: string[], ctx: RouteHandlerEmitConte // ADR-0129: close the handler-body IIFE and merge the response-header // channel into whatever response the body produced. lines.push(` })(), __responseHeaders);`); - lines.push(`})`); + lines.push(`}];`); lines.push(''); } @@ -371,16 +371,6 @@ export function renderActionRoute( isSSG: boolean, ): void { renderRouteHandler(lines, { method: 'post', route, renderers, docConfig, isSSG }); - // ADR-0121 (#572): only GET/POST are defined for page routes — other - // methods get a defined 405 instead of the server fallback 404. The - // method-specific handlers above are registered first and win for - // GET/POST/HEAD. no-store/Vary apply to the 405 as well (#586). - lines.push( - `app.all(${ - quoteGeneratedJavaScriptValue(route.path) - }, (c) => { c.header('Cache-Control', 'no-store'); c.header('Vary', __actionFetchHeader); return c.text('Method Not Allowed', 405, { Allow: 'GET, POST' }); });`, - ); - lines.push(''); } /** diff --git a/packages/adapter-vite/src/internal/ssg/entry-orchestrator.ts b/packages/adapter-vite/src/internal/ssg/entry-orchestrator.ts index a31be8f01..519123098 100644 --- a/packages/adapter-vite/src/internal/ssg/entry-orchestrator.ts +++ b/packages/adapter-vite/src/internal/ssg/entry-orchestrator.ts @@ -49,6 +49,10 @@ export function renderEntry(desc: EntryDescriptor): string { const ssrAdmissionPlan = desc.ssrAdmissionPlan; for (const island of desc.islands) validateIslandModuleSpecifier(island.modulePath); + lines.push( + "import { createRouteMiddleware as __createRouteMiddleware } from '@openelement/app/router/http';", + ); + // --- Imports --- for (const imp of desc.imports) { lines.push(renderImport(imp)); @@ -318,6 +322,11 @@ export function renderEntry(desc: EntryDescriptor): string { renderApiRoute(lines, route); } + lines.push( + `const __pageHandlers = Object.fromEntries(${ + JSON.stringify(desc.pageRoutes.map((r) => r.path)) + }.map(path => [path, {}]));`, + ); // --- Page routes --- const docConfig = { title: desc.document.title, @@ -334,6 +343,18 @@ export function renderEntry(desc: EntryDescriptor): string { renderActionRoute(lines, route, desc.renderers, docConfig, desc.isSSG); } + lines.push(`app.all('*', __createRouteMiddleware([`); + for (const route of desc.pageRoutes) { + lines.push( + ` { id: ${JSON.stringify(route.filePath)}, path: ${ + JSON.stringify(route.path) + }, handlers: __pageHandlers[${JSON.stringify(route.path)}] },`, + ); + } + lines.push( + `], { methodNotAllowed: (c, allow) => { c.header('Cache-Control', 'no-store'); c.header('Vary', __actionFetchHeader); return c.text('Method Not Allowed', 405, { Allow: allow.join(', ') }); } }));`, + ); + // --- Styled 404 (#923): unmatched paths render the /404 page --- const notFoundPage = desc.pageRoutes.find((r) => r.path === '/404'); if (notFoundPage) { diff --git a/packages/adapter-vite/src/internal/ssg/route-scanner.ts b/packages/adapter-vite/src/internal/ssg/route-scanner.ts index 16ea5f8f4..068f9d7d7 100644 --- a/packages/adapter-vite/src/internal/ssg/route-scanner.ts +++ b/packages/adapter-vite/src/internal/ssg/route-scanner.ts @@ -329,18 +329,22 @@ export async function scanRoutes( } } - // Sort routes: static paths first, then dynamic + // Generated order: special files last; static, parameters, catch-all; + // ties use code-point path/file ordering independent of machine locale. entries.sort((a, b) => { - // Special files go to the end - if (a.special || b.special) { - if (a.special && !b.special) return 1; - if (!a.special && b.special) return -1; - return 0; - } - const aDynamic = a.path.includes(':'); - const bDynamic = b.path.includes(':'); - if (aDynamic !== bDynamic) return aDynamic ? 1 : -1; - return a.path.localeCompare(b.path); + const rank = (r: RouteEntry) => + r.special ? 3 : !r.path.includes(':') ? 0 : r.path.includes('{.+}') ? 2 : 1; + const difference = rank(a) - rank(b); + if (difference) return difference; + return a.path < b.path + ? -1 + : a.path > b.path + ? 1 + : a.filePath < b.filePath + ? -1 + : a.filePath > b.filePath + ? 1 + : 0; }); // #1029: pathToVarName folds '/', '-', and '_' into '_', so paths like @@ -350,6 +354,25 @@ export async function scanRoutes( // both source paths. Checked once at the top-level call (recursion passes a // non-empty baseDir). if (baseDir === '') { + const seenPaths = new Map(); + for (const entry of entries) { + if (entry.special) continue; + // Only compare the scanner's bracket-file grammar; no regex-language + // equivalence or arbitrary overlap rejection is attempted. + const shape = parseRouteFilePath( + entry.filePath.replace(/\[\.\.\.[^\]]+\]/g, '[...param]').replace( + /\[(?!\.\.\.)[^\]]+\]/g, + '[param]', + ), + ); + const previous = seenPaths.get(shape); + if (previous) { + throw new Error( + `Equivalent route files: '${previous}' and '${entry.filePath}' produce '${shape}'`, + ); + } + seenPaths.set(shape, entry.filePath); + } const seenVarNames = new Map(); for (const entry of entries) { const existing = seenVarNames.get(entry.varName); diff --git a/packages/adapter-vite/src/internal/ssg/ssg-helpers.ts b/packages/adapter-vite/src/internal/ssg/ssg-helpers.ts index 6a47e840a..a1b20b1db 100644 --- a/packages/adapter-vite/src/internal/ssg/ssg-helpers.ts +++ b/packages/adapter-vite/src/internal/ssg/ssg-helpers.ts @@ -5,7 +5,7 @@ * This module sits at the bottom of the dependency graph. */ -import { normalizeRoutePatternForURLPattern } from '@openelement/element/build-utils'; +import { normalizeRoutePatternForURLPattern } from '@openelement/app/router'; import { walkHtmlFileEntries } from '../html-files.ts'; import { NODE_BRIDGE_EMBEDDED_FUNCTIONS } from '../node-bridge.ts'; @@ -95,21 +95,6 @@ interface RequestTimeRoutePattern { path: string; } -/** - * Translate a request-time route pattern ('/item/:id', '/docs/:path{.+}') - * into a WHATWG URLPattern pathname (#856, ADR-0123). The framework dialect - * is already URLPattern-shaped except for the Hono-style `:name{regex}` - * catch-all emitted by the route scanner (#812), which rewrites to the - * URLPattern `:name(regex)` form. Used to generate the self-contained - * admission predicate inside dist/server/index.js (#556, narrowed by #1215): - * hosts get a dispatch predicate instead of re-implementing pattern matching - * against the raw ':param' strings in server-manifest.json. - * - * The implementation is shared with the app client router through - * @openelement/element/build-utils (#1103). - */ -export const routePatternToURLPatternPath = normalizeRoutePatternForURLPattern; - /** * Serialize the request-time admission patterns embedded in the generated * server entry (#1215). Declaration order is preserved and irrelevant: the @@ -118,7 +103,7 @@ export const routePatternToURLPatternPath = normalizeRoutePatternForURLPattern; function renderRequestTimeAdmissionPatterns(routes: RequestTimeRoutePattern[]): string { return routes .map((route) => { - const pattern = JSON.stringify(routePatternToURLPatternPath(route.path)); + const pattern = JSON.stringify(normalizeRoutePatternForURLPattern(route.path)); return ` new URLPattern({ pathname: ${pattern} }),`; }) .join('\n'); diff --git a/packages/adapter-vite/src/internal/ssg/ssg-render.ts b/packages/adapter-vite/src/internal/ssg/ssg-render.ts index a3e3ccaa1..87647f09e 100644 --- a/packages/adapter-vite/src/internal/ssg/ssg-render.ts +++ b/packages/adapter-vite/src/internal/ssg/ssg-render.ts @@ -148,8 +148,30 @@ export async function ssgRender( // not receive the request path) and surface them in the build summary. const staticNon200: Array<{ path: string; status: number }> = []; const warnings: string[] = []; + // Beta.2.1 (#1325): the unified entry serves pages behind a single + // app.all('*', dispatcher), so app.routes no longer enumerates pages and + // hono/ssg discovers nothing. Project eligible static pages from canonical + // routeInfo for DISCOVERY ONLY — matching/rendering still runs through the + // real app.fetch/request (unified dispatcher). The dummy handler is never + // executed; its arity only keeps hono's isMiddleware filter from dropping + // the enumerated path. Host routes coexisting on the app are preserved + // as-is; the wildcard dispatcher itself is not a discoverable page. + const eligibleStaticPaths = routeInfo + .filter((r) => r.rendering !== 'dynamic' && !r.isDynamic) + .map((r) => r.path); + const preservedHostRoutes = (app.routes ?? []).filter( + (r) => r.path !== '*' && r.path !== '/*', + ); + const preservedPaths = new Set(preservedHostRoutes.map((r) => r.path)); + const discoveryHandler = () => {}; + const discoveryRoutes = [ + ...preservedHostRoutes, + ...eligibleStaticPaths + .filter((p) => !preservedPaths.has(p)) + .map((path) => ({ method: 'GET', handler: discoveryHandler, path })), + ]; const recordingApp: SsgHonoApp = { - routes: app.routes, + routes: discoveryRoutes, fetch: (request, ...args) => app.fetch(request, ...args), request: async (input, ...args) => { const response = await app.request(input, ...args); diff --git a/packages/adapter-vite/src/route-manifest.ts b/packages/adapter-vite/src/route-manifest.ts index bf36561c0..0c48ec20a 100644 --- a/packages/adapter-vite/src/route-manifest.ts +++ b/packages/adapter-vite/src/route-manifest.ts @@ -10,15 +10,15 @@ * ```ts * // Generated to `${outDir}/route-manifest.ts`; import from that generated * // module or alias it from your client entry. - * import { routeManifest } from './route-manifest.ts'; + * import { routeRecords } from './route-manifest.ts'; * import { defineApp } from '@openelement/app'; * * const app = defineApp({ * mode: 'spa', * routes: await Promise.all( - * Object.entries(routeManifest).map(async ([path, load]) => { + * routeRecords.map(async ({ load, ...record }) => { * const mod = await load(); - * return { path, tagName: mod.tagName }; + * return { ...record, tagName: mod.tagName }; * }), * ), * }); @@ -74,9 +74,11 @@ async function generateRouteManifest( // Route paths and file names are interpolated into generated code, so // they go through the shared JS-literal quoting (a `'` in a file name // must not break the manifest's syntax, #1039). - return ` ${quoteGeneratedJavaScriptValue(r.path)}: () => import(${ + return ` { id: ${quoteGeneratedJavaScriptValue(r.filePath)}, path: ${ + quoteGeneratedJavaScriptValue(r.path) + }, methods: ['GET', 'POST'], load: () => import(${ quoteGeneratedJavaScriptValue(importPath) - })`; + }) }`; }); if (entries.length === 0) { @@ -84,7 +86,7 @@ async function generateRouteManifest( count: 0, content: `// Auto-generated by @openelement/adapter-vite — do not edit. // No page routes found in "${routesDir}". -export const routeManifest = {} as const; +export const routeRecords = [] as const; `, }; } @@ -92,9 +94,9 @@ export const routeManifest = {} as const; return { count: entries.length, content: `// Auto-generated by @openelement/adapter-vite — do not edit. -export const routeManifest = { +export const routeRecords = [ ${entries.join(',\n')} -} as const; +] as const; `, }; } diff --git a/packages/app/__tests__/authoring.test.ts b/packages/app/__tests__/authoring.test.ts index ca354a2ba..2c8757516 100644 --- a/packages/app/__tests__/authoring.test.ts +++ b/packages/app/__tests__/authoring.test.ts @@ -104,7 +104,7 @@ Deno.test('definePage() attaches the descriptor to the compiled class and return const props = () => ({}); const error = () => ({}); const result = definePage(Page, { - route: { path: '/', id: 'home' }, + route: { id: 'home' }, head: { title: 'Home', description: 'Application API', @@ -120,7 +120,7 @@ Deno.test('definePage() attaches the descriptor to the compiled class and return const descriptor = (Page as unknown as { openElementPage: Record }) .openElementPage; assertEquals(descriptor.kind, 'page'); - assertEquals(descriptor.route, { path: '/', id: 'home' }); + assertEquals(descriptor.route, { id: 'home' }); assertEquals(descriptor.head, { title: 'Home', description: 'Application API', diff --git a/packages/app/__tests__/client-router.test.ts b/packages/app/__tests__/client-router.test.ts index c0910a916..9d40e9f8f 100644 --- a/packages/app/__tests__/client-router.test.ts +++ b/packages/app/__tests__/client-router.test.ts @@ -13,10 +13,11 @@ import { const routes: RouteConfig[] = [{ path: '/items/:id', tagName: 'item-page' }]; -Deno.test('client router decodes path parameters and gives path values precedence', () => { +Deno.test('client router keeps decoded path parameters separate from query', () => { const match = matchRoute('/items/hello%20world', '?id=query&view=full', routes); assertEquals(match?.params.id, 'hello world'); - assertEquals(match?.params.view, 'full'); + assertEquals(match?.params.view, undefined); + assertEquals(match?.searchParams.get('view'), 'full'); }); Deno.test('client router decodes query components exactly once', () => { @@ -27,15 +28,15 @@ Deno.test('client router decodes query components exactly once', () => { ['?value=%2B', '+'], ] as const; for (const [search, expected] of cases) { - assertEquals(matchRoute('/items/id', search, routes)?.params.value, expected); + assertEquals(matchRoute('/items/id', search, routes)?.searchParams.get('value'), expected); } }); Deno.test('client router preserves malformed query escapes without aborting matching', () => { const match = matchRoute('/items/id', '?bad=%&also=%2&key%=value%', routes); - assertEquals(match?.params.bad, '%'); - assertEquals(match?.params.also, '%2'); - assertEquals(match?.params['key%'], 'value%'); + assertEquals(match?.searchParams.get('bad'), '%'); + assertEquals(match?.searchParams.get('also'), '%2'); + assertEquals(match?.searchParams.get('key%'), 'value%'); }); interface ExpectedRouteMatch { @@ -178,7 +179,7 @@ const semanticCases: Array<{ search: '?id=query&view=first&view=last&encoded=a%2Bb+two', expected: { route: 'item-page', - params: { id: 'hello world', view: 'last', encoded: 'a+b two' }, + params: { id: 'hello world' }, }, }, { @@ -187,7 +188,7 @@ const semanticCases: Array<{ search: '?bad=%&also=%2&key%=value%', expected: { route: 'item-page', - params: { bad: '%', also: '%2', 'key%': 'value%', id: 'id' }, + params: { id: 'id' }, }, }, { @@ -298,8 +299,7 @@ Deno.test('RouteTable rejects malformed URLPattern patterns consistently', () => Deno.test('RouteTable classifies methods, HEAD, base paths, and trailing-slash policy', () => { type MethodRoute = RouteConfig & { methods: readonly string[] }; const methodRoutes: MethodRoute[] = [ - { path: '/items/:id', tagName: 'item-get', methods: ['GET'] }, - { path: '/items/:id', tagName: 'item-post', methods: ['POST'] }, + { path: '/items/:id', tagName: 'item', methods: ['GET', 'POST'] }, ]; const tables = [ new RouteTable(methodRoutes, URLPatternPolyfillConstructor, { @@ -318,9 +318,9 @@ Deno.test('RouteTable classifies methods, HEAD, base paths, and trailing-slash p ]; const expected = [ - { kind: 'match', route: 'item-get', params: { id: 'a b' } }, - { kind: 'match', route: 'item-get', params: { id: 'a b' } }, - { kind: 'match', route: 'item-post', params: { id: 'a b' } }, + { kind: 'match', route: 'item', params: { id: 'a b' } }, + { kind: 'match', route: 'item', params: { id: 'a b' } }, + { kind: 'match', route: 'item', params: { id: 'a b' } }, { kind: 'method-not-allowed', allow: ['GET', 'HEAD', 'POST'] }, { kind: 'not-found' }, ]; @@ -363,7 +363,13 @@ Deno.test('client router dispose removes event listeners and double dispose is s const removed: EventListener[] = []; Object.defineProperty(globalThis, 'location', { configurable: true, - value: { protocol: 'https:', pathname: '/', search: '', hash: '' }, + value: { + protocol: 'https:', + href: 'https://router.test/', + pathname: '/', + search: '', + hash: '', + }, }); Object.defineProperty(globalThis, 'history', { configurable: true, @@ -402,7 +408,13 @@ Deno.test('client router guard redirect limit rejects redirect loops', async () const originalHistory = Object.getOwnPropertyDescriptor(globalThis, 'history'); Object.defineProperty(globalThis, 'location', { configurable: true, - value: { protocol: 'https:', pathname: '/', search: '', hash: '' }, + value: { + protocol: 'https:', + href: 'https://router.test/', + pathname: '/', + search: '', + hash: '', + }, }); Object.defineProperty(globalThis, 'history', { configurable: true, @@ -961,6 +973,9 @@ function installFakeHistoryStack(initialEntries: string[]) { configurable: true, value: { protocol: 'https:', + get href() { + return currentUrl().href; + }, get pathname() { return currentUrl().pathname; }, @@ -1306,6 +1321,175 @@ Deno.test('client router popstate with a synchronously-throwing guard fails open } }); +// ─── Native navigation ownership: POST / fragment / reload stay browser-owned ─── + +interface FakeNavigateEvent { + destination: { url: string }; + canIntercept: boolean; + downloadRequest: unknown; + sourceElement: { hasAttribute: (name: string) => boolean } | null; + signal: AbortSignal; + info: unknown; + formData?: FormData | null; + method?: string; + navigationType?: string; + intercepted: boolean; + intercept(options: { handler: () => Promise }): void; +} + +function installFakeNavigation() { + const descriptors = { + location: Object.getOwnPropertyDescriptor(globalThis, 'location'), + history: Object.getOwnPropertyDescriptor(globalThis, 'history'), + navigation: Object.getOwnPropertyDescriptor(globalThis, 'navigation'), + }; + const listeners = new Map(); + Object.defineProperty(globalThis, 'location', { + configurable: true, + value: { + protocol: 'http:', + origin: 'http://router.test', + href: 'http://router.test/a', + pathname: '/a', + search: '', + hash: '', + }, + }); + Object.defineProperty(globalThis, 'history', { + configurable: true, + value: { pushState() {}, replaceState() {} }, + }); + const fakeNavigation = { + addEventListener(type: string, listener: EventListener) { + listeners.set(type, [...(listeners.get(type) ?? []), listener]); + }, + removeEventListener(type: string, listener: EventListener) { + listeners.set(type, (listeners.get(type) ?? []).filter((entry) => entry !== listener)); + }, + }; + Object.defineProperty(globalThis, 'navigation', { configurable: true, value: fakeNavigation }); + const makeEvent = ( + overrides: Partial & { destination: { url: string } }, + ): FakeNavigateEvent => ({ + canIntercept: true, + downloadRequest: null, + sourceElement: null, + signal: new AbortController().signal, + info: undefined, + intercepted: false, + intercept(_options: { handler: () => Promise }) { + (this as FakeNavigateEvent).intercepted = true; + }, + ...overrides, + }); + return { + fire(event: FakeNavigateEvent) { + for (const listener of listeners.get('navigate') ?? []) { + (listener as (e: unknown) => void)(event); + } + }, + makeEvent, + restore() { + for (const [key, descriptor] of Object.entries(descriptors)) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else delete (globalThis as Record)[key]; + } + }, + }; +} + +Deno.test('native POST with formData stays browser-owned: no pending, no intercept', () => { + const nav = installFakeNavigation(); + let pending = 0; + const router = createRouter({ + mode: 'history', + routes: [{ path: '/a', tagName: 'a-page' }], + onPending: () => pending++, + }); + try { + const event = nav.makeEvent({ + destination: { url: 'http://router.test/a' }, + formData: new FormData(), + }); + nav.fire(event); + assertEquals(event.intercepted, false); + assertEquals(pending, 0); + assertEquals(router.currentPath, '/a'); + } finally { + router.dispose(); + nav.restore(); + } +}); + +Deno.test('native fragment-only navigation stays browser-owned without cancelling loaders', () => { + const nav = installFakeNavigation(); + let pending = 0; + let guards = 0; + const router = createRouter({ + mode: 'history', + routes: [ + { path: '/a', tagName: 'a-page', guard: () => (guards++, Promise.resolve(true)) }, + ], + onPending: () => void pending++, + onChange: () => void pending++, + }); + try { + const event = nav.makeEvent({ destination: { url: 'http://router.test/a#section' } }); + nav.fire(event); + assertEquals(event.intercepted, false); + assertEquals(pending, 0); + assertEquals(guards, 0); + assertEquals(router.currentPath, '/a'); + } finally { + router.dispose(); + nav.restore(); + } +}); + +Deno.test('native reload stays browser-owned', () => { + const nav = installFakeNavigation(); + let pending = 0; + const router = createRouter({ + mode: 'history', + routes: [{ path: '/a', tagName: 'a-page' }], + onPending: () => pending++, + }); + try { + const event = nav.makeEvent({ + destination: { url: 'http://router.test/a' }, + navigationType: 'reload', + }); + nav.fire(event); + assertEquals(event.intercepted, false); + assertEquals(pending, 0); + } finally { + router.dispose(); + nav.restore(); + } +}); + +Deno.test('native same-origin GET without formData still intercepts', () => { + const nav = installFakeNavigation(); + let pending = 0; + const router = createRouter({ + mode: 'history', + routes: [ + { path: '/a', tagName: 'a-page' }, + { path: '/b', tagName: 'b-page' }, + ], + onPending: () => pending++, + }); + try { + const event = nav.makeEvent({ destination: { url: 'http://router.test/b' } }); + nav.fire(event); + assertEquals(event.intercepted, true); + assertEquals(pending, 1); + } finally { + router.dispose(); + nav.restore(); + } +}); + Deno.test('client router never leaks unhandled rejections across dispose and guard-failure storms (#1146-3d)', async () => { const trap = trapUnhandledRejections(); const browser = installFakeBrowser('/public'); diff --git a/packages/app/__tests__/route-pattern.test.ts b/packages/app/__tests__/route-pattern.test.ts new file mode 100644 index 000000000..af3121f70 --- /dev/null +++ b/packages/app/__tests__/route-pattern.test.ts @@ -0,0 +1,14 @@ +import { assertEquals } from '@std/assert'; +import { normalizeRoutePatternForURLPattern } from '../src/internal/router/route-pattern.ts'; + +Deno.test('shared route normalizer preserves params and converts Hono catch-alls (#1103)', () => { + assertEquals(normalizeRoutePatternForURLPattern('/item/:id'), '/item/:id'); + assertEquals( + normalizeRoutePatternForURLPattern('/docs/:path{.+}'), + '/docs/:path(.+)', + ); + assertEquals( + normalizeRoutePatternForURLPattern('/org/:org/repo/:path{.*}'), + '/org/:org/repo/:path(.*)', + ); +}); diff --git a/packages/app/__tests__/route-resolution.test.ts b/packages/app/__tests__/route-resolution.test.ts new file mode 100644 index 000000000..c411a3641 --- /dev/null +++ b/packages/app/__tests__/route-resolution.test.ts @@ -0,0 +1,49 @@ +import { assertEquals } from '@std/assert'; +import { RouteTable } from '../src/internal/router/route-table.ts'; + +Deno.test('URL winner precedes method dispatch, including overlapping explicit records', () => { + const routes = [ + { path: '/products/new', methods: ['GET'] }, + { path: '/products/:id', methods: ['POST'] }, + ]; + const resolution = new RouteTable(routes).resolve('/products/new', '', 'POST'); + assertEquals(resolution.kind, 'method-not-allowed'); + if (resolution.kind === 'method-not-allowed') assertEquals(resolution.allow, ['GET', 'HEAD']); + assertEquals( + new RouteTable([...routes].reverse()).resolve('/products/new', '', 'POST').kind, + 'match', + ); +}); + +Deno.test('query never becomes a path capture', () => { + const match = new RouteTable([{ path: '/items/:id' }]).match( + '/items/a%252Fb', + '?id=query&view=&view=full', + ); + assertEquals(Object.entries(match?.params ?? {}), [['id', 'a%2Fb']]); +}); + +Deno.test('resolution snapshots and full URL component patterns preserve URLPattern semantics', () => { + const routes = [{ + id: 'secure', + path: '/items/:id', + pattern: { hostname: 'shop.example', protocol: 'https' }, + methods: ['get'], + }]; + const table = new RouteTable(routes); + routes[0].path = '/changed'; + routes[0].methods.push('POST'); + const result = table.resolve(new URL('https://shop.example/items/a%2Fb?q=&q=2')); + assertEquals(result.kind, 'match'); + if (result.kind === 'match') { + assertEquals(result.id, 'secure'); + assertEquals(result.params.id, 'a/b'); + assertEquals(result.searchParams.getAll('q'), ['', '2']); + } + assertEquals(table.resolve(new URL('https://other.example/items/a')).kind, 'not-found'); + assertEquals(table.resolve('/items/a', '', 'POST').kind, 'not-found'); + assertEquals( + new RouteTable([{ path: '//a' }]).match(new URL('https://shop.example//a'))?.route.path, + '//a', + ); +}); diff --git a/packages/app/__tests__/router-browser.test.ts b/packages/app/__tests__/router-browser.test.ts new file mode 100644 index 000000000..7da106323 --- /dev/null +++ b/packages/app/__tests__/router-browser.test.ts @@ -0,0 +1,239 @@ +import { assertEquals } from '@std/assert'; +import { chromium, firefox, webkit } from '@playwright/test'; +import { createServer } from 'vite'; +import { generateWorkspaceAliases } from '../../adapter-vite/src/workspace-alias.ts'; + +Deno.test({ + name: 'Navigation API: three-browser push/replace/traversal and stale guard regression', + sanitizeOps: false, + sanitizeResources: false, + async fn() { + const root = new URL('../../../', import.meta.url).pathname.replace(/\/$/, ''); + const source = + `import {createRouter} from '/@fs/${root}/packages/app/src/internal/router/client-router.ts'; + window.changes=[]; + window.router=createRouter({mode:'history', routes:[ + {path:'/',tagName:'home-page'}, {path:'/a',tagName:'a-page'}, {path:'/b',tagName:'b-page'}, + {path:'/slow',tagName:'slow-page',guard:()=>new Promise(r=>window.releaseGuard=r)}, + {path:'/download',tagName:'download-page'}, {path:'/redirect',tagName:'redirect-page',guard:async()=>'/b'}, {path:'/blocked',tagName:'blocked-page',guard:async()=>false} + ],onChange:()=>window.changes.push(window.router.currentPath)});`; + const server = await createServer({ + root, + configFile: false, + optimizeDeps: { noDiscovery: true, include: [] }, + logLevel: 'error', + resolve: { alias: generateWorkspaceAliases(root) }, + server: { host: '127.0.0.1', port: 0 }, + plugins: [{ + name: 'nav-proof', + configureServer(server) { + server.middlewares.use((req, res, next) => { + if (req.url === '/download') { + res.setHeader('content-type', 'text/plain'); + res.setHeader('content-disposition', 'attachment; filename="proof.txt"'); + res.end('download proof'); + return; + } + if (req.url === '/proof.js') { + res.setHeader('content-type', 'text/javascript'); + res.end(source); + return; + } + if (req.headers.accept?.includes('text/html')) { + res.setHeader('content-type', 'text/html'); + res.end( + 'a', + ); + return; + } + next(); + }); + }, + }], + }); + await server.listen(); + try { + const address = server.httpServer!.address() as { port: number }; + for (const type of [chromium, firefox, webkit]) { + const browser = await type.launch({ headless: true }); + try { + const page = await browser.newPage(); + page.setDefaultTimeout(10_000); + await page.goto(`http://127.0.0.1:${address.port}`); + await page.waitForFunction('window.router'); + await page.evaluate('window.router.navigate("/a")'); + assertEquals(await page.evaluate('window.router.currentPath'), '/a', type.name()); + await page.evaluate('window.router.replace("/b")'); + await page.goBack(); + await page.waitForFunction('window.router.currentPath === "/"'); + await page.goForward(); + await page.waitForFunction('window.router.currentPath === "/b"'); + await page.evaluate('window.pending = window.router.navigate("/slow"); void 0'); + await page.evaluate('window.router.navigate("/a")'); + await page.evaluate('window.releaseGuard("/b"); window.pending'); + assertEquals(await page.evaluate('window.router.currentPath'), '/a'); + await page.evaluate('window.router.navigate("/redirect")'); + assertEquals(await page.evaluate('window.router.currentPath'), '/b'); + // Read layout directly: WebKit's Playwright locator auto-wait treats + // Navigation API same-document traversals as pending document loads. + const link = await page.evaluate(() => + document.querySelector('#link')!.getBoundingClientRect().toJSON() + ); + await page.mouse.click(link.x + link.width / 2, link.y + link.height / 2); + await page.waitForFunction('window.router.currentPath === "/a"'); + await page.evaluate( + 'document.querySelector("#link").href="/blocked"; document.querySelector("#link").click()', + ); + await page.waitForFunction( + 'location.pathname === "/a" && window.router.currentPath === "/a"', + ); + await page.evaluate( + 'document.querySelector("#link").href="/slow"; document.querySelector("#link").click()', + ); + await page.waitForFunction('location.pathname === "/slow"'); + await page.evaluate('window.router.navigate("/b")'); + await page.evaluate('window.releaseGuard(false)'); + assertEquals(await page.evaluate('location.pathname'), '/b'); + assertEquals(await page.evaluate('window.router.currentPath'), '/b'); + await page.evaluate( + 'window.navEvents=[]; navigation.addEventListener("navigate",e=>window.navEvents.push({url:e.destination.url,can:e.canIntercept,download:e.downloadRequest,source:e.sourceElement?.outerHTML}))', + ); + const downloaded = page.waitForEvent('download').catch(async (error) => { + console.log( + type.name(), + await page.evaluate( + '({path:location.href,events:window.navEvents,current:window.router?.currentPath})', + ), + ); + throw error; + }); + await page.evaluate( + 'const a=document.querySelector("#link"); a.href="/download"; a.download="proof.txt"', + ); + await page.mouse.click(link.x + link.width / 2, link.y + link.height / 2); + assertEquals((await downloaded).suggestedFilename(), 'proof.txt'); + assertEquals(await page.evaluate('window.router.currentPath'), '/b'); + await page.route( + 'https://external.invalid/**', + (route) => + route.fulfill({ contentType: 'text/html', body: '

external document

' }), + ); + await page.evaluate( + 'const a=document.querySelector("#link"); a.removeAttribute("download"); a.href="https://external.invalid/a"; a.click()', + ); + await page.waitForURL('https://external.invalid/a'); + assertEquals(await page.evaluate('typeof window.router'), 'undefined'); + console.log( + `${type.name()} ${browser.version()}: navigation, abort, download, cross-origin PASS`, + ); + } finally { + await browser.close(); + } + } + } finally { + await server.close(); + } + }, +}); + +Deno.test({ + name: 'Navigation API: native POST / fragment / reload stay browser-owned (three browsers)', + sanitizeOps: false, + sanitizeResources: false, + async fn() { + const root = new URL('../../../', import.meta.url).pathname.replace(/\/$/, ''); + const source = + `import {createRouter} from '/@fs/${root}/packages/app/src/internal/router/client-router.ts'; + window.pending=0; window.guards=0; window.changes=[]; + window.router=createRouter({mode:'history', routes:[ + {path:'/',tagName:'home-page'}, {path:'/a',tagName:'a-page'}, {path:'/submit',tagName:'submit-page'} + ],onPending:()=>window.pending++,onChange:()=>window.changes.push(window.router.currentPath)});`; + const seen: Array<{ method: string; url: string; body: string }> = []; + const server = await createServer({ + root, + configFile: false, + optimizeDeps: { noDiscovery: true, include: [] }, + logLevel: 'error', + resolve: { alias: generateWorkspaceAliases(root) }, + server: { host: '127.0.0.1', port: 0 }, + plugins: [{ + name: 'ownership-proof', + configureServer(server) { + server.middlewares.use((req, res, next) => { + if (req.url === '/proof2.js') { + res.setHeader('content-type', 'text/javascript'); + res.end(source); + return; + } + if (req.method === 'POST' && req.url === '/submit') { + let body = ''; + req.on('data', (chunk) => body += chunk); + req.on('end', () => { + seen.push({ method: req.method!, url: req.url!, body }); + res.setHeader('content-type', 'text/html'); + res.end('submitted'); + }); + return; + } + if (req.headers.accept?.includes('text/html')) { + res.setHeader('content-type', 'text/html'); + res.end( + '' + + '
' + + 'frag
target
', + ); + return; + } + next(); + }); + }, + }], + }); + await server.listen(); + try { + const address = server.httpServer!.address() as { port: number }; + for (const type of [chromium, firefox, webkit]) { + seen.length = 0; + const browser = await type.launch({ headless: true }); + try { + const page = await browser.newPage(); + page.setDefaultTimeout(10_000); + await page.goto(`http://127.0.0.1:${address.port}/a`); + await page.waitForFunction('window.router'); + // Fragment: native scroll preserved, no guard/pending/change, no server hit. + await page.click('#frag'); + await page.waitForFunction('location.hash === "#section"'); + assertEquals(await page.evaluate('location.pathname'), '/a', type.name()); + assertEquals(await page.evaluate('window.router.currentPath'), '/a', type.name()); + assertEquals(await page.evaluate('window.pending'), 0, type.name()); + assertEquals(await page.evaluate('window.changes.length'), 0, type.name()); + assertEquals(Number(await page.evaluate('scrollY')) > 0, true, type.name()); + assertEquals(seen.length, 0, type.name()); + // Reload: full document load through the browser, fresh router after. + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForFunction('window.router'); + assertEquals(await page.evaluate('window.router.currentPath'), '/a', type.name()); + // Real form POST: method/body reach the server; the SPA never + // converts it into a GET page navigation (full document load). + await page.evaluate('location.hash=""'); + await Promise.all([ + page.waitForURL('**/submit'), + page.click('#submit'), + ]); + assertEquals(await page.evaluate('location.pathname'), '/submit', type.name()); + assertEquals(seen.length, 1, type.name()); + assertEquals(seen[0].method, 'POST', type.name()); + assertEquals(seen[0].body.includes('field=hello'), true, type.name()); + assertEquals(await page.evaluate('document.body.textContent'), 'submitted', type.name()); + console.log( + `${type.name()} ${browser.version()}: POST/fragment/reload browser-owned PASS`, + ); + } finally { + await browser.close(); + } + } + } finally { + await server.close(); + } + }, +}); diff --git a/packages/app/__tests__/router-http.test.ts b/packages/app/__tests__/router-http.test.ts new file mode 100644 index 000000000..ccfd30292 --- /dev/null +++ b/packages/app/__tests__/router-http.test.ts @@ -0,0 +1,78 @@ +import { assertEquals, assertRejects, assertThrows } from '@std/assert'; +import { Hono } from 'hono'; +import { createRouteMiddleware } from '../src/router-http.ts'; + +Deno.test('Request chooses one URL before methods; Hono retains context, middleware and response', async () => { + const events: string[] = []; + const app = new Hono<{ Variables: { host: string } }>(); + app.use('*', async (c, next) => { + events.push('before'); + c.set('host', 'ok'); + await next(); + events.push('after'); + c.header('x-host', 'yes'); + }); + app.get('/host', (c) => c.text('host')); + app.all( + '*', + createRouteMiddleware([ + { id: 'new', path: '/products/new', handlers: { GET: (c) => c.text(c.get('host')) } }, + { + id: 'item', + path: '/products/:id', + handlers: { POST: (c) => c.json({ params: c.get('routeResolution').params }) }, + }, + { + path: '/explicit-head', + handlers: { + GET: (c) => c.text('get'), + HEAD: (c) => c.text('body', 202, { 'x-explicit': 'yes' }), + }, + }, + ]), + ); + let response = await app.request('/products/new', { method: 'POST' }); + assertEquals(response.status, 405); + assertEquals(response.headers.get('Allow'), 'GET, HEAD'); + assertEquals(response.headers.get('x-host'), 'yes'); + response = await app.request('/products/new', { method: 'HEAD' }); + assertEquals(response.status, 200); + assertEquals(await response.text(), ''); + response = await app.request('/explicit-head', { method: 'HEAD' }); + assertEquals(response.status, 202); + assertEquals(response.headers.get('x-explicit'), 'yes'); + assertEquals(await response.text(), ''); + response = await app.request('/products/a%252Fb?q=x', { method: 'POST' }); + assertEquals(await response.json(), { params: { id: 'a%2Fb' } }); + assertEquals(await (await app.request('/host')).text(), 'host'); + assertEquals((await app.request('/missing')).status, 404); + assertEquals((await app.request('/products/new', { method: 'OPTIONS' })).status, 405); + assertEquals(events, Array.from({ length: 7 }, () => ['before', 'after']).flat()); +}); + +Deno.test('HTTP records reject ambiguous duplicate methods and preserve thrown handler errors', async () => { + assertThrows( + () => + createRouteMiddleware([{ + path: '/', + handlers: { get: (c) => c.text('a'), GET: (c) => c.text('b') }, + }]), + TypeError, + ); + const app = new Hono<{ Variables: { host: string } }>(); + app.onError(() => { + throw new Error('host error boundary'); + }); + app.all( + '*', + createRouteMiddleware([{ + path: '/', + handlers: { + GET: () => { + throw new Error('handler'); + }, + }, + }]), + ); + await assertRejects(() => Promise.resolve(app.request('/')), Error, 'host error boundary'); +}); diff --git a/packages/app/__tests__/spa.test.ts b/packages/app/__tests__/spa.test.ts index aae8a145c..16f38246b 100644 --- a/packages/app/__tests__/spa.test.ts +++ b/packages/app/__tests__/spa.test.ts @@ -111,7 +111,8 @@ Deno.test('defineApp mounts loader data and route context through the page props routes: [{ path: '/articles/:slug', tagName: 'article-page', - loader: ({ params }) => Promise.resolve({ title: `${params.slug}:${params.preview}` }), + loader: ({ params, searchParams }) => + Promise.resolve({ title: `${params.slug}:${searchParams.get('preview')}` }), }], }); try { @@ -121,13 +122,13 @@ Deno.test('defineApp mounts loader data and route context through the page props assertEquals(calls.normal.length, 1); const context = calls.normal[0]; assertEquals(context.data, { title: 'hello:yes' }); - assertEquals(context.params, { slug: 'hello', preview: 'yes' }); + assertEquals(context.params, { slug: 'hello' }); assertEquals(context.request?.url, 'https://example.test/articles/hello?preview=yes'); assertEquals(context.route, { path: '/articles/:slug' }); // The projected values landed on the host as pre-connect own properties. assertEquals(hosts.length, 1); assertEquals(hosts[0].slug, 'hello'); - assertEquals(hosts[0].preview, 'yes'); + assertEquals(hosts[0].preview, undefined); assertEquals(hosts[0].title, 'hello:yes'); } finally { app.dispose(); @@ -761,3 +762,211 @@ Deno.test('defineApp routes action notFound() to the page error projector (#731) restoreRegistry(); } }); + +// ─── Each mount gets a fresh execution controller ─── + +Deno.test('defineApp mount gives a live signal, dispose aborts it, remount gives a fresh live signal', async () => { + const signals: AbortSignal[] = []; + const root = { innerHTML: '', addEventListener() {}, removeEventListener() {}, appendChild() {} }; + const env = stubNavigableEnvironment(root, '/'); + const app = defineApp({ + mode: 'spa', + routerMode: 'history', + routes: [{ + path: '/', + tagName: 'home-page', + loader: ({ signal }) => { + signals.push(signal); + return Promise.resolve({ page: 'home' }); + }, + }], + }); + try { + app.mount('#app'); + await new Promise((resolve) => setTimeout(resolve, 0)); + assertEquals(signals.length, 1); + assertEquals(signals[0].aborted, false); + const signal1 = signals[0]; + app.dispose(); + assertEquals(signal1.aborted, true); + app.mount('#app'); + await new Promise((resolve) => setTimeout(resolve, 0)); + assertEquals(signals.length, 2); + const signal2 = signals[1]; + assertEquals(signal2 === signal1, false); + assertEquals(signal2.aborted, false); + } finally { + app.dispose(); + env.restore(); + } +}); + +Deno.test('defineApp direct remount aborts the stale loader and never commits its late result', async () => { + let release!: () => void; + const pending = new Promise((resolve) => release = resolve); + const seen: Array<{ signal: AbortSignal; value: string }> = []; + const hosts: Record[] = []; + const root = { + innerHTML: '', + addEventListener() {}, + removeEventListener() {}, + appendChild(host: Record) { + hosts.push(host); + return host; + }, + }; + const env = stubNavigableEnvironment(root, '/'); + let first = true; + const app = defineApp({ + mode: 'spa', + routerMode: 'history', + routes: [{ + path: '/', + tagName: 'home-page', + loader: ({ signal }) => { + if (first) { + first = false; + seen.push({ signal, value: 'stale' }); + return pending.then(() => ({ page: 'stale' })); + } + seen.push({ signal, value: 'fresh' }); + return Promise.resolve({ page: 'fresh' }); + }, + }], + }); + try { + app.mount('#app'); + await new Promise((resolve) => setTimeout(resolve, 0)); + // Direct remount without an explicit dispose: the stale mount aborts. + app.mount('#app'); + await new Promise((resolve) => setTimeout(resolve, 0)); + assertEquals(seen.length, 2); + assertEquals(seen[0].signal.aborted, true); + assertEquals(seen[1].signal.aborted, false); + assertEquals(seen[0].signal === seen[1].signal, false); + // The stale loader resolves late: it must not overwrite the fresh render. + release(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + assertEquals(hosts.at(-1)?.page, 'fresh'); + } finally { + app.dispose(); + env.restore(); + } +}); + +Deno.test('defineApp remount gives actions a fresh live signal', async () => { + let submit: ((event: Event) => void) | undefined; + const signals: AbortSignal[] = []; + const root = { + innerHTML: '', + addEventListener(type: string, listener: (event: Event) => void) { + if (type === 'submit') submit = listener; + }, + removeEventListener() {}, + appendChild() {}, + }; + const env = stubNavigableEnvironment(root, '/'); + const app = defineApp({ + mode: 'spa', + routerMode: 'history', + routes: [{ + path: '/', + tagName: 'home-page', + loader: () => Promise.resolve({ page: 'home' }), + action: ({ signal }) => { + signals.push(signal); + return Promise.resolve({ saved: true }); + }, + }], + }); + try { + app.mount('#app'); + await new Promise((resolve) => setTimeout(resolve, 0)); + submit?.( + { + target: { tagName: 'FORM' }, + composedPath: () => [], + preventDefault() {}, + } as unknown as Event, + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + assertEquals(signals.length, 1); + assertEquals(signals[0].aborted, false); + const signal1 = signals[0]; + app.dispose(); + assertEquals(signal1.aborted, true); + app.mount('#app'); + await new Promise((resolve) => setTimeout(resolve, 0)); + submit?.( + { + target: { tagName: 'FORM' }, + composedPath: () => [], + preventDefault() {}, + } as unknown as Event, + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + assertEquals(signals.length, 2); + assertEquals(signals[1] === signal1, false); + assertEquals(signals[1].aborted, false); + } finally { + app.dispose(); + env.restore(); + } +}); + +for (const operation of ['loader', 'action'] as const) { + Deno.test(`SPA aborts superseded ${operation} and ignores its late redirect`, async () => { + const hosts: Record[] = []; + let submit: ((event: Event) => void) | undefined; + let release!: () => void; + let signal: AbortSignal | undefined; + const pending = new Promise((resolve) => release = resolve); + const root = { + innerHTML: '', + addEventListener(type: string, handler: (event: Event) => void) { + if (type === 'submit') submit = handler; + }, + removeEventListener() {}, + appendChild(host: Record) { + hosts.push(host); + return host; + }, + }; + const env = stubNavigableEnvironment(root, '/'); + const delayed = async (context: { signal: AbortSignal }) => { + signal = context.signal; + await pending; + redirect('/stale'); + }; + const app = defineApp({ + mode: 'spa', + routerMode: 'history', + routes: [ + { path: '/', tagName: 'home-page', [operation]: delayed }, + { path: '/new', tagName: 'new-page', loader: () => Promise.resolve({ page: 'new' }) }, + { path: '/stale', tagName: 'stale-page', loader: () => Promise.resolve({ page: 'stale' }) }, + ], + }); + try { + app.mount('#app'); + await new Promise((resolve) => setTimeout(resolve, 0)); + if (operation === 'action') { + const event = new Event('submit', { cancelable: true }); + Object.defineProperty(event, 'target', { value: { tagName: 'FORM' } }); + submit!(event); + } + assertEquals(signal?.aborted, false); + await app.router!.navigate('/new'); + assertEquals(signal?.aborted, true); + release(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assertEquals(env.pushed, ['/new']); + assertEquals(app.router!.currentPath, '/new'); + assertEquals(hosts.at(-1)?.page, 'new'); + } finally { + app.dispose(); + env.restore(); + } + }); +} diff --git a/packages/app/__tests__/url-pattern-list.bench.ts b/packages/app/__tests__/url-pattern-list.bench.ts new file mode 100644 index 000000000..30e7618a2 --- /dev/null +++ b/packages/app/__tests__/url-pattern-list.bench.ts @@ -0,0 +1,98 @@ +/** + * Bounded diagnostic, not a ranking: deno run -A this-file. The qualified + * construction/hit/miss/memory evidence for #1324 lives in the maintained + * fork: open-element/url-pattern-list BENCHMARKS.md (Node, GC-controlled). + */ +import { RouteTable, URLPatternPolyfillConstructor } from '../src/internal/router/route-table.ts'; +import { URLPatternList } from '../src/internal/router/url-pattern-list/index.ts'; + +async function main(): Promise { + const base = '0d826954cb96b3a9306119830defd6000a798c95'; + const source = await new Deno.Command('git', { + args: ['show', `${base}:packages/app/src/internal/router/route-table.ts`], + stdout: 'piped', + }).output(); + if (!source.success) throw new Error('Baseline source unavailable'); + const baselineCode = new TextDecoder().decode(source.stdout) + .replace( + '@openelement/element/build-utils', + new URL('../src/internal/router/route-pattern.ts', import.meta.url).href, + ) + .replace("'urlpattern-polyfill'", "'npm:urlpattern-polyfill@10.1.0'"); + const file = await Deno.makeTempFile({ suffix: '.ts' }); + await Deno.writeTextFile(file, baselineCode); + try { + const { RouteTable: Baseline } = await import(new URL(`file://${file}`).href); + console.log( + JSON.stringify({ + deno: Deno.version, + baseline: base, + memory: 'NOT MEASURED: no reliable per-index isolation', + samples: 5, + lookupsPerSample: 100, + }), + ); + const median = (values: number[]) => + values.sort((a, b) => a - b)[Math.floor(values.length / 2)]; + for (const count of [100, 1000, 5000]) { + for (const conservative of [false, true]) { + const paths = Array.from({ length: count }, (_, i) => `/shared/catalog/${i}/details`); + if (conservative) paths.unshift('/shared/:path*'); + const records = paths.map((path) => ({ path })); + const build: number[] = []; + let entries: Array = []; + let list!: URLPatternList; + for (let sample = 0; sample < 5; sample++) { + const start = performance.now(); + entries = paths.map((pathname, i) => + [new URLPatternPolyfillConstructor({ pathname }), i] as const + ); + list = new URLPatternList(entries); + build.push(performance.now() - start); + } + const oldBuild = performance.now(); + const old = new Baseline(records, URLPatternPolyfillConstructor); + const oldBuildMs = performance.now() - oldBuild; + const current = new RouteTable(records, URLPatternPolyfillConstructor); + const linear = (url: URL) => entries.find(([pattern]) => pattern.exec(url.href)); + for ( + const path of [`/shared/catalog/${count - 1}/details`, '/shared/catalog/missing/details'] + ) { + const url = new URL(path, 'https://localhost'); + const times: Record = {}; + for ( + const [name, match] of Object.entries({ + original: () => old.match(path), + linear: () => linear(url), + ownedList: () => list.match(url), + table: () => current.match(url), + }) + ) { + match(); + const samples = []; + for (let sample = 0; sample < 5; sample++) { + const start = performance.now(); + for (let i = 0; i < 100; i++) match(); + samples.push((performance.now() - start) / 100); + } + times[name] = median(samples); + } + console.log( + JSON.stringify({ + count, + conservative, + path, + buildListMedianMs: median(build), + originalBuildSingleMs: oldBuildMs, + matchMedianMs: times, + }), + ); + } + } + } + } finally { + await Deno.remove(file); + } +} + +if (import.meta.main) await main(); diff --git a/packages/app/__tests__/url-pattern-list.test.ts b/packages/app/__tests__/url-pattern-list.test.ts new file mode 100644 index 000000000..83139ff08 --- /dev/null +++ b/packages/app/__tests__/url-pattern-list.test.ts @@ -0,0 +1,190 @@ +import { assertEquals, assertStrictEquals, assertThrows } from '@std/assert'; +import { URLPatternList } from '../src/internal/router/url-pattern-list/index.ts'; +import { URLPatternPolyfillConstructor } from '../src/internal/router/route-table.ts'; + +const constructors = [ + ['polyfill', URLPatternPolyfillConstructor], + ...('URLPattern' in globalThis ? [['native', globalThis.URLPattern] as const] : []), +] as const; + +for (const [name, Pattern] of constructors) { + Deno.test(`URLPatternList ${name}: complete results and identity against ordered oracle`, () => { + const patterns = [ + '/', + '/static', + '/:id', + '/a/:first/:second', + '/assets/:path*', + '/x/:id(\\d+)', + '/a{/:id}?', + '/a/:id+', + '/a/:id*', + '/a/\\:literal', + '/東京', + '/x/%2F', + '/shared/prefix/miss', + '/shared/prefix/:id', + '/shared/prefix/hit', + '*', + '/:__proto__/:constructor', + '/a//b', + '/a/', + '/a/:x([a-z]+)', + ].map((pathname) => new Pattern({ pathname })); + patterns.push(new Pattern({ pathname: '/static', search: '', hash: '', port: '' })); + patterns.push( + new Pattern({ + protocol: 'https', + hostname: ':sub.example.com', + pathname: '/:id', + search: 'q=:q', + hash: ':hash', + }), + ); + const inputs = [ + '/', + '/static', + '/a', + '/a/b', + '/a/b/c', + '/a//b', + '/a/', + '/a/:literal', + '/x/123', + '/x/abc', + '/assets', + '/assets/a/b', + '/東京', + '/x/%2F', + '/x/%252F', + '/x/%', + '/shared/prefix/hit', + '/shared/prefix/no', + '/missing?q=&q=2#h', + 'https://sub.example.com/a?q=1#h', + ]; + // Every pair, both orders, including duplicate patterns with distinct values. + for (const first of patterns) { + for (const second of patterns) { + const entries = [[first, {}], [second, {}]] as const; + const list = new URLPatternList(entries); + for (const input of inputs) { + const url = new URL(input, 'https://example.com'); + const expected = entries.map(([pattern, value]) => ({ + result: pattern.exec(url.href), + value, + })).find((r) => r.result); + const actual = list.match(input, 'https://example.com'); + assertEquals( + actual?.result ?? null, + expected?.result ?? null, + `${name}: ${first.pathname}, ${second.pathname}, ${input}`, + ); + assertStrictEquals(actual?.value, expected?.value); + assertEquals(list.match(url)?.result ?? null, expected?.result ?? null); + } + } + } + }); + + Deno.test(`URLPatternList ${name}: ignoreCase and empty URL components`, () => { + const CasePattern = Pattern as unknown as new ( + init: URLPatternInit, + options: { ignoreCase: boolean }, + ) => URLPattern; + const entries = [ + [new CasePattern({ pathname: '/Case' }, { ignoreCase: true }), 1], + [new Pattern({ pathname: '/case', search: '', hash: '', port: '' }), 2], + ] as const; + const list = new URLPatternList(entries); + for ( + const input of [ + 'https://example.com/case', + 'https://example.com/CASE?q=1', + 'http://example.com:80/case#', + 'http://example.com:81/case', + ] + ) { + const expected = entries.find(([p]) => p.exec(new URL(input).href)); + assertEquals(list.match(input)?.value, expected?.[1]); + assertEquals(list.match(input)?.result, expected?.[0].exec(new URL(input).href)); + } + }); + + Deno.test(`URLPatternList ${name}: seeded literal/conservative permutations`, () => { + const seed = 1324; + let state = seed; + const next = () => (state = (Math.imul(state, 1664525) + 1013904223) >>> 0); + for (let iteration = 0; iteration < 150; iteration++) { + const entries = Array.from({ length: 20 }, (_, id) => { + const pathname = next() % 3 === 0 ? '/shared/:id' : `/shared/${next() % 30}`; + return [new Pattern({ pathname }), id] as const; + }); + const input = `https://example.com/shared/${next() % 35}`; + const mismatch = (candidate: typeof entries) => { + const actual = new URLPatternList(candidate).match(input); + const expected = candidate.map(([pattern, value]) => ({ + result: pattern.exec(input), + value, + })) + .find((entry) => entry.result); + return actual?.value !== expected?.value || + JSON.stringify(actual?.result) !== JSON.stringify(expected?.result); + }; + // Deletion shrinking keeps the original input/seed and reduces the route + // sequence to a 1-minimal reproducer without changing record identities. + let minimal = entries; + if (mismatch(entries)) { + for (let index = 0; index < minimal.length;) { + const candidate = minimal.filter((_, i) => i !== index); + if (mismatch(candidate)) { + minimal = candidate; + index = 0; + } else index++; + } + } + assertEquals( + mismatch(minimal), + false, + `seed=${seed} iteration=${iteration} input=${input} patterns=${ + JSON.stringify(minimal.map(([p, value]) => ({ pathname: p.pathname, value }))) + }`, + ); + } + }); +} + +Deno.test('URLPatternList invalid URL boundary is consistent for empty and populated lists', () => { + for ( + const entries of [[], [[new URLPatternPolyfillConstructor({ pathname: '*' }), 1] as const]] + ) { + const list = new URLPatternList(entries); + assertThrows(() => list.match('/relative'), TypeError); + assertThrows(() => list.match('http://['), TypeError); + assertEquals(list.match('https://example.com')?.value ?? null, entries.length ? 1 : null); + } +}); + +Deno.test('admitted route patterns have consistent native/polyfill observable results', () => { + for ( + const pathname of [ + '/', + '/:id', + '/a/:x*', + '/a/:x(\\d+)', + '/a{/:x}?', + '/東京', + '/:__proto__', + '/a//b', + ] + ) { + for (const path of ['/', '/a', '/a/123', '/a/b/c', '/a//b', '/東京', '/%2F', '/%E0%A4%A']) { + const input = new URL(path, 'https://example.com').href; + assertEquals( + new URLPatternPolyfillConstructor({ pathname }).exec(input), + new URLPattern({ pathname }).exec(input), + `${pathname} ${input}`, + ); + } + } +}); diff --git a/packages/app/deno.json b/packages/app/deno.json index 78dc98007..a786961f8 100644 --- a/packages/app/deno.json +++ b/packages/app/deno.json @@ -3,6 +3,8 @@ "version": "0.44.0-beta.2", "exports": { ".": "./src/index.ts", + "./router/http": "./src/router-http.ts", + "./router": "./src/router.ts", "./model": "./src/model.ts", "./spa": "./src/spa.ts", "./i18n": "./src/i18n.ts", diff --git a/packages/app/src/authoring.ts b/packages/app/src/authoring.ts index 39587a7dd..854f713e8 100644 --- a/packages/app/src/authoring.ts +++ b/packages/app/src/authoring.ts @@ -36,7 +36,6 @@ type PageRenderingMode = 'static' | 'dynamic'; export type PageMeta = Record; interface PageRouteIntent { - path?: string; id?: string; params?: readonly string[]; } @@ -348,6 +347,11 @@ export function definePage< 'their Part Program — there is no render() function field (v0.44).', ); } + if (descriptor.route && Object.hasOwn(descriptor.route, 'path')) { + throw new Error( + `${ERROR_PREFIX} definePage route.path is not supported; the route file owns its URL path.`, + ); + } if (descriptor.props !== undefined && typeof descriptor.props !== 'function') { throw new Error(`${ERROR_PREFIX} definePage() props must be a projector function.`); } diff --git a/packages/app/src/internal/router/client-router.ts b/packages/app/src/internal/router/client-router.ts index baedd0d8e..c4d8ca96e 100644 --- a/packages/app/src/internal/router/client-router.ts +++ b/packages/app/src/internal/router/client-router.ts @@ -3,7 +3,7 @@ * * Supports history (pushState), hash, and auto-detection modes. * Alpha.9 authority: URLPattern owns pathname grammar and RouteTable owns - * declaration order, query merging, safe params, and static lookup. + * declaration order, separate query/captures, and HTTP policy. * * Alpha.9 removes client-local route grammars and compatibility matchers so * browser navigation and the other route consumers share one semantic owner. @@ -14,21 +14,22 @@ // frozen semantics — types clarified, runtime unchanged). import type { SpaActionContext, SpaLoaderContext } from '@openelement/element'; import { createLogger, ERROR_PREFIX } from '@openelement/element'; -import { RouteTable } from './route-table.ts'; +import { type RouteMatch, type RouteRecord, RouteTable } from './route-table.ts'; const log = createLogger('router'); export type RouterMode = 'history' | 'hash' | 'auto'; -export interface RouteConfig { - path: string; // e.g. '/products/:id' +export interface RouteConfig extends RouteRecord { /** Custom element tag to instantiate directly in SPA mode. */ tagName: string; /** Client-side loader — runs before component render. Receives matched route params. */ - loader?: (ctx: SpaLoaderContext) => Promise; + loader?: ( + ctx: SpaLoaderContext & { searchParams: URLSearchParams; signal: AbortSignal }, + ) => Promise; /** Client-side action — runs on form submit. Receives matched route params and form data. */ action?: ( - ctx: SpaActionContext, + ctx: SpaActionContext & { searchParams: URLSearchParams; signal: AbortSignal }, ) => Promise; guard?: () => Promise; } @@ -38,6 +39,8 @@ interface RouterOptions { routes: RouteConfig[]; /** Called after navigation or browser history/hash changes update the current match. */ onChange?: () => void | Promise; + /** Invalidate pending execution as soon as a newer navigation owns intent. */ + onPending?: () => void; } export interface RouterInstance { @@ -47,13 +50,14 @@ export interface RouterInstance { currentPath: string; currentRoute: RouteConfig | null; params: Record; + readonly searchParams: URLSearchParams; } const MAX_GUARD_REDIRECTS = 10; export type CompiledRouteMatcher = Pick< RouteTable, - 'match' | 'candidateCount' + 'match' | 'resolve' | 'candidateCount' >; // ─── Internal helpers ───────────────────────────────────────────── @@ -71,7 +75,7 @@ export function matchRoute( pathname: string, search: string, routes: RouteConfig[], -): { route: RouteConfig; params: Record } | null { +): RouteMatch | null { return matcherFor(routes).match(pathname, search); } @@ -100,6 +104,13 @@ export function createRouter(options: RouterOptions): RouterInstance { let currentPath = ''; let currentRoute: RouteConfig | null = null; let currentParams: Record = Object.create(null); + let currentSearchParams = new URLSearchParams(); + const checkedNavigation = Object.freeze({}); + /** One-shot URL of the router's own guard-veto restore (Navigation API path). */ + let pendingRestoreUrl: string | null = null; + const nativeNavigation = mode === 'history' && typeof navigation !== 'undefined' + ? navigation + : undefined; let disposed = false; /** Registered listeners keyed by event type, to support dispose. */ @@ -125,16 +136,20 @@ export function createRouter(options: RouterOptions): RouterInstance { return '#' + (path.startsWith('#') ? path.slice(1) : path); } - function rematch(): void { - const raw = readPath(); - const u = new URL(raw, 'http://x'); - const pathname = u.pathname; + function resolveTarget(url: URL): RouteMatch | null { + const resolution = routeMatcher.resolve(url); + return resolution.kind === 'match' ? resolution : null; + } + + function rematch(raw = readPath()): void { + const u = new URL(raw, location.href); const search = u.search; - const matched = routeMatcher.match(pathname, search); + const matched = resolveTarget(u); currentPath = raw; currentRoute = matched?.route ?? null; currentParams = matched?.params ?? Object.create(null); + currentSearchParams = matched?.searchParams ?? new URLSearchParams(search); } function notifyChange(): void { @@ -153,16 +168,29 @@ export function createRouter(options: RouterOptions): RouterInstance { /** * Restore the entry the user came from after a guard vetoed a - * browser-driven navigation. replaceState — not pushState (#1036): pushing - * left the vetoed entry sitting directly below the restored copy, so the - * next back re-landed on the vetoed URL and bounced again, trapping every - * earlier entry behind it (the user could never back out past the guard). - * Rewriting the vetoed entry instead collapses the dead stop, and - * replaceState does not fire popstate/hashchange, so restoring cannot - * re-enter commitBrowserNavigation. + * browser-driven navigation. The vetoed entry is rewritten, not pushed + * (#1036): pushing left the vetoed entry sitting directly below the + * restored copy, so the next back re-landed on the vetoed URL and bounced + * again, trapping every earlier entry behind the guard (the user could + * never back out past the guard). Under the Navigation API the rewrite + * goes through a marked replace navigation; elsewhere history.replaceState + * fires no popstate/hashchange. Neither re-enters commitBrowserNavigation + * for the router's own restore. */ function restoreBlockedEntry(): void { - history.replaceState(null, '', mode === 'hash' ? toHashUrl(currentPath) : currentPath); + const url = mode === 'hash' ? toHashUrl(currentPath) : currentPath; + if (nativeNavigation) { + // An intercepted traverse must be superseded by a real navigation to + // leave the vetoed URL. history.replaceState fires a navigate event + // (navigationType "replace") for it; the one-shot URL marker lets + // onNativeNavigate recognize that event as the router's own restore + // and intercept it without rematch/notify — an unintercepted replace + // races the in-flight traverse and does not land. + pendingRestoreUrl = url; + history.replaceState(null, '', url); + return; + } + history.replaceState(null, '', url); } async function commitNavigation( @@ -179,8 +207,13 @@ export function createRouter(options: RouterOptions): RouterInstance { } // Run guard if we have a matching target route - const u = new URL(path, 'http://x'); - const matched = routeMatcher.match(u.pathname, u.search); + const u = new URL(path, location.href); + if (mode === 'history' && u.origin !== new URL(location.href).origin) { + if (navOptions.replace) location.replace(u.href); + else location.assign(u.href); + return; + } + const matched = resolveTarget(u); if (matched?.route.guard) { const result = await matched.route.guard(); if (disposed) return; @@ -207,6 +240,13 @@ export function createRouter(options: RouterOptions): RouterInstance { if (disposed || (ticket !== undefined && ticket !== programmaticNavigationSeq)) return; const url = mode === 'hash' ? toHashUrl(path) : path; + if (nativeNavigation) { + await nativeNavigation.navigate(url, { + history: navOptions.replace ? 'replace' : 'push', + info: checkedNavigation, + }).finished; + return; + } if (navOptions.replace) { history.replaceState(null, '', url); } else { @@ -228,10 +268,12 @@ export function createRouter(options: RouterOptions): RouterInstance { let programmaticNavigationSeq = 0; function navigate(path: string): Promise { + options.onPending?.(); return commitNavigation(path, { replace: false }, ++programmaticNavigationSeq); } function replace(path: string): Promise { + options.onPending?.(); return commitNavigation(path, { replace: true }, ++programmaticNavigationSeq); } @@ -247,17 +289,19 @@ export function createRouter(options: RouterOptions): RouterInstance { // for what is effectively a single navigation. let lastLandedUrl: string | null = null; - async function commitBrowserNavigation(): Promise { + async function commitBrowserNavigation( + ticket = programmaticNavigationSeq, + landed = readPath(), + ): Promise { if (disposed) return; - const landed = readPath(); if (landed === lastLandedUrl) return; try { - const u = new URL(landed, 'http://x'); - const matched = routeMatcher.match(u.pathname, u.search); + const u = new URL(landed, location.href); + const matched = resolveTarget(u); if (matched?.route.guard) { const seqAtGuardStart = programmaticNavigationSeq; const result = await matched.route.guard(); - if (disposed) return; + if (disposed || ticket !== programmaticNavigationSeq) return; if (result === false) { // Blocked: restore the entry the user came from (see // restoreBlockedEntry for why this rewrites rather than pushes). @@ -279,13 +323,13 @@ export function createRouter(options: RouterOptions): RouterInstance { return; } } - if (disposed) return; - rematch(); + if (disposed || ticket !== programmaticNavigationSeq) return; + rematch(landed); notifyChange(); } finally { // Track the committed URL (restored on block, replaced on redirect) so // only bursts landing on the same URL are deduped, not genuine retries. - lastLandedUrl = readPath(); + if (ticket === programmaticNavigationSeq) lastLandedUrl = currentPath; } } @@ -295,15 +339,20 @@ export function createRouter(options: RouterOptions): RouterInstance { function onBrowserNavigation(): void { if (disposed) return; + options.onPending?.(); + const ticket = ++programmaticNavigationSeq; browserNavigationQueue = browserNavigationQueue - .then(commitBrowserNavigation) + .then(() => + ticket === programmaticNavigationSeq ? commitBrowserNavigation(ticket) : undefined + ) .catch((err) => { - if (disposed) return; + if (disposed || ticket !== programmaticNavigationSeq) return; // Intentional fail-open: a rejected guard or a router error must not // wedge the queue or leave the UI inconsistent with the address bar, // so we log and converge to the real URL instead of rethrowing. log.error('browser navigation failed:', err); rematch(); + lastLandedUrl = currentPath; notifyChange(); }); } @@ -318,11 +367,87 @@ export function createRouter(options: RouterOptions): RouterInstance { removeEventListener(type, handler); } listeners.length = 0; + nativeNavigation?.removeEventListener('navigate', onNativeNavigate); + } + + function onNativeNavigate(event: NavigateEvent): void { + // Ownership before side effects: our own programmatic navigations opt + // into SPA handling; browser-driven POST/fragment/reload default to the + // browser. No onPending, no ticket bump, no intercept for those. + const eventInfo = (event as NavigateEvent & { info?: unknown }).info; + if (pendingRestoreUrl !== null) { + // The navigate event fired by our own guard-veto replaceState: intercept + // so the vetoed traverse stays superseded, but never rematch/notify — + // the router state already describes the restored URL. A genuine + // navigation clears a stale marker via the URL/type match. + const navigationType = (event as NavigateEvent & { navigationType?: string }).navigationType; + const matches = eventInfo === undefined && navigationType === 'replace' && + new URL(event.destination.url).href === new URL(pendingRestoreUrl, location.href).href; + pendingRestoreUrl = null; + if (matches) { + event.intercept({ handler: () => {} }); + return; + } + } + const isOwn = eventInfo === checkedNavigation; + if (!isOwn) { + // Native POST forms carry formData: leave to browser/server unless an + // explicit action-navigation protocol claims them (none yet — the SPA + // submit handler owns in-page actions via preventDefault, so no + // navigate event fires there; this guard covers the rest). + const formData = (event as NavigateEvent & { formData?: FormData | null }).formData; + const eventMethod = (event as NavigateEvent & { method?: string }).method; + if (formData != null || eventMethod === 'POST') return; + // Reload defaults to the browser; app data refresh never poses as reload. + const navigationType = (event as NavigateEvent & { navigationType?: string }).navigationType; + if (navigationType === 'reload') return; + // Fragment-only in history mode: preserve native scroll, don't cancel + // unrelated loaders or run guard/loader/render. Hash-router semantics + // are separate (nativeNavigation is history-only). + try { + const probe = new URL(event.destination.url); + if ( + probe.origin === location.origin && + probe.pathname === location.pathname && + probe.search === location.search + ) return; + } catch { + // Malformed destination URL falls through to normal handling below. + } + } + const target = new URL(event.destination.url); + // Firefox can emit a follow-up navigate with downloadRequest=null for + // the same download anchor. Preserve the originating element's policy. + const downloadLink = event.sourceElement?.hasAttribute('download') ?? false; + if ( + !event.canIntercept || event.downloadRequest !== null || downloadLink || + target.origin !== location.origin || + !resolveTarget(target) + ) return; + options.onPending?.(); + const ticket = ++programmaticNavigationSeq; + event.signal.addEventListener('abort', () => { + if (ticket === programmaticNavigationSeq) programmaticNavigationSeq++; + }, { once: true }); + event.intercept({ + handler: async () => { + if (event.signal.aborted || ticket !== programmaticNavigationSeq) return; + if (event.info === checkedNavigation) { + lastLandedUrl = null; + rematch(target.pathname + target.search); + notifyChange(); + } else { + await commitBrowserNavigation(ticket, target.pathname + target.search); + } + }, + }); } // ─── Initialization ─────────────────────────────────────────── - if (mode === 'history') { + if (nativeNavigation) { + nativeNavigation.addEventListener('navigate', onNativeNavigate); + } else if (mode === 'history') { addCleanupListener('popstate', onBrowserNavigation); } else { addCleanupListener('hashchange', onBrowserNavigation); @@ -341,6 +466,9 @@ export function createRouter(options: RouterOptions): RouterInstance { get currentRoute(): RouteConfig | null { return currentRoute; }, + get searchParams(): URLSearchParams { + return currentSearchParams; + }, get params(): Record { return currentParams; }, diff --git a/packages/app/src/internal/router/route-pattern.ts b/packages/app/src/internal/router/route-pattern.ts new file mode 100644 index 000000000..96e4bcbc2 --- /dev/null +++ b/packages/app/src/internal/router/route-pattern.ts @@ -0,0 +1,11 @@ +/** Convert the framework's Hono-style route dialect to WHATWG URLPattern syntax. */ +export function normalizeRoutePatternForURLPattern(path: string): string { + return path + .split('/') + .map((segment) => { + const brace = segment.startsWith(':') ? segment.indexOf('{') : -1; + if (brace === -1 || !segment.endsWith('}')) return segment; + return `${segment.slice(0, brace)}(${segment.slice(brace + 1, -1)})`; + }) + .join('/'); +} diff --git a/packages/app/src/internal/router/route-table.ts b/packages/app/src/internal/router/route-table.ts index cc41dd8a8..6ac285c8b 100644 --- a/packages/app/src/internal/router/route-table.ts +++ b/packages/app/src/internal/router/route-table.ts @@ -1,23 +1,26 @@ -/** - * Thin internal RouteTable. URLPattern owns pathname grammar; this table owns - * records, declaration priority, static lookup, query merging, and safe params. - */ - -import { normalizeRoutePatternForURLPattern } from '@openelement/element/build-utils'; +/** Route identity, URL winner and HTTP policy; URLPatternList owns indexing. */ +import { normalizeRoutePatternForURLPattern } from './route-pattern.ts'; +import { URLPatternList } from './url-pattern-list/index.ts'; import { URLPattern as URLPatternPolyfill } from 'urlpattern-polyfill'; export interface RouteRecord { path: string; + id?: string; + /** Full URL component patterns for explicit Route Mode. */ + pattern?: URLPatternInit; methods?: readonly string[]; } export interface RouteMatch { route: T; + id: string; params: Record; + searchParams: URLSearchParams; + patternResult: URLPatternResult; } export type RouteResolution = - | ({ kind: 'match' } & RouteMatch) + | ({ kind: 'match'; method: string } & RouteMatch) | { kind: 'method-not-allowed'; allow: string[] } | { kind: 'not-found' }; @@ -26,35 +29,14 @@ export interface RouteTableOptions { trailingSlash?: 'strict' | 'ignore'; } -export interface URLPatternMatch { - pathname: { groups: Record }; -} - -export interface URLPatternLike { - exec(input: { protocol: string; hostname: string; pathname: string }): URLPatternMatch | null; -} - -export type URLPatternConstructor = new (init: { pathname: string }) => URLPatternLike; - -interface CompiledRecord { - index: number; - route: T; - pattern: URLPatternLike; - staticPath?: string; -} - -type ParamMap = Map; - -function runtimeURLPattern(): URLPatternConstructor { - return (globalThis.URLPattern ?? URLPatternPolyfill) as URLPatternConstructor; -} - -function isStaticPath(path: string): boolean { - return !/[:*?+(){}]/.test(path); -} +export type URLPatternConstructor = new (init: URLPatternInit) => URLPattern; +const runtimeURLPattern = (): URLPatternConstructor => + (globalThis.URLPattern ?? URLPatternPolyfill) as URLPatternConstructor; function staticPathKey(pathname: string): string { - return new URL(pathname, 'https://openelement.invalid').pathname; + const url = new URL('https://openelement.invalid'); + url.pathname = pathname; + return url.pathname; } function normalizeBasePath(basePath: string | undefined): string { @@ -77,10 +59,11 @@ function routePathname(pathname: string, options: RouteTableOptions): string | u } function methodsFor(route: RouteRecord): string[] { - const source = route.methods?.length ? route.methods : ['GET']; - const methods = [...new Set(source.map((method) => method.toUpperCase()))]; + const methods = [ + ...new Set((route.methods?.length ? route.methods : ['GET']).map((m) => m.toUpperCase())), + ]; if (methods.includes('GET') && !methods.includes('HEAD')) methods.push('HEAD'); - return methods; + return methods.sort(); } function decodeComponent(value: string, plusAsSpace = false): string { @@ -96,130 +79,84 @@ function isSafeParamName(name: string): boolean { return name !== '__proto__' && name !== 'prototype' && name !== 'constructor'; } -function setParam(target: ParamMap, name: string, value: string): void { - if (isSafeParamName(name)) target.set(name, value); -} - -function queryParams(search: string): ParamMap { - const result = new Map(); - const source = search.startsWith('?') ? search.slice(1) : search; - if (!source) return result; - for (const pair of source.split('&')) { - const separator = pair.indexOf('='); - const key = separator < 0 ? pair : pair.slice(0, separator); - const value = separator < 0 ? '' : pair.slice(separator + 1); - setParam(result, decodeComponent(key, true), decodeComponent(value, true)); - } - return result; -} - -function paramsRecord(...sources: ParamMap[]): Record { - const values = new Map(); - for (const source of sources) { - for (const [key, value] of source) values.set(key, value); - } - return new Proxy(Object.create(null), { - get: (_target, property) => typeof property === 'string' ? values.get(property) : undefined, - getOwnPropertyDescriptor: (_target, property) => - typeof property === 'string' && values.has(property) - ? { value: values.get(property), enumerable: true, configurable: true } - : undefined, - has: (_target, property) => typeof property === 'string' && values.has(property), - ownKeys: () => [...values.keys()], - }) as Record; -} - -function pathParams(pattern: URLPatternLike, pathname: string): ParamMap | null { - const match = pattern.exec({ protocol: 'https', hostname: 'localhost', pathname }); - if (!match) return null; - const params = new Map(); - for (const [name, value] of Object.entries(match.pathname.groups)) { - if (value === undefined || /^\d+$/.test(name)) continue; - setParam(params, name, decodeComponent(value)); - } - return params; -} - export class RouteTable { - readonly #static = new Map[]>(); - readonly #dynamic: CompiledRecord[] = []; + readonly #list: URLPatternList<{ route: T; id: string }>; + readonly routes: readonly T[]; + readonly options: RouteTableOptions; constructor( - readonly routes: readonly T[], + routes: readonly T[], Pattern: URLPatternConstructor = runtimeURLPattern(), - readonly options: RouteTableOptions = {}, + options: RouteTableOptions = {}, ) { - routes.forEach((route, index) => { - let pathname = normalizeRoutePatternForURLPattern(route.path); + this.options = Object.freeze({ ...options }); + this.routes = Object.freeze( + routes.map((route) => + Object.freeze({ + ...route, + ...(route.methods ? { methods: Object.freeze([...route.methods]) } : {}), + ...(route.pattern ? { pattern: Object.freeze({ ...route.pattern }) } : {}), + }) + ), + ); + const ids = new Set(); + this.#list = new URLPatternList(this.routes.map((route, index) => { + const id = route.id ?? String(index); + if (ids.has(id)) throw new TypeError(`Duplicate route identity: ${id}`); + ids.add(id); + let pathname = route.pattern?.pathname ?? normalizeRoutePatternForURLPattern(route.path); if (options.trailingSlash === 'ignore' && pathname.length > 1 && pathname.endsWith('/')) { pathname = pathname.slice(0, -1); } - const record: CompiledRecord = { - index, - route, - pattern: new Pattern({ pathname }), - ...(isStaticPath(pathname) ? { staticPath: staticPathKey(pathname) } : {}), - }; - if (record.staticPath === undefined) { - this.#dynamic.push(record); - } else { - const entries = this.#static.get(record.staticPath) ?? []; - entries.push(record); - this.#static.set(record.staticPath, entries); - } - }); + return [new Pattern({ ...route.pattern, pathname }), { route, id }] as const; + })); } - match(pathname: string, search = ''): RouteMatch | null { - const routedPath = routePathname(pathname, this.options); - if (routedPath === undefined) return null; - const staticWinner = this.#static.get(staticPathKey(routedPath))?.[0]; - const query = queryParams(search); - for (const record of this.#dynamic) { - if (staticWinner && record.index > staticWinner.index) break; - const path = pathParams(record.pattern, routedPath); - if (path) return { route: record.route, params: paramsRecord(query, path) }; - } - if (!staticWinner) return null; - return { route: staticWinner.route, params: paramsRecord(query) }; + #url(input: string | URL, search: string): URL | undefined { + const url = input instanceof URL ? new URL(input.href) : new URL(input, 'https://localhost'); + if (search) url.search = search; + const pathname = routePathname(url.pathname, this.options); + if (pathname === undefined) return undefined; + url.pathname = pathname; + return url; } - resolve(pathname: string, search = '', method = 'GET'): RouteResolution { - const routedPath = routePathname(pathname, this.options); - if (routedPath === undefined) return { kind: 'not-found' }; - const query = queryParams(search); - const candidates: Array<{ record: CompiledRecord; params: ParamMap }> = []; - for (const record of this.#static.get(staticPathKey(routedPath)) ?? []) { - candidates.push({ record, params: new Map() }); - } - for (const record of this.#dynamic) { - const params = pathParams(record.pattern, routedPath); - if (params) candidates.push({ record, params }); + match(input: string | URL, search = ''): RouteMatch | null { + const url = this.#url(input, search); + if (!url) return null; + const winner = this.#list.match(url); + if (!winner) return null; + const params: Record = Object.create(null); + for (const [name, value] of Object.entries(winner.result.pathname.groups)) { + if (value !== undefined && !/^\d+$/.test(name) && isSafeParamName(name)) { + params[name] = decodeComponent(value); + } } - candidates.sort((left, right) => left.record.index - right.record.index); - if (candidates.length === 0) return { kind: 'not-found' }; + return { + ...winner.value, + params, + searchParams: url.searchParams, + patternResult: winner.result, + }; + } + resolve(input: string | URL, search = '', method = 'GET'): RouteResolution { + const winner = this.match(input, search); + if (!winner) return { kind: 'not-found' }; const requested = method.toUpperCase(); - for (const candidate of candidates) { - if (methodsFor(candidate.record.route).includes(requested)) { - return { - kind: 'match', - route: candidate.record.route, - params: paramsRecord(query, candidate.params), - }; - } - } - const allow = [...new Set(candidates.flatMap(({ record }) => methodsFor(record.route)))].sort(); - return { kind: 'method-not-allowed', allow }; + const allow = methodsFor(winner.route); + if (!allow.includes(requested)) return { kind: 'method-not-allowed', allow }; + const explicit = winner.route.methods?.map((m) => m.toUpperCase()) ?? ['GET']; + return { + kind: 'match', + ...winner, + method: requested === 'HEAD' && !explicit.includes('HEAD') ? 'GET' : requested, + }; } - /** Number of records URLPattern/static lookup may inspect for this path. */ - candidateCount(pathname: string): number { - const routedPath = routePathname(pathname, this.options); - if (routedPath === undefined) return 0; - const staticWinner = this.#static.get(staticPathKey(routedPath))?.[0]; - if (!staticWinner) return this.#dynamic.length; - return 1 + this.#dynamic.filter((record) => record.index < staticWinner.index).length; + candidateCount(input: string | URL): number { + const url = this.#url(input, ''); + return url ? this.#list.candidateCount(url) : 0; } } diff --git a/packages/app/src/internal/router/url-pattern-list/LICENSE b/packages/app/src/internal/router/url-pattern-list/LICENSE new file mode 100644 index 000000000..cffe7798b --- /dev/null +++ b/packages/app/src/internal/router/url-pattern-list/LICENSE @@ -0,0 +1,7 @@ +Copyright 2025 Justin Fagnani + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/packages/app/src/internal/router/url-pattern-list/PROVENANCE.md b/packages/app/src/internal/router/url-pattern-list/PROVENANCE.md new file mode 100644 index 000000000..2b57bb433 --- /dev/null +++ b/packages/app/src/internal/router/url-pattern-list/PROVENANCE.md @@ -0,0 +1,33 @@ +# Owned URLPatternList + +Source: [justinfagnani/url-pattern-list v0.5.0](https://github.com/justinfagnani/url-pattern-list/tree/4911e649cc11860c7da90c9d0d9b05626c5cbb83), +commit `4911e649cc11860c7da90c9d0d9b05626c5cbb83`. The commit's package.json +identifies version 0.5.0; its MIT license is preserved verbatim alongside this file. + +OE derives the sequence-bearing item, fixed prefix tree and value/result contract +from src/index.ts. The original component parser and wildcard/regex traversal are +removed: their pruning needs more proof than final exec supplies, and empty URL +components must not disappear from matching. No upstream release/visualizer tooling +or global API is copied. This private module is maintained by OpenElement. + +Only canonical pathname strings in a literal ASCII alphabet enter the fixed tree. +ASCII case folding is an over-approximation; exec still determines case semantics. +Every other legal pattern remains in the conservative collection. Both collections +are merged by original sequence before exec. Other URL components are never pruned. +Duplicate values/patterns keep their order. The table is an immutable snapshot. + +Inputs follow upstream's URL/string boundary: relative strings require a baseURL; +invalid URL input throws TypeError, including for an empty list. Both the index and +exec consume the same normalized URL. Pattern construction and captures belong to +the supplied native/polyfill constructor. No URLPatternInit match-input API is claimed. + +Tests: packages/app/**tests**/url-pattern-list.test.ts compares full results and value +identity with each constructor's linear oracle. This is bounded corpus evidence, +not a claim of complete URLPattern standard or runtime qualification. + +Maintenance home: [open-element/url-pattern-list](https://github.com/open-element/url-pattern-list) +(public fork, own Node tests, differential oracle coverage and the +construction/hit/miss/memory benchmarks in its BENCHMARKS.md). This embedded +copy retires when OE consumes the published fork package with a lockfile; +the npm publish that unblocks the switch is a separately authorized step +(#1324), so this copy remains the production matcher until then. diff --git a/packages/app/src/internal/router/url-pattern-list/index.ts b/packages/app/src/internal/router/url-pattern-list/index.ts new file mode 100644 index 000000000..d5cf99a0b --- /dev/null +++ b/packages/app/src/internal/router/url-pattern-list/index.ts @@ -0,0 +1,101 @@ +/** + * Derived from url-pattern-list 0.5.0, Copyright 2025 Justin Fagnani (MIT). + * See LICENSE and PROVENANCE.md. URLPattern owns all matching and captures. + */ +export interface ListPattern { + readonly pathname: string; + exec(input: string): URLPatternResult | null; +} + +interface URLPatternListItem { + readonly sequence: number; + readonly pattern: ListPattern; + readonly value: T; +} + +export interface URLPatternListMatch { + result: URLPatternResult; + value: T; +} + +/** Fixed-prefix nodes only; unproven grammars never enter the tree. */ +class FixedPrefixTreeNode { + readonly children = new Map>(); + readonly patterns: URLPatternListItem[] = []; +} + +/** + * A deliberately small literal alphabet, not a URLPattern grammar parser. + * These characters have no pattern operators/escapes. Every other spelling + * remains conservative, including groups, regex, empty paths and Unicode. + * ASCII folding over-selects for case-sensitive patterns and admits ignoreCase + * patterns without relying on a non-standard URLPattern options getter. + */ +function literalPath(path: string): string | undefined { + if (!path.startsWith('/')) return undefined; + for (const char of path) { + if (!'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/_-.%~'.includes(char)) { + return undefined; + } + } + return path.toLowerCase(); +} + +/** Immutable ordered snapshot. Input is a URL or URL string (relative with base). */ +export class URLPatternList { + readonly #root = new FixedPrefixTreeNode(); + readonly #conservative: URLPatternListItem[] = []; + + constructor(patterns: Iterable) { + let sequence = 0; + for (const [pattern, value] of patterns) { + const item = { sequence: sequence++, pattern, value }; + const key = literalPath(pattern.pathname); + if (key === undefined) { + this.#conservative.push(item); + continue; + } + let node = this.#root; + for (const char of key) { + let child = node.children.get(char); + if (!child) node.children.set(char, child = new FixedPrefixTreeNode()); + node = child; + } + node.patterns.push(item); + } + } + + #fixed(url: URL): readonly URLPatternListItem[] { + let node: FixedPrefixTreeNode | undefined = this.#root; + for (const char of url.pathname.toLowerCase()) { + node = node.children.get(char); + if (!node) return []; + } + return node.patterns; + } + + match(input: string | URL, baseURL?: string): URLPatternListMatch | null { + // One URL normalization boundary for both pruning and exec; invalid input + // throws TypeError even for an empty list. Do not silently turn it into 404. + const url = new URL(String(input), baseURL); + const fullURL = url.href; + const fixed = this.#fixed(url); + let fast = 0; + let slow = 0; + while (fast < fixed.length || slow < this.#conservative.length) { + const a = fixed[fast]; + const b = this.#conservative[slow]; + const item = a && (!b || a.sequence < b.sequence) + ? fixed[fast++] + : this.#conservative[slow++]; + const result = item.pattern.exec(fullURL); + if (result) return { result, value: item.value }; + } + return null; + } + + /** Diagnostic upper bound; no matcher internals are exposed. */ + candidateCount(input: string | URL, baseURL?: string): number { + return this.#fixed(new URL(String(input), baseURL)).length + this.#conservative.length; + } +} diff --git a/packages/app/src/router-http.ts b/packages/app/src/router-http.ts new file mode 100644 index 000000000..693f05bcf --- /dev/null +++ b/packages/app/src/router-http.ts @@ -0,0 +1,58 @@ +/** Hono integration for Route Mode; the pure matching entry is ./router. */ +import type { Context, Handler, MiddlewareHandler } from 'hono'; +import { every } from 'hono/combine'; +import { type RouteRecord, RouteTable, type RouteTableOptions } from './router.ts'; + +export interface HttpRouteRecord extends Omit { + handlers: Readonly>; +} + +/** Mount after host middleware/routes; unmatched URLs continue to the host. */ +export function createRouteMiddleware( + records: readonly HttpRouteRecord[], + options: RouteTableOptions & { + methodNotAllowed?: (context: Context, allow: readonly string[]) => Response | Promise; + } = {}, +): MiddlewareHandler { + const routes = records.map(({ handlers, ...record }) => { + const methods: string[] = []; + const dispatch = new Map(); + for (const [name, handler] of Object.entries(handlers)) { + const method = name.toUpperCase(); + if (dispatch.has(method)) { + throw new TypeError(`Duplicate ${method} handler for ${record.id ?? record.path}`); + } + if (!/^[!#$%&'*+.^_`|~0-9A-Z-]+$/.test(method)) { + throw new TypeError(`Invalid HTTP method: ${name}`); + } + methods.push(method); + dispatch.set(method, typeof handler === 'function' ? [handler] : [...handler]); + if (!dispatch.get(method)?.length) { + throw new TypeError(`Empty ${method} handler for ${record.path}`); + } + } + if (!methods.length) throw new TypeError(`No handlers for ${record.path}`); + return { ...record, methods, dispatch }; + }); + const table = new RouteTable(routes, undefined, options); + return async (c, next) => { + const resolution = table.resolve(new URL(c.req.url), '', c.req.method); + if (resolution.kind === 'not-found') return next(); + if (resolution.kind === 'method-not-allowed') { + if (options.methodNotAllowed) return options.methodNotAllowed(c, resolution.allow); + return c.text('Method Not Allowed', 405, { Allow: resolution.allow.join(', ') }); + } + c.set('routeResolution', resolution); + const handlers = resolution.route.dispatch.get(resolution.method)!; + // Hono executes middleware and Response semantics; it never rematches a path. + await every(...handlers)(c, next); + if (c.req.method === 'HEAD') { + c.res = new Response(null, { + status: c.res.status, + statusText: c.res.statusText, + headers: c.res.headers, + }); + } + return c.res; + }; +} diff --git a/packages/app/src/router.ts b/packages/app/src/router.ts new file mode 100644 index 000000000..4a1d0b0c1 --- /dev/null +++ b/packages/app/src/router.ts @@ -0,0 +1,9 @@ +/** Pure Route Mode matching entry: no Element, host or build dependencies. */ +export { RouteTable } from './internal/router/route-table.ts'; +export type { + RouteMatch, + RouteRecord, + RouteResolution, + RouteTableOptions, +} from './internal/router/route-table.ts'; +export { normalizeRoutePatternForURLPattern } from './internal/router/route-pattern.ts'; diff --git a/packages/app/src/spa.ts b/packages/app/src/spa.ts index 4a58b3740..187382f87 100644 --- a/packages/app/src/spa.ts +++ b/packages/app/src/spa.ts @@ -102,6 +102,12 @@ export function defineApp(options: SpaAppOptions): SpaAppInstance { let rootEl: Element | null = null; let submitHandler: ((e: Event) => void) | null = null; let renderId = 0; + let execution = new AbortController(); + function cancelPending(): void { + execution.abort(); + execution = new AbortController(); + renderId++; + } let currentLoaderData: unknown; let currentLoaderError: unknown; let currentActionData: unknown; @@ -122,16 +128,24 @@ export function defineApp(options: SpaAppOptions): SpaAppInstance { * channel with the original error so the error projector can read its 404 * status/message — mirroring the server chain. */ - async function runLoader(): Promise<{ data: unknown; error?: unknown; redirected?: boolean }> { + async function runLoader( + ticket = renderId, + ): Promise<{ data: unknown; error?: unknown; redirected?: boolean }> { if (!router) return { data: undefined }; const route = router.currentRoute; if (!route?.loader) return { data: undefined }; try { - return { data: await route.loader({ params: router.params }) }; + return { + data: await route.loader({ + params: router.params, + searchParams: router.searchParams, + signal: execution.signal, + }), + }; } catch (err) { if (isOpenElementRedirect(err)) { // Skip navigation if the app was disposed while the loader awaited. - if (router) await router.navigate(err.location); + if (router && ticket === renderId) await router.navigate(err.location); return { data: undefined, redirected: true }; } if (isOpenElementNotFound(err)) { @@ -255,6 +269,8 @@ export function defineApp(options: SpaAppOptions): SpaAppInstance { const outcome = classifyActionResult( await route.action({ params: router.params, + searchParams: router.searchParams, + signal: execution.signal, formData: createFormData(form), }), ); @@ -265,7 +281,7 @@ export function defineApp(options: SpaAppOptions): SpaAppInstance { if (isOpenElementRedirect(err)) { // PRG: navigate to the redirect target; its own render cycle renders // the destination. Skip if the app was disposed while awaiting. - if (router) await router.navigate(err.location); + if (router && currentRender === renderId) await router.navigate(err.location); return; } if (isOpenElementNotFound(err)) { @@ -280,6 +296,7 @@ export function defineApp(options: SpaAppOptions): SpaAppInstance { actionData = normalizeActionFailure(err, development, log.error); } + if (currentRender !== renderId || !router || !rootEl) return; // Re-run loader for fresh data const { data: loaderData, error: loaderError, redirected } = await runLoader(); if (currentRender !== renderId || !router || !rootEl) return; @@ -300,6 +317,10 @@ export function defineApp(options: SpaAppOptions): SpaAppInstance { if (router) { dispose(); } + // Each mount gets a fresh execution controller: the previous mount's + // signal stays aborted (stale loaders/actions must not commit) while the + // new mount's initial loader/action runs un-aborted. + execution = new AbortController(); renderId++; const el = document.querySelector(selector); @@ -311,6 +332,7 @@ export function defineApp(options: SpaAppOptions): SpaAppInstance { router = createRouter({ mode: options.routerMode ?? 'auto', routes: options.routes ?? [], + onPending: cancelPending, onChange: renderRoute, }); @@ -329,6 +351,7 @@ export function defineApp(options: SpaAppOptions): SpaAppInstance { } function dispose(): void { + execution.abort(); // Remove form submit listener renderId++; if (submitHandler && rootEl) { diff --git a/packages/element/__tests__/html-route-utils.test.ts b/packages/element/__tests__/html-route-utils.test.ts index d19d09fec..923f26ccc 100644 --- a/packages/element/__tests__/html-route-utils.test.ts +++ b/packages/element/__tests__/html-route-utils.test.ts @@ -1,17 +1,5 @@ import { assertEquals, assertStringIncludes } from '@std/assert'; -import { insertBeforeBodyClose, normalizeRoutePatternForURLPattern } from '../src/build-utils.ts'; - -Deno.test('shared route normalizer preserves params and converts Hono catch-alls (#1103)', () => { - assertEquals(normalizeRoutePatternForURLPattern('/item/:id'), '/item/:id'); - assertEquals( - normalizeRoutePatternForURLPattern('/docs/:path{.+}'), - '/docs/:path(.+)', - ); - assertEquals( - normalizeRoutePatternForURLPattern('/org/:org/repo/:path{.*}'), - '/org/:org/repo/:path(.*)', - ); -}); +import { insertBeforeBodyClose } from '../src/build-utils.ts'; Deno.test('shared body injector handles tolerant close tags and missing body (#1103)', () => { const tag = ''; diff --git a/packages/element/src/build-utils.ts b/packages/element/src/build-utils.ts index 42692d9f7..62a4c0ca9 100644 --- a/packages/element/src/build-utils.ts +++ b/packages/element/src/build-utils.ts @@ -13,9 +13,6 @@ export { formatJson } from './public-build-runtime.ts'; export { normalizeSeparators, pathToTagName } from './public-build-runtime.ts'; export { SsrRenderError } from './public-build-runtime.ts'; export { transformIslandSource } from './public-build-runtime.ts'; -export { - insertBeforeBodyClose, - normalizeRoutePatternForURLPattern, -} from './public-build-runtime.ts'; +export { insertBeforeBodyClose } from './public-build-runtime.ts'; export type { OpenElementRequestHandler, RuntimeContext } from './public-build-runtime.ts'; export { composeFetchMiddleware, createRuntimeAdapter } from './public-build-runtime.ts'; diff --git a/packages/element/src/internal/core/html-route-utils.ts b/packages/element/src/internal/core/html-route-utils.ts index 0a6c40c17..679061eb0 100644 --- a/packages/element/src/internal/core/html-route-utils.ts +++ b/packages/element/src/internal/core/html-route-utils.ts @@ -1,15 +1,3 @@ -/** Convert the framework's Hono-style route dialect to WHATWG URLPattern syntax. */ -export function normalizeRoutePatternForURLPattern(path: string): string { - return path - .split('/') - .map((segment) => { - const brace = segment.startsWith(':') ? segment.indexOf('{') : -1; - if (brace === -1 || !segment.endsWith('}')) return segment; - return `${segment.slice(0, brace)}(${segment.slice(brace + 1, -1)})`; - }) - .join('/'); -} - /** Insert markup before a tolerant HTML body close (``, case-insensitive). */ export function insertBeforeBodyClose(html: string, content: string): string { const match = /<\/body\s*>/i.exec(html); diff --git a/packages/element/src/public-build-runtime.ts b/packages/element/src/public-build-runtime.ts index 39fb87062..e13481fa6 100644 --- a/packages/element/src/public-build-runtime.ts +++ b/packages/element/src/public-build-runtime.ts @@ -3,9 +3,6 @@ export { formatJson } from './internal/core/write-json.ts'; export { normalizeSeparators, pathToTagName } from './internal/core/path-utils.ts'; export { SsrRenderError } from './internal/core/errors.ts'; export { transformIslandSource } from './internal/core/island-transform.ts'; -export { - insertBeforeBodyClose, - normalizeRoutePatternForURLPattern, -} from './internal/core/html-route-utils.ts'; +export { insertBeforeBodyClose } from './internal/core/html-route-utils.ts'; export type { OpenElementRequestHandler, RuntimeContext } from './internal/core/runtime.ts'; export { composeFetchMiddleware, createRuntimeAdapter } from './internal/core/runtime.ts'; diff --git a/packages/ui/README.md b/packages/ui/README.md index f0717aef2..96944dba1 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -1,6 +1,11 @@ # @openelement/ui -First-party reference UI package for the OpenElement framework. +First-party dogfood and reference UI package for OpenElement. + +Element and Router are the two core products; Router includes Framework Mode. +UI demonstrates and tests those products rather than defining a third core product +or a comprehensive design-system roadmap. The existing package remains available; +this positioning does not remove components or force migration to another library. The components are first-party `open-*` Web Components. They are designed to prove the OpenElement authoring model with shadow/DSD output, explicit light DOM diff --git a/tools/autoflow/__tests__/release.test.ts b/tools/autoflow/__tests__/release.test.ts index f3b9854da..219880f11 100644 --- a/tools/autoflow/__tests__/release.test.ts +++ b/tools/autoflow/__tests__/release.test.ts @@ -25,6 +25,7 @@ import { type ReleaseEvidence, renderClosureSection, renderReleaseNote, + resolvePatchTargetVersion, resumeEvidenceFromPrior, verifyMainCiSuccessForHead, verifyPrepareRecord, @@ -358,7 +359,7 @@ Deno.test('advancePrepareReleaseStateText: prepare advances the planning anchors // check-release-truth pins the planning pair to ACTIVE/NEXT_EXECUTION_VERSION, // which the bump advances; prepare must move them with the bump. assertEquals(state.activeTarget, 'v0.44.0-beta.2'); - assertEquals(state.nextPlannedTrain, 'v0.44.0-beta.3'); + assertEquals(state.nextPlannedTrain, 'v0.44.0-beta.2.1'); // The published-line fields stay finalize-owned // (advancePublishedReleaseStateText): prepare leaves the prepare-window lag. assertEquals(state.sourceVersion, '0.44.0-beta.1'); @@ -1541,3 +1542,24 @@ Deno.test('foldStarterLockfileIntoBumpCommit: a clean lockfile is a no-op (#1083 }, ); }); + +Deno.test('Beta checkpoint prepare, public-stage planning and retry preserve the source/published window', () => { + const published = { + schemaVersion: 1, + sourceVersion: '0.44.0-beta.2', + publishedVersion: '0.44.0-beta.2', + latestLandedTrain: 'v0.44.0-beta.2', + activeTarget: 'v0.44.0-beta.2.1', + nextPlannedTrain: 'v0.44.0-beta.2.1', + maturity: 'beta', + }; + const prepared = advancePrepareReleaseStateText(JSON.stringify(published), '0.44.0-beta.2.1'); + assertEquals(advancePrepareReleaseStateText(prepared, '0.44.0-beta.2.1'), prepared); + assertEquals(JSON.parse(prepared).publishedVersion, published.publishedVersion); + assertEquals(JSON.parse(prepared).nextPlannedTrain, 'v0.44.0-beta.2.2'); + assertEquals(nextPrereleaseTag('0.44.0-beta.2.3'), 'v1.0.0-alpha.1'); + assertEquals( + resolvePatchTargetVersion('0.44.0-beta.2.1', { kind: 'patch-release', status: 'failed' }), + { targetVersion: '0.44.0-beta.2.1', resumed: true }, + ); +}); diff --git a/tools/autoflow/release.ts b/tools/autoflow/release.ts index 3f1f4a3ff..e9efcc510 100644 --- a/tools/autoflow/release.ts +++ b/tools/autoflow/release.ts @@ -1,5 +1,6 @@ import { AUTOFLOW3_POLICY_VERSION, isCI } from './policy.ts'; import { + assertPublicReleaseVersion, compareVersions as compareLineVersions, FIRST_TAGGED_VERSION, nextPatchVersion as nextLinePatchVersion, @@ -143,6 +144,7 @@ export function createReleaseEvidence( targetVersion: string, approvalId?: string, ): ReleaseEvidence { + assertPublicReleaseVersion(targetVersion); const now = new Date().toISOString(); return { id: `${kind}-${releaseTag(targetVersion)}-${now.replace(/[:.]/g, '-')}`, diff --git a/tools/autoflow/version-anchors.ts b/tools/autoflow/version-anchors.ts index 7e205ff49..923927151 100644 --- a/tools/autoflow/version-anchors.ts +++ b/tools/autoflow/version-anchors.ts @@ -16,7 +16,12 @@ import { PREVIOUS_PACKAGE_VERSION, PREVIOUS_PACKAGE_VERSION_TAG, } from '../project-constants.ts'; -import { prereleaseChannel, prereleaseParts } from '../lib/version.ts'; +import { + nextPatchVersion, + nextProductStageVersion, + prereleaseChannel, + prereleaseParts, +} from '../lib/version.ts'; export function releaseTag(version: string): string { return `v${version}`; @@ -107,7 +112,9 @@ export async function updatePrepareReleaseState(version: string): Promise export function nextPrereleaseTag(version: string): string { const parts = prereleaseParts(version); if (!parts) return releaseTag(version); - return `v${parts.base}-${parts.name}.${parts.num + 1}`; + return releaseTag( + version === '0.44.0-beta.2.3' ? nextProductStageVersion(version) : nextPatchVersion(version), + ); } /** diff --git a/tools/bump-version.test.ts b/tools/bump-version.test.ts index a0b8ed127..aac843676 100644 --- a/tools/bump-version.test.ts +++ b/tools/bump-version.test.ts @@ -13,6 +13,7 @@ Deno.test('parseVersion parses stable and prerelease versions', () => { minor: 41, patch: 0, prerelease: 'alpha', + identifiers: ['alpha', '7'], prereleaseNumber: 7, }); }); diff --git a/tools/bump-version.ts b/tools/bump-version.ts index 577cd1fc5..9a1a9a33c 100644 --- a/tools/bump-version.ts +++ b/tools/bump-version.ts @@ -30,7 +30,7 @@ interface PackageJson { [key: string]: unknown; } -import { type LineVersion, parseLineVersion } from './lib/version.ts'; +import { compareVersions, type LineVersion, parseLineVersion } from './lib/version.ts'; import { getArg } from './lib/process.ts'; // Canonical prerelease/version truth lives in tools/lib/version.ts (#1231 @@ -40,37 +40,17 @@ export type ParsedVersion = LineVersion; export const parseVersion = parseLineVersion; -const PRERELEASE_RANK: Record = { - alpha: 1, - beta: 2, - rc: 3, -}; - export function validateVersionStep(fromVersion: string, toVersion: string): void { - const from = parseVersion(fromVersion); - const to = parseVersion(toVersion); - - const fromBase = from.major * 1_000_000 + from.minor * 1_000 + from.patch; - const toBase = to.major * 1_000_000 + to.minor * 1_000 + to.patch; - if (toBase < fromBase) { + if (compareVersions(toVersion, fromVersion) < 0) { + const from = parseVersion(fromVersion); + const to = parseVersion(toVersion); + const sameBase = from.major === to.major && from.minor === to.minor && from.patch === to.patch; throw new Error( - `Version step regresses the release base: ${fromVersion} → ${toVersion}`, + sameBase + ? `Prerelease step regresses: ${fromVersion} → ${toVersion}` + : `Version step regresses the release base: ${fromVersion} → ${toVersion}`, ); } - - // Same-base prerelease steps must not move backwards (e.g. beta → alpha). - if (toBase === fromBase && from.prerelease && to.prerelease) { - const fromRank = PRERELEASE_RANK[from.prerelease] ?? 99; - const toRank = PRERELEASE_RANK[to.prerelease] ?? 99; - if ( - toRank < fromRank || - (toRank === fromRank && to.prereleaseNumber < from.prereleaseNumber) - ) { - throw new Error( - `Prerelease step regresses: ${fromVersion} → ${toVersion}`, - ); - } - } } function findPackageDenos(root: string): string[] { diff --git a/tools/check-critical-path-tests.ts b/tools/check-critical-path-tests.ts index 42de3cd71..cb40642ce 100644 --- a/tools/check-critical-path-tests.ts +++ b/tools/check-critical-path-tests.ts @@ -190,7 +190,7 @@ const suites: Suite[] = [ 'dispose removes event listeners', 'double dispose is safe', 'redirect limit rejects redirect loops', - 'decodes path parameters', + 'decoded path parameters', ], }, { diff --git a/tools/check-docs-truth-www.test.ts b/tools/check-docs-truth-www.test.ts index 0cdd46fcc..64dd10054 100644 --- a/tools/check-docs-truth-www.test.ts +++ b/tools/check-docs-truth-www.test.ts @@ -88,3 +88,31 @@ Deno.test('www-truth: current version is not flagged (bare, v-prefixed, short)', assertEquals(code, 0); }); }); + +Deno.test('www-truth: approved planned chain versions are not flagged (ADR-0152)', async () => { + // The roadmap page lists the approved forward chain as planned entries: + // beta.2.1…beta.2.3 and the admitted product-stage successor 1.0.0-alpha.1. + // Planned future releases are not retired current claims. + await withFixture( + 'export const x = "planned: v0.44.0-beta.2.1, v0.44.0-beta.2.2, v0.44.0-beta.2.3, v1.0.0-alpha.1";\n', + ({ code, stderr }) => { + assertEquals(code, 0, stderr); + }, + ); +}); + +Deno.test('www-truth: unapproved future prerelease is flagged', async () => { + // beta.3 is not on the approved chain (no automatic Beta.2.4-style drift). + await withFixture('export const x = "v0.44.0-beta.3";\n', ({ code, stderr }) => { + assertEquals(code, 1); + assert(stderr.includes('retired prerelease current claim')); + }); +}); + +Deno.test('www-truth: historical internal alpha workspace is flagged', async () => { + // 0.44.0-alpha.7 is a retired internal workspace, never a public plan entry. + await withFixture('export const x = "v0.44.0-alpha.7";\n', ({ code, stderr }) => { + assertEquals(code, 1); + assert(stderr.includes('retired prerelease current claim')); + }); +}); diff --git a/tools/check-docs-truth.ts b/tools/check-docs-truth.ts index fec50380c..aebb52ba2 100644 --- a/tools/check-docs-truth.ts +++ b/tools/check-docs-truth.ts @@ -32,6 +32,8 @@ import { roadmapEntryTheme } from './autoflow/release.ts'; import { compareVersions, FIRST_TAGGED_VERSION, + nextCheckpointVersion, + nextProductStageVersion, PRERELEASE_CHANNELS, prereleaseSequence, } from './lib/version.ts'; @@ -395,13 +397,40 @@ const escapedRegistryVersion = PREVIOUS_PACKAGE_VERSION.replaceAll('.', '\\.'); const currentAlphaNumber = prereleaseSequence(PACKAGE_VERSION, 'alpha'); const registryAlphaNumber = prereleaseSequence(PREVIOUS_PACKAGE_VERSION, 'alpha'); const allowedAlphaNumbers = [currentAlphaNumber, registryAlphaNumber].filter(Boolean).join('|'); +// The approved forward chain (ADR-0152 checkpoints + admitted product-stage +// successor, owned by lib/version.ts) names planned future releases, not +// retired claims: exempt exactly those versions; unapproved prereleases +// (e.g. 0.44.0-beta.3, historical 0.44.0-alpha.N) stay forbidden. +function plannedChainVersions(from: string): string[] { + const planned: string[] = []; + let cursor = from; + for (;;) { + try { + cursor = nextCheckpointVersion(cursor); + } catch { + break; + } + planned.push(cursor); + } + try { + planned.push(nextProductStageVersion(cursor)); + } catch { + // No admitted product-stage successor on this line. + } + return planned; +} +const escapedPlannedChain = plannedChainVersions(PACKAGE_VERSION) + .map((version) => `(?!v?${version.replaceAll('.', '\\.')}(?!\\d))`) + .join(''); const retiredFullForm = - `(?!v?${escapedCurrentVersion}(?!\\d))(?!v?${escapedRegistryVersion}(?!\\d))v?\\d+\\.\\d+\\.\\d+-(?:${ + `(?!v?${escapedCurrentVersion}(?!\\d))(?!v?${escapedRegistryVersion}(?!\\d))${escapedPlannedChain}v?\\d+\\.\\d+\\.\\d+-(?:${ PRERELEASE_CHANNELS.join('|') })\\.\\d+(?!\\d)`; +// `(? { +Deno.test('fullstack-boundary: sliceRouteHandlers slices the generated page-handler table', () => { const source = [ - " * app.post('/entry', async (c) => {", - 'app.get("/notes", async (c) => {', + 'const __pageHandlers = {};', + '// __pageHandlers["/entry"].GET mentioned without assignment must not match', + '__pageHandlers["/notes"].GET = [async (c) => {', '\tc.header("Cache-Control", "no-store");', - '});', - 'app.post("/login", bodyLimit({', + '}];', + '__pageHandlers["/login"].POST = [__bodyLimit({ maxSize: 1024 }), async (c) => {', '\tc.header("Cache-Control", "no-store");', - '}), async (c) => {', - '\tc.header("Cache-Control", "no-store");', - '});', + '}];', ].join('\n'); const handlers = sliceRouteHandlers(source); assertEquals(handlers.length, 2); @@ -90,22 +89,22 @@ Deno.test('fullstack-boundary: parseRequestTimeRoutePaths reads the generated ad Deno.test('fullstack-boundary: no-store baseline with the #943 private relaxation passes', () => { const entry = [ - 'app.get("/notes", async (c) => {', + '__pageHandlers["/notes"].GET = [async (c) => {', '\tc.header("Cache-Control", "no-store");', '\tif (ok) c.header("Cache-Control", "private, no-cache");', - '});', - 'app.post("/notes", bodyLimit({}), async (c) => {', + '}];', + '__pageHandlers["/notes"].POST = [__bodyLimit({}), async (c) => {', '\tc.header("Cache-Control", "no-store");', - '});', + '}];', ].join('\n'); assertEquals(findCacheBoundaryIssues(entry, ['/notes']), []); }); Deno.test('fullstack-boundary: a handler without the no-store baseline is flagged', () => { const entry = [ - 'app.get("/notes", async (c) => {', + '__pageHandlers["/notes"].GET = [async (c) => {', '\treturn c.html("notes");', - '});', + '}];', ].join('\n'); const issues = findCacheBoundaryIssues(entry, ['/notes']); assertEquals(issues.length, 1); @@ -115,10 +114,10 @@ Deno.test('fullstack-boundary: a handler without the no-store baseline is flagge Deno.test('fullstack-boundary: a publicly cacheable emission is flagged', () => { const entry = [ - 'app.get("/notes", async (c) => {', + '__pageHandlers["/notes"].GET = [async (c) => {', '\tc.header("Cache-Control", "no-store");', '\tc.header("Cache-Control", "public, max-age=60");', - '});', + '}];', ].join('\n'); const issues = findCacheBoundaryIssues(entry, ['/notes']); assertEquals(issues.length, 1); diff --git a/tools/check-fullstack-boundary.ts b/tools/check-fullstack-boundary.ts index 7ca6738b6..087fda180 100644 --- a/tools/check-fullstack-boundary.ts +++ b/tools/check-fullstack-boundary.ts @@ -100,10 +100,11 @@ export interface RouteHandlerSlice { body: string; } -// The generated entry (dist/server/entry.js) is bundled but not minified: -// framework route registrations sit at column 0 with double-quoted paths, -// while hono's doc-comment examples are indented and single-quoted. -const ROUTE_REGISTRATION = /^app\.(get|post)\("([^"]+)"/gm; +// The generated entry (dist/server/entry.js) dispatches through one unified +// `app.all('*', …)`: per-route handlers live in the generated `__pageHandlers` +// method table, keyed by a double-quoted path literal. The reserved identifier +// and assignment shape survive bundling; doc-comment mentions lack `= [`. +const ROUTE_REGISTRATION = /__pageHandlers\["((?:[^"\\]|\\.)*)"\]\.(GET|POST)\s*=\s*\[/g; /** Slice the generated entry into per-route handler bodies. */ export function sliceRouteHandlers(entrySource: string): RouteHandlerSlice[] { @@ -114,8 +115,8 @@ export function sliceRouteHandlers(entrySource: string): RouteHandlerSlice[] { const start = match.index!; const end = index + 1 < matches.length ? matches[index + 1].index! : entrySource.length; handlers.push({ - method: match[1] as 'get' | 'post', - path: match[2], + method: match[2].toLowerCase() as 'get' | 'post', + path: JSON.parse(`"${match[1]}"`) as string, line: entrySource.slice(0, start).split('\n').length, body: entrySource.slice(start, end), }); diff --git a/tools/check-review-outcome.test.ts b/tools/check-review-outcome.test.ts new file mode 100644 index 000000000..e1bdba115 --- /dev/null +++ b/tools/check-review-outcome.test.ts @@ -0,0 +1,157 @@ +import { assert, assertEquals } from '@std/assert'; +import { + classifyReviewComment, + evaluateReviewOutcome, + type ReviewCommentSummary, + runReviewOutcomeCheck, +} from './check-review-outcome.ts'; + +function comment(body: string): ReviewCommentSummary { + return { body, createdAt: '2026-09-09T00:00:00Z' }; +} + +const FOOTER = '[opencode session](https://opencode.ai/s/abc) | ' + + '[github run](/open-element/openelement/actions/runs/123)'; + +const GENUINE_REVIEW = [ + '## Review summary', + '', + 'The route-table change looks correct. A few observations:', + '', + '- `match()` merges conservative and indexed candidates in registration order — good.', + '- Consider documenting the `baseURL` behavior for relative inputs.', + '- Nit: the `sequence` field could use a tighter type.', + '', + FOOTER, +].join('\n'); + +Deno.test('review-outcome: genuine review comment passes', () => { + const verdict = evaluateReviewOutcome({ + stepOutcome: 'success', + comments: [comment(GENUINE_REVIEW)], + }); + assertEquals(verdict.ok, true); +}); + +Deno.test('review-outcome: observed #1343 insufficient-balance comment fails', () => { + const verdict = evaluateReviewOutcome({ + stepOutcome: 'success', + comments: [comment(`APIError: Insufficient Balance\n\n${FOOTER}`)], + }); + assertEquals(verdict.ok, false); + assert(verdict.reasons.some((reason) => reason.includes('provider'))); +}); + +Deno.test('review-outcome: each provider error category fails', () => { + const bodies = [ + 'APIError: upstream rejected the request', + 'Error: HTTP 429 Too Many Requests from provider', + 'rate_limit exceeded for this API key', + 'Authentication failed: invalid api key', + 'HTTP 401 returned by the provider', + 'HTTP 503 Service Unavailable', + 'The model request timed out after 30s', + 'Error: model deepseek-v9-pro not found', + 'context length exceeded for this model', + ]; + for (const body of bodies) { + assertEquals( + classifyReviewComment(`${body}\n\n${FOOTER}`).ok, + false, + `expected failure for: ${body}`, + ); + } +}); + +Deno.test('review-outcome: long review mentioning rate limits deep in the body passes', () => { + const body = '## Review\n\nThe rate-limit binding correctly fails closed. ' + + 'Detailed notes follow. '.repeat(20) + FOOTER; + assertEquals(classifyReviewComment(body).ok, true); +}); + +Deno.test('review-outcome: missing review comment fails closed', () => { + const verdict = evaluateReviewOutcome({ stepOutcome: 'success', comments: [] }); + assertEquals(verdict.ok, false); + assert(verdict.reasons.some((reason) => reason.includes('no review comment'))); +}); + +Deno.test('review-outcome: failed step outcome fails even with a genuine review comment', () => { + const verdict = evaluateReviewOutcome({ + stepOutcome: 'failure', + comments: [comment(GENUINE_REVIEW)], + }); + assertEquals(verdict.ok, false); + assert(verdict.reasons.some((reason) => reason.includes('"failure"'))); +}); + +Deno.test('review-outcome: empty payload after the footer fails closed', () => { + assertEquals(classifyReviewComment(FOOTER).ok, false); +}); + +Deno.test('review-outcome: an earlier error is superseded by a later genuine review', () => { + const verdict = evaluateReviewOutcome({ + stepOutcome: 'success', + comments: [comment(`APIError: Insufficient Balance\n\n${FOOTER}`), comment(GENUINE_REVIEW)], + }); + assertEquals(verdict.ok, true); +}); + +Deno.test('review-outcome: run check fails without run identity', async () => { + const errors: string[] = []; + const code = await runReviewOutcomeCheck({ + env: () => undefined, + error: (message) => errors.push(message), + }); + assertEquals(code, 1); + assert(errors.some((message) => message.includes('GITHUB_REPOSITORY'))); +}); + +Deno.test('review-outcome: run check fails without a token', async () => { + const errors: string[] = []; + const env = (name: string) => + ({ GITHUB_REPOSITORY: 'open-element/openelement', PR_NUMBER: '1343', RUN_ID: '1' })[name]; + const code = await runReviewOutcomeCheck({ + env, + error: (message) => errors.push(message), + }); + assertEquals(code, 1); + assert(errors.some((message) => message.includes('GITHUB_TOKEN'))); +}); + +Deno.test('review-outcome: run check passes with injected genuine review', async () => { + const logs: string[] = []; + const env = (name: string) => + ({ + GITHUB_REPOSITORY: 'open-element/openelement', + PR_NUMBER: '1343', + RUN_ID: '1', + REVIEW_STEP_OUTCOME: 'success', + GITHUB_TOKEN: 'test-token', + })[name]; + const code = await runReviewOutcomeCheck({ + env, + fetchComments: () => Promise.resolve([comment(GENUINE_REVIEW)]), + log: (message) => logs.push(message), + }); + assertEquals(code, 0); + assert(logs.some((message) => message.includes('executed review'))); +}); + +Deno.test('review-outcome: run check fails when comment fetch throws', async () => { + const errors: string[] = []; + const env = (name: string) => + ({ + GITHUB_REPOSITORY: 'open-element/openelement', + PR_NUMBER: '1343', + RUN_ID: '1', + REVIEW_STEP_OUTCOME: 'success', + GITHUB_TOKEN: 'test-token', + })[name]; + const code = await runReviewOutcomeCheck({ + env, + fetchComments: () => Promise.reject(new Error('HTTP 403')), + error: (message) => errors.push(message), + }); + assertEquals(code, 1); + assert(errors.some((message) => message.includes('HTTP 403'))); +}); diff --git a/tools/check-review-outcome.ts b/tools/check-review-outcome.ts new file mode 100644 index 000000000..609b503c0 --- /dev/null +++ b/tools/check-review-outcome.ts @@ -0,0 +1,253 @@ +/** + * #1332 / #1343 merge prerequisite: the third-party AI review job + * (opencode-review.yml) must actually execute its review. + * + * The review action posts its result as a PR comment and exits 0 even when the + * provider call fails — the observed #1343 failure posted `APIError: + * Insufficient Balance` while the workflow wrapper stayed green. A successful + * wrapper job is therefore not review evidence. This gate fails closed: + * + * a) the review step's own outcome must be `success`; + * b) a review comment attributable to this workflow run (the action links + * its `actions/runs/` URL) must exist on the PR; + * c) that comment must not match provider/API error signatures. + * + * Unverifiable means failed, never assumed green. The token comes from + * GITHUB_TOKEN or GH_TOKEN and is never printed. The workflow grants the job + * `issues: read`/`pull-requests: write` via its existing permissions. + * + * Logic and IO are separated: evaluateReviewOutcome / classifyReviewComment are + * pure; fetchRunReviewComments is the only network code. Import-free on + * purpose: the review job installs Deno only, not the workspace graph. + */ + +export interface ReviewCommentSummary { + /** Comment body, verbatim. */ + body: string; + /** ISO-8601 creation timestamp. */ + createdAt: string; +} + +export interface ReviewOutcomeVerdict { + ok: boolean; + reasons: string[]; +} + +const formatError = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +/** + * Provider/API error signatures. Several independent categories, not one + * hard-coded string: a provider failure is an error even when its wording is + * new, so each category anchors on the structural shape of a provider report. + */ +const PROVIDER_ERROR_PATTERNS: Array<{ category: string; re: RegExp }> = [ + { category: 'provider API error', re: /\bAPIError\b/i }, + { category: 'billing/balance', re: /\binsufficient\s+(?:balance|quota|funds)\b/i }, + { category: 'billing/balance', re: /\bbilling\b.{0,40}\b(?:error|failed|required)\b/i }, + { category: 'rate limit', re: /\brate[\s_-]?limit(?:ed|ing)?\b/i }, + { category: 'rate limit', re: /\bHTTP\s*429\b|\b429\s+Too\s+Many\s+Requests\b/i }, + { + category: 'authentication', + re: /\b(?:unauthorized|authentication\s+failed|invalid\s+api[\s_-]?key)\b/i, + }, + { category: 'authentication', re: /\bHTTP\s*40[13]\b/i }, + { + category: 'provider unavailable', + re: /\b(?:service\s+unavailable|provider\s+(?:is\s+)?unavailable)\b/i, + }, + { category: 'provider unavailable', re: /\bHTTP\s*5\d\d\b/ }, + { + category: 'provider timeout', + re: /\b(?:request\s+)?timed?\s*out\b.{0,40}\b(?:provider|api|model)\b/i, + }, + { category: 'provider timeout', re: /\b(?:provider|api|model)\b.{0,40}\btimed?\s*out\b/i }, + { + category: 'model error', + re: /\bmodel\b.{0,40}\b(?:not\s+found|does\s+not\s+exist|unavailable)\b/i, + }, + { category: 'model error', re: /\bcontext\s+(?:length|window)\s+exceeded\b/i }, + { + category: 'generic provider exception', + re: + /^(?:Error|Exception|TypeError|FetchError)\b.{0,120}(?:provider|api|deepseek|openai|anthropic|model)/i, + }, +]; + +/** + * Classification is structural, not one hard-coded string: + * - a payload that leads with a machine error marker (`APIError: …`, + * `Error: …`, `HTTP <4xx/5xx> …`) is a provider failure — genuine reviews + * lead with prose; + * - a payload too short to be a review that matches any provider-error + * category anywhere is a provider failure; + * - a payload with no letters at all (footer only) is empty and fails. + * Quoting "rate limit" inside a long review body does not fail the gate. + */ +const HEAD_ERROR_MARKER = /^(?:[A-Za-z]*Error|Exception)\s*[:;]|^HTTP\s*[45]\d{2}\b/i; +const SHORT_PAYLOAD_MAX = 300; + +/** The action's standard footer (session/run links) is attribution, not content. */ +function reviewPayload(body: string): string { + return body + .replace(/\[[^\]]*(?:opencode session|github run)[^\]]*\]\([^)]*\)/giu, '') + .replace(/ /g, ' ') + .trim(); +} + +export function classifyReviewComment(body: string): { ok: boolean; category?: string } { + const payload = reviewPayload(body).replace(/[|\s]+/g, ' ').trim(); + if (!/\p{L}/u.test(payload)) return { ok: false, category: 'empty review payload' }; + if (HEAD_ERROR_MARKER.test(payload)) { + const category = PROVIDER_ERROR_PATTERNS.find(({ re }) => re.test(payload))?.category ?? + 'provider error'; + return { ok: false, category }; + } + if (payload.length <= SHORT_PAYLOAD_MAX) { + for (const { category, re } of PROVIDER_ERROR_PATTERNS) { + if (re.test(payload)) return { ok: false, category }; + } + } + return { ok: true }; +} + +export function evaluateReviewOutcome(input: { + /** `steps..outcome` of the review action step. */ + stepOutcome: string; + /** Review comments attributable to this run, oldest first. */ + comments: ReviewCommentSummary[]; +}): ReviewOutcomeVerdict { + const reasons: string[] = []; + if (input.stepOutcome !== 'success') { + reasons.push( + `review step outcome is "${input.stepOutcome}", not success; a wrapped failure is not ` + + 'review evidence', + ); + } + const [latest] = input.comments.slice(-1); + if (latest === undefined) { + reasons.push( + 'no review comment attributable to this workflow run was found on the PR; the review ' + + 'did not produce evidence and the gate fails closed', + ); + } else { + const verdict = classifyReviewComment(latest.body); + if (!verdict.ok) { + reasons.push( + `the run's review comment is a provider/API error (${verdict.category}), not a review; ` + + 'restore provider availability and rerun the review job', + ); + } + } + return { ok: reasons.length === 0, reasons }; +} + +export function resolveToken(env: (name: string) => string | undefined): string | undefined { + return env('GITHUB_TOKEN') || env('GH_TOKEN') || undefined; +} + +/** The only network code in this tool. Never logs the token. */ +export async function fetchRunReviewComments( + repo: string, + prNumber: string, + runId: string, + token: string, +): Promise { + const attribution = `actions/runs/${runId}`; + const comments: ReviewCommentSummary[] = []; + // Issue comments, oldest first; a review PR accumulates few enough that a + // bounded page walk suffices. + for (let page = 1; page <= 10; page++) { + const url = + `https://api.github.com/repos/${repo}/issues/${prNumber}/comments?per_page=100&page=${page}`; + let response: Response; + try { + response = await fetch(url, { + headers: { + accept: 'application/vnd.github+json', + authorization: `Bearer ${token}`, + 'x-github-api-version': '2022-11-28', + 'user-agent': 'openelement-review-outcome-gate', + }, + }); + } catch (error) { + throw new Error( + `GitHub API unreachable while reading PR #${prNumber} comments (${formatError(error)}); ` + + 'review evidence requires readable PR comments and fails closed', + ); + } + if (!response.ok) { + await response.body?.cancel(); + throw new Error( + `GitHub API returned HTTP ${response.status} for PR #${prNumber} comments; the token ` + + 'needs issues:read on the repository and review evidence fails closed', + ); + } + const body: unknown = await response.json(); + if (!Array.isArray(body)) { + throw new Error(`unexpected GitHub API payload for PR #${prNumber} comments`); + } + for (const comment of body as Array>) { + const user = comment.user as Record | undefined; + const text = typeof comment.body === 'string' ? comment.body : ''; + if (user?.login === 'github-actions[bot]' && text.includes(attribution)) { + comments.push({ body: text, createdAt: String(comment.created_at ?? '') }); + } + } + if (body.length < 100) break; + } + return comments; +} + +export interface ReviewOutcomeCheckDeps { + env?: (name: string) => string | undefined; + fetchComments?: typeof fetchRunReviewComments; + log?: (message: string) => void; + error?: (message: string) => void; +} + +/** Returns the process exit code; kept Deno.exit-free so tests can drive it. */ +export async function runReviewOutcomeCheck(deps: ReviewOutcomeCheckDeps = {}): Promise { + const env = deps.env ?? ((name: string) => Deno.env.get(name)); + const log = deps.log ?? console.log; + const error = deps.error ?? console.error; + + const repo = env('GITHUB_REPOSITORY'); + const prNumber = env('PR_NUMBER'); + const runId = env('RUN_ID'); + const stepOutcome = env('REVIEW_STEP_OUTCOME') ?? ''; + if (!repo || !prNumber || !runId) { + error( + 'review outcome gate: GITHUB_REPOSITORY, PR_NUMBER and RUN_ID are required; without run ' + + 'identity the review evidence is unverifiable and the gate fails closed', + ); + return 1; + } + const token = resolveToken(env); + if (token === undefined) { + error( + 'review outcome gate: neither GITHUB_TOKEN nor GH_TOKEN is set; review evidence is ' + + 'unverifiable and the gate fails closed', + ); + return 1; + } + const fetchComments = deps.fetchComments ?? fetchRunReviewComments; + let comments: ReviewCommentSummary[]; + try { + comments = await fetchComments(repo, prNumber, runId, token); + } catch (cause) { + error(`review outcome gate: ${formatError(cause)}`); + return 1; + } + const verdict = evaluateReviewOutcome({ stepOutcome, comments }); + if (!verdict.ok) { + for (const reason of verdict.reasons) error(`review outcome gate: ${reason}`); + return 1; + } + log(`review outcome gate: run ${runId} has an executed review on PR #${prNumber}.`); + return 0; +} + +if (import.meta.main) { + Deno.exit(await runReviewOutcomeCheck()); +} diff --git a/tools/check-v044-orchestration.test.ts b/tools/check-v044-orchestration.test.ts deleted file mode 100644 index 299b9b020..000000000 --- a/tools/check-v044-orchestration.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { assert, assertEquals } from '@std/assert'; -import { loadV044RoleConfig, V044_ROLE_CONFIG_PATH } from './config/load-v044-roles.ts'; -import { - type ReleaseDoctrineTexts, - validateAlphaWorkspaceConfig, - validateAlphaWorkspaceTopology, - validateExecutionState, - validateExecutorContract, - validateReleaseDoctrine, -} from './check-v044-orchestration.ts'; - -const config = await loadV044RoleConfig(); - -function validState(): Record { - return { - schemaVersion: 2, - train: '0.44.0', - status: 'READY', - currentIssue: 1193, - executionMode: 'independent-alpha-workspaces', - threeRoleLoopActive: false, - integrationBaseSha: null, - workspaces: Object.fromEntries([ - ...Array.from({ length: 7 }, (_, index) => [`alpha.${index + 1}`, 'PENDING_BASE']), - ['alpha.8', 'WAITING_FOR_WORKSPACE_SHAS'], - ]), - authoritativeCiOwner: 'alpha.8 exact-SHA pull request', - betaThreeRoleExecutorConfig: V044_ROLE_CONFIG_PATH, - parallelReady: true, - integrationBaseEvidenceIssue: 1193, - workspaceConfig: 'tools/config/v044-alpha-workspaces.json', - collaborationContract: 'docs/current/v0.44.0-ALPHA-CONTRACT.md', - }; -} - -Deno.test('Alpha workspace execution state validates without three-role sessions', () => { - assertEquals(validateExecutionState(validState(), config), []); -}); - -Deno.test('Alpha.8 plus Alpha.9 convergence keeps PR #1199 as exact-head authority', () => { - const state = validState(); - state.executionMode = 'alpha8-integration-plus-alpha9-semantic-convergence'; - state.authoritativeCiOwner = 'PR #1199 final exact head'; - (state.workspaces as Record)['alpha.9'] = 'BLOCKING_BETA1'; - assertEquals(validateExecutionState(state, config), []); -}); - -Deno.test('Alpha state rejects an active three-role loop or embedded role sessions', () => { - const active = validState(); - active.threeRoleLoopActive = true; - assert(validateExecutionState(active, config).some((failure) => failure.includes('disabled'))); - - const embedded = validState(); - embedded.implementer = { sessionId: 'forbidden' }; - embedded.releaseVerifier = { sessionId: 'forbidden' }; - const failures = validateExecutionState(embedded, config); - assert(failures.some((failure) => failure.includes('implementer'))); - assert(failures.some((failure) => failure.includes('releaseVerifier'))); -}); - -Deno.test('Alpha state rejects a missing workspace or non-authoritative CI owner', () => { - const missing = validState(); - delete (missing.workspaces as Record)['alpha.4']; - assert(validateExecutionState(missing, config).some((failure) => failure.includes('alpha.4'))); - - const wrongOwner = validState(); - wrongOwner.authoritativeCiOwner = 'every workspace'; - assert( - validateExecutionState(wrongOwner, config).some((failure) => failure.includes('PR #1199')), - ); -}); - -Deno.test('Beta executor capability remains pinned but is not activated by Alpha state', () => { - assertEquals(validateExecutorContract(config), []); - const drifted = structuredClone(config); - drifted.executor.contextTokens = 128000; - drifted.executor.defaultEffort = 'low'; - assert(validateExecutorContract(drifted).length >= 2); -}); - -Deno.test('executable Alpha workspace config has seven disjoint writers and one aggregator', async () => { - const workspaceConfig = JSON.parse( - await Deno.readTextFile('tools/config/v044-alpha-workspaces.json'), - ); - assertEquals(validateAlphaWorkspaceConfig(workspaceConfig), []); - - const overlap = structuredClone(workspaceConfig); - overlap.workspaces[1].writePaths.push(overlap.workspaces[0].writePaths[0]); - assert( - validateAlphaWorkspaceConfig(overlap).some((failure) => failure.includes('overlaps')), - ); - - const missing = structuredClone(workspaceConfig); - missing.workspaces = missing.workspaces.filter((entry: { id: string }) => entry.id !== 'alpha.6'); - assert(validateAlphaWorkspaceConfig(missing).some((failure) => failure.includes('alpha.6'))); -}); - -Deno.test('alpha.8 integration whitelist is required, bounded and safe', async () => { - const workspaceConfig = JSON.parse( - await Deno.readTextFile('tools/config/v044-alpha-workspaces.json'), - ); - - const absent = structuredClone(workspaceConfig); - delete absent.integration.writePaths; - assert( - validateAlphaWorkspaceConfig(absent).some((failure) => - failure.includes('write-path whitelist') - ), - ); - - const bare = structuredClone(workspaceConfig); - bare.integration.writePaths = ['packages/']; - assert( - validateAlphaWorkspaceConfig(bare).some((failure) => failure.includes('bare top-level')), - ); - - const unsafe = structuredClone(workspaceConfig); - unsafe.integration.writePaths = ['../outside.ts']; - assert(validateAlphaWorkspaceConfig(unsafe).some((failure) => failure.includes('unsafe'))); - - const glob = structuredClone(workspaceConfig); - glob.integration.writePaths = ['packages/element/**']; - assert(validateAlphaWorkspaceConfig(glob).some((failure) => failure.includes('unsafe'))); -}); - -function alphaPlan(): string { - return [ - 'The Alpha train uses parallel development in independent workspaces and one final integration workspace.', - 'It does not use the three-role release loop.', - '| `alpha.0` | #1160 #1182 #1193 | common base |', - '| alpha.1 | Compiler | #1161 #1162 #1163 |', - '| alpha.2 | Runtime | #1164 #1165 #1166 #723 #1167 |', - '| alpha.3 | SSR / Claim | #1168 #1169 #1170 |', - '| alpha.4 | App / Delivery | #1088 #1171 #1172 #1173 #1163 |', - '| alpha.5 | Migration | #1174 |', - '| alpha.6 | Interoperability | #1175 |', - '| alpha.7 | Qualification | #1176 |', - '| alpha.8 | Final Integration | #1181 |', - 'One agent owns each alpha.1-alpha.7 workspace end-to-end.', - 'The Alpha integration agent is the only aggregator.', - 'PR #1199 runs the only full matrix for its exact SHA.', - 'No internal Alpha ID causes a tag, npm publication, GitHub Release, dist-tag change, `main` promotion, three-role GO or fresh release-verifier run.', - 'Beta.1 activates the three-role release loop. Beta.3 owns #1192 #1156 #1187 #1188 #1189.', - '#1150 #1157 #1158 #1159 #1177 #1178', - ].join('\n'); -} - -Deno.test('internal Alpha workspace topology accepts alpha.1-alpha.8 and one aggregator', () => { - assertEquals(validateAlphaWorkspaceTopology(alphaPlan()), []); -}); - -Deno.test('internal Alpha workspace topology matches the live execution plan', async () => { - const plan = await Deno.readTextFile('docs/current/v0.44.0-EXECUTION-PLAN.md'); - assertEquals(validateAlphaWorkspaceTopology(plan), []); -}); - -Deno.test('workspace topology rejects missing workspaces and missing final aggregator', () => { - const missing = alphaPlan().replace( - '| alpha.4 | App / Delivery | #1088 #1171 #1172 #1173 #1163 |\n', - '', - ); - assert(validateAlphaWorkspaceTopology(missing).some((failure) => failure.includes('alpha.4'))); - - const noAggregator = alphaPlan().replace('is the only aggregator', 'is an aggregator'); - assert( - validateAlphaWorkspaceTopology(noAggregator).some((failure) => - failure.includes('only aggregator') - ), - ); -}); - -Deno.test('workspace topology rejects three-role Alpha execution and per-workspace full matrices', () => { - const roles = alphaPlan().replace( - 'It does not use the three-role release loop.', - 'Invoke the configured implementer and a fresh release verifier session for Alpha.', - ); - assert(validateAlphaWorkspaceTopology(roles).some((failure) => failure.includes('three-role'))); - - const matrices = alphaPlan().replace( - 'PR #1199 runs the only full matrix', - 'every workspace runs the full matrix', - ); - assert(validateAlphaWorkspaceTopology(matrices).some((failure) => failure.includes('PR #1199'))); -}); - -async function doctrineCorpus(): Promise { - return { - issueMap: await Deno.readTextFile('docs/roadmap/v0.44.0-ISSUES.md'), - versionPlan: await Deno.readTextFile('docs/current/VERSION_PLAN.md'), - alphaSop: await Deno.readTextFile('docs/governance/V044_ALPHA_WORKSPACE_SOP.md'), - alphaPrompt: await Deno.readTextFile( - 'docs/prompts/v0.44.0-ALPHA-SEVEN-SUBAGENTS.md', - ), - betaSop: await Deno.readTextFile('docs/governance/V044_AGENT_LOOP_SOP.md'), - betaPrompt: await Deno.readTextFile('docs/prompts/v0.44.0-THINKER-ORCHESTRATOR.md'), - plan: await Deno.readTextFile('docs/current/v0.44.0-EXECUTION-PLAN.md'), - }; -} - -Deno.test('real doctrine disables three-role Alpha and activates it at Beta.1', async () => { - assertEquals(validateReleaseDoctrine(await doctrineCorpus()), []); -}); - -Deno.test('doctrine rejects publishable Alpha, Alpha verifier use, executor preflight and missing Beta activation', async () => { - const publishable = await doctrineCorpus(); - publishable.issueMap += '\nAlpha.4 may publish to npm.'; - assert(validateReleaseDoctrine(publishable).some((failure) => failure.includes('publishable'))); - - const verifier = await doctrineCorpus(); - verifier.alphaSop += '\nStart a fresh release verifier for alpha.8.'; - assert(validateReleaseDoctrine(verifier).some((failure) => failure.includes('release verifier'))); - - const preflight = await doctrineCorpus(); - preflight.alphaSop = preflight.alphaSop.replace( - 'Do not run `v044:executor:check` during Alpha', - 'The executor preflight is optional during Alpha', - ); - assert( - validateReleaseDoctrine(preflight).some((failure) => failure.includes('v044:executor:check')), - ); - - const noBeta = await doctrineCorpus(); - noBeta.versionPlan = noBeta.versionPlan.replace('It begins at Beta.1', 'It stays disabled'); - assert(validateReleaseDoctrine(noBeta).some((failure) => failure.includes('Beta.1'))); -}); - -Deno.test('Beta SOP and prompt preserve exact-SHA fast-forward and #1178 stop', async () => { - const texts = await doctrineCorpus(); - for (const text of [texts.betaSop, texts.betaPrompt]) { - assert(text.includes('beta.1') || text.includes('Beta.1')); - assert(text.includes('beta.3') || text.includes('Beta.3')); - assert(text.includes('--ff-only')); - assert(text.includes('#1178')); - } -}); diff --git a/tools/check-v044-orchestration.ts b/tools/check-v044-orchestration.ts deleted file mode 100644 index cbe9e639b..000000000 --- a/tools/check-v044-orchestration.ts +++ /dev/null @@ -1,504 +0,0 @@ -/** Deterministic v0.44 Alpha-workspace and Beta-release orchestration gate. */ - -import { - loadV044RoleConfig, - V044_ROLE_CONFIG_PATH, - type V044RoleConfig, -} from './config/load-v044-roles.ts'; - -const root = new URL('../', import.meta.url); - -function requiredFiles(config: V044RoleConfig): string[] { - return [ - config.profiles.implementer.agentFile, - config.profiles.releaseVerifier.agentFile, - V044_ROLE_CONFIG_PATH, - 'tools/run-v044-role.ts', - 'tools/check-role-neutral-docs.ts', - 'docs/adr/ADR-0146-three-role-agent-execution-control-plane.md', - 'docs/adr/ADR-0147-internal-alpha-workspace-train.md', - 'docs/current/v0.44.0-ALPHA-CONTRACT.md', - 'docs/current/v0.44.0-AUTONOMOUS-GOAL.md', - 'docs/current/v0.44.0-EXECUTION-PLAN.md', - 'docs/current/v0.44.0-EXECUTION-STATE.json', - 'docs/current/VERSION_PLAN.md', - 'docs/governance/V044_ALPHA_WORKSPACE_SOP.md', - 'docs/governance/V044_AGENT_LOOP_SOP.md', - 'docs/governance/V044_ISSUE_SOP.md', - 'docs/prompts/v0.44.0-ALPHA-WORKSPACE-TRAIN.md', - 'docs/prompts/v0.44.0-ALPHA-SEVEN-SUBAGENTS.md', - 'docs/prompts/v0.44.0-THINKER-ORCHESTRATOR.md', - 'docs/roadmap/v0.44.0-ISSUES.md', - 'tools/config/v044-alpha-workspaces.json', - ]; -} - -const allowedStatuses = new Set([ - 'READY', - 'DISPATCHED', - 'IMPLEMENTED', - 'REVIEWED', - 'VERIFIED', - 'REPAIR', - 'VERSION_CLOSURE', - 'AWAITING_HUMAN_GO', - 'BLOCKED_DIRTY_WORKTREE', - 'BLOCKED_EXECUTOR_UNAVAILABLE', - 'BLOCKED_TRUTH_DRIFT', - 'BLOCKED_EXTERNAL', - 'COMPLETE', -]); - -const ALPHA_WORKSPACES = Array.from({ length: 8 }, (_, index) => `alpha.${index + 1}`); - -export function validateExecutionState( - state: Record, - config: V044RoleConfig, -): string[] { - const failures: string[] = []; - if (state.schemaVersion !== 2) failures.push('execution state schemaVersion must be 2'); - if (state.train !== '0.44.0') failures.push('execution state train must be 0.44.0'); - if (typeof state.status !== 'string' || !allowedStatuses.has(state.status)) { - failures.push(`execution state status is invalid: ${String(state.status)}`); - } - if (typeof state.currentIssue !== 'number' || !Number.isInteger(state.currentIssue)) { - failures.push('execution state currentIssue must be an integer'); - } - const alphaExecutionModes = new Set([ - 'independent-alpha-workspaces', - 'alpha8-integration-plus-alpha9-semantic-convergence', - ]); - if (typeof state.executionMode !== 'string' || !alphaExecutionModes.has(state.executionMode)) { - failures.push('Alpha executionMode must be the workspace or integration/convergence mode'); - } - if (state.threeRoleLoopActive !== false) { - failures.push('three-role loop must be disabled throughout Alpha'); - } - for (const forbidden of ['implementer', 'releaseVerifier']) { - if (forbidden in state) { - failures.push(`Alpha execution state must not embed ${forbidden} session state`); - } - } - const workspaces = state.workspaces; - if (!workspaces || typeof workspaces !== 'object') { - failures.push('execution state workspaces must be an object'); - } else { - for (const workspace of ALPHA_WORKSPACES) { - if (!(workspace in workspaces)) failures.push(`execution state omits ${workspace}`); - } - if ( - state.executionMode === 'alpha8-integration-plus-alpha9-semantic-convergence' && - !('alpha.9' in workspaces) - ) { - failures.push('Alpha integration/convergence state omits alpha.9'); - } - } - if ( - typeof state.authoritativeCiOwner !== 'string' || - !(state.authoritativeCiOwner.includes('alpha.8') || - state.authoritativeCiOwner.includes('PR #1199')) - ) { - failures.push('authoritative full CI owner must be PR #1199 exact head'); - } - if (state.betaThreeRoleExecutorConfig !== V044_ROLE_CONFIG_PATH) { - failures.push(`Beta executor configuration must reference ${V044_ROLE_CONFIG_PATH}`); - } - if (!config.profiles.implementer || !config.profiles.releaseVerifier) { - failures.push('Beta role profiles must remain configured for post-Alpha release work'); - } - if (state.parallelReady !== true) { - failures.push('Alpha workspace state must be parallelReady after #1193 closes'); - } - if (state.integrationBaseEvidenceIssue !== 1193) { - failures.push('Alpha common-base evidence must resolve from issue #1193'); - } - if (state.workspaceConfig !== 'tools/config/v044-alpha-workspaces.json') { - failures.push('Alpha state must reference the executable workspace configuration'); - } - if (state.collaborationContract !== 'docs/current/v0.44.0-ALPHA-CONTRACT.md') { - failures.push('Alpha state must reference the collaboration contract'); - } - return failures; -} - -/** Beta capability contract remains available but is not activated by Alpha state. */ -export function validateExecutorContract(config: V044RoleConfig): string[] { - const failures: string[] = []; - if (config.executor.contextTokens !== 262144) { - failures.push(`executor contextTokens must be 262144, got ${config.executor.contextTokens}`); - } - if (config.executor.defaultEffort !== 'high') { - failures.push(`executor defaultEffort must be high, got ${config.executor.defaultEffort}`); - } - for (const capability of ['thinking', 'tool_use']) { - if (!config.executor.requiredCapabilities.includes(capability)) { - failures.push(`executor requiredCapabilities must include ${capability}`); - } - } - return failures; -} - -const REQUIRED_ISSUES = [ - 723, - 1088, - 1150, - 1156, - 1157, - 1158, - 1159, - 1160, - 1161, - 1162, - 1163, - 1164, - 1165, - 1166, - 1167, - 1168, - 1169, - 1170, - 1171, - 1172, - 1173, - 1174, - 1175, - 1176, - 1177, - 1178, - 1181, - 1182, - 1187, - 1188, - 1189, - 1192, - 1193, -]; - -const WORKSPACE_ISSUES = [ - { workspace: 'alpha.1', issues: [1161, 1162, 1163] }, - { workspace: 'alpha.2', issues: [1164, 1165, 1166, 723, 1167] }, - { workspace: 'alpha.3', issues: [1168, 1169, 1170] }, - { workspace: 'alpha.4', issues: [1088, 1171, 1172, 1173, 1163] }, - { workspace: 'alpha.5', issues: [1174] }, - { workspace: 'alpha.6', issues: [1175] }, - { workspace: 'alpha.7', issues: [1176] }, - { workspace: 'alpha.8', issues: [1181] }, -] as const; - -type AlphaWorkspaceConfig = { - schemaVersion?: unknown; - commonBase?: Record; - contract?: unknown; - workspaces?: Array>; - integration?: Record; -}; - -export function validateAlphaWorkspaceConfig(raw: AlphaWorkspaceConfig): string[] { - const failures: string[] = []; - if (raw.schemaVersion !== 1) failures.push('Alpha workspace config schemaVersion must be 1'); - if (raw.commonBase?.branch !== 'dev' || raw.commonBase?.evidenceIssue !== 1193) { - failures.push('Alpha workspace config common base must resolve from dev and issue #1193'); - } - if (raw.contract !== 'docs/current/v0.44.0-ALPHA-CONTRACT.md') { - failures.push('Alpha workspace config must reference the collaboration contract'); - } - - const workspaces = raw.workspaces ?? []; - const owners = new Map(); - for (const expected of WORKSPACE_ISSUES.slice(0, 7)) { - const workspace = workspaces.find((entry) => entry.id === expected.workspace); - if (!workspace) { - failures.push(`Alpha workspace config omits ${expected.workspace}`); - continue; - } - const issues = Array.isArray(workspace.issues) ? workspace.issues : []; - for (const issue of expected.issues) { - if (!issues.includes(issue)) failures.push(`${expected.workspace} config omits #${issue}`); - } - const writePaths = Array.isArray(workspace.writePaths) ? workspace.writePaths : []; - if (writePaths.length === 0 || writePaths.some((path) => typeof path !== 'string')) { - failures.push(`${expected.workspace} must own one or more string write paths`); - continue; - } - for (const path of writePaths as string[]) { - if (path.startsWith('/') || path.includes('..')) { - failures.push(`${expected.workspace} has unsafe write path ${path}`); - } - for (const [ownedPath, owner] of owners) { - const overlap = path === ownedPath || - (path.endsWith('/') && ownedPath.startsWith(path)) || - (ownedPath.endsWith('/') && path.startsWith(ownedPath)); - if (overlap) { - failures.push(`${expected.workspace} write path ${path} overlaps ${owner}:${ownedPath}`); - } - } - owners.set(path, expected.workspace); - } - } - if (workspaces.length !== 7) { - failures.push('Alpha workspace config must contain exactly 7 writers'); - } - if ( - raw.integration?.id !== 'alpha.8' || raw.integration?.issue !== 1181 || - raw.integration?.role !== 'sole-aggregator' || raw.integration?.authoritativeFullCi !== true - ) { - failures.push('Alpha integration config must make alpha.8 the sole full-CI aggregator'); - } - // #1181: the alpha.8 restricted integration permission must be recorded as an - // executable write-path whitelist, not prose. Entries are repo-relative file - // paths or '/'-suffixed directory prefixes; bare top-level directories and - // glob/absolute/parent-escaping entries are rejected so the boundary stays - // bounded and machine-checkable. - const integrationPaths = raw.integration?.writePaths; - if ( - !Array.isArray(integrationPaths) || integrationPaths.length === 0 || - integrationPaths.some((path) => typeof path !== 'string') - ) { - failures.push('Alpha integration config must record a non-empty string write-path whitelist'); - } else { - const bareTopLevel = [ - 'packages/', - 'tools/', - 'docs/', - 'www/', - 'examples/', - 'tests/', - 'benchmarks/', - 'e2e/', - ]; - for (const path of integrationPaths as string[]) { - if ( - path === '' || path === '.' || path.startsWith('/') || path.includes('..') || - path.includes('*') - ) { - failures.push(`Alpha integration has unsafe write path ${path}`); - } else if (bareTopLevel.includes(path)) { - failures.push(`Alpha integration write path ${path} is a bare top-level directory`); - } - } - } - return failures; -} - -export function validateAlphaWorkspaceTopology(plan: string): string[] { - const failures: string[] = []; - const lines = plan.split('\n'); - const alphaZero = lines.find((line) => line.includes('`alpha.0`')); - if (!alphaZero) { - failures.push('execution plan omits alpha.0 foundation'); - } else { - for (const issue of [1160, 1182, 1193]) { - if (!alphaZero.includes(`#${issue}`)) failures.push(`alpha.0 foundation omits #${issue}`); - } - } - - for (const { workspace, issues } of WORKSPACE_ISSUES) { - const row = lines.find((line) => line.startsWith('|') && line.includes(workspace)); - if (!row) { - failures.push(`execution plan omits ${workspace} workspace`); - continue; - } - for (const issue of issues) { - if (!row.includes(`#${issue}`)) failures.push(`${workspace} workspace omits #${issue}`); - } - } - - const normalized = plan.replaceAll(/\s+/gu, ' '); - for ( - const required of [ - 'parallel development in independent workspaces', - 'one final integration workspace', - 'does not use the three-role release loop', - 'One agent owns each alpha.1-alpha.7 workspace end-to-end', - 'Alpha integration agent is the only aggregator', - 'PR #1199 runs the only full matrix', - 'No internal Alpha ID causes a tag', - 'Beta.1', - 'Beta.3', - ] - ) { - if (!normalized.includes(required)) failures.push(`execution plan omits "${required}"`); - } - if ( - /invoke the configured implementer/iu.test(plan) || - /fresh release verifier session/iu.test(plan) - ) { - failures.push('execution plan activates a three-role Alpha implementation or verifier flow'); - } - return failures; -} - -export interface ReleaseDoctrineTexts { - issueMap: string; - versionPlan: string; - alphaSop: string; - alphaPrompt: string; - betaSop: string; - betaPrompt: string; - plan: string; -} - -export function validateReleaseDoctrine(texts: ReleaseDoctrineTexts): string[] { - const failures: string[] = []; - const normalize = (text: string) => text.replaceAll(/^>\s?/gmu, '').replaceAll(/\s+/gu, ' '); - const issueMap = normalize(texts.issueMap); - const versionPlan = normalize(texts.versionPlan); - const alphaSop = normalize(texts.alphaSop); - const alphaPrompt = normalize(texts.alphaPrompt); - const betaSop = normalize(texts.betaSop); - const betaPrompt = normalize(texts.betaPrompt); - const plan = normalize(texts.plan); - - if (/alpha\.\d+\s+may\s+publish/iu.test(issueMap)) { - failures.push('issue map describes an internal Alpha identifier as publishable'); - } - for ( - const required of [ - 'internal identifiers', - 'no tag', - 'no npm publication', - 'no GitHub Release', - 'no dist-tag', - 'no `main` promotion', - 'three-role loop is disabled', - ] - ) { - if (!issueMap.includes(required)) failures.push(`issue map omits "${required}"`); - } - - for ( - const required of [ - 'internal work identifiers', - 'three-role release loop is disabled throughout Alpha', - 'It begins at Beta.1', - 'not npm versions', - ] - ) { - if (!versionPlan.includes(required)) failures.push(`version plan omits "${required}"`); - } - - for ( - const required of [ - 'The three-role release SOP does not apply during this phase', - 'One worktree, branch and writing agent per Alpha workspace', - 'Alpha.8 is created as the integration workspace', - 'No three-role GO or release-verifier session', - 'Do not run `v044:executor:check` during Alpha', - '`tools/config/v044-alpha-workspaces.json` is the executable write boundary', - ] - ) { - if (!alphaSop.includes(required)) failures.push(`Alpha workspace SOP omits "${required}"`); - } - if (/start a fresh release verifier/iu.test(alphaSop)) { - failures.push('Alpha workspace SOP starts a release verifier'); - } - - for ( - const required of [ - 'Launch one subagent per workspace', - 'fewer than seven subagent slots', - 'source-to-integrated SHA mapping', - 'Only alpha.8 opens a pull request to `dev`', - 'does not use the thinker, implementer or release-verifier loop', - ] - ) { - if (!alphaPrompt.includes(required)) failures.push(`seven-subagent prompt omits "${required}"`); - } - - for ( - const [name, text] of [ - ['Beta agent loop SOP', betaSop], - ['Beta bootstrap prompt', betaPrompt], - ] as const - ) { - for (const required of ['Beta.1', 'Beta.3', '--ff-only', '#1178']) { - if (!text.includes(required) && !text.includes(required.toLowerCase())) { - failures.push(`${name} omits "${required}"`); - } - } - } - if (!betaSop.includes('does not govern the internal Alpha workspace train')) { - failures.push('Beta agent loop SOP does not exclude Alpha'); - } - if (!betaPrompt.includes('Do not use this prompt during Alpha')) { - failures.push('Beta bootstrap prompt does not exclude Alpha'); - } - - for ( - const required of [ - 'does not use the three-role release loop', - 'No internal Alpha ID causes a tag', - 'PR #1199 runs the only full matrix', - ] - ) { - if (!plan.includes(required)) failures.push(`execution plan omits "${required}"`); - } - return failures; -} - -async function main(): Promise { - const failures: string[] = []; - const config = await loadV044RoleConfig(root); - - async function read(path: string): Promise { - try { - return await Deno.readTextFile(new URL(path, root)); - } catch (error) { - failures.push(`missing or unreadable ${path}: ${String(error)}`); - return ''; - } - } - - for (const file of requiredFiles(config)) await read(file); - - let state: Record = {}; - try { - state = JSON.parse(await read('docs/current/v0.44.0-EXECUTION-STATE.json')); - } catch (error) { - failures.push(`execution state is not valid JSON: ${String(error)}`); - } - failures.push(...validateExecutionState(state, config)); - failures.push(...validateExecutorContract(config)); - - let alphaWorkspaceConfig: AlphaWorkspaceConfig = {}; - try { - alphaWorkspaceConfig = JSON.parse( - await read('tools/config/v044-alpha-workspaces.json'), - ) as AlphaWorkspaceConfig; - } catch (error) { - failures.push(`Alpha workspace config is not valid JSON: ${String(error)}`); - } - failures.push(...validateAlphaWorkspaceConfig(alphaWorkspaceConfig)); - - const plan = await read('docs/current/v0.44.0-EXECUTION-PLAN.md'); - const issueMap = await read('docs/roadmap/v0.44.0-ISSUES.md'); - for (const issue of REQUIRED_ISSUES) { - if (!plan.includes(`#${issue}`)) failures.push(`execution plan omits #${issue}`); - if (!issueMap.includes(`#${issue}`)) failures.push(`issue map omits #${issue}`); - } - failures.push(...validateAlphaWorkspaceTopology(plan)); - - failures.push(...validateReleaseDoctrine({ - issueMap, - versionPlan: await read('docs/current/VERSION_PLAN.md'), - alphaSop: await read('docs/governance/V044_ALPHA_WORKSPACE_SOP.md'), - alphaPrompt: await read('docs/prompts/v0.44.0-ALPHA-SEVEN-SUBAGENTS.md'), - betaSop: await read('docs/governance/V044_AGENT_LOOP_SOP.md'), - betaPrompt: await read('docs/prompts/v0.44.0-THINKER-ORCHESTRATOR.md'), - plan, - })); - - if (failures.length > 0) { - console.error('v0.44 orchestration check failed:'); - for (const failure of failures) console.error(`- ${failure}`); - Deno.exit(1); - } - - console.log( - `v0.44 orchestration check passed (${requiredFiles(config).length} control files, ` + - `${REQUIRED_ISSUES.length} scheduled issues, 8 internal Alpha workspaces, ` + - 'three-role release loop deferred to Beta.1).', - ); -} - -if (import.meta.main) await main(); diff --git a/tools/check-www-truth.test.ts b/tools/check-www-truth.test.ts index 8bf8c813f..ce820e147 100644 --- a/tools/check-www-truth.test.ts +++ b/tools/check-www-truth.test.ts @@ -42,6 +42,18 @@ const entries = { assertEquals(roadmapCurrentVersion('const entries = {};'), undefined); }); +Deno.test('roadmapCurrentVersion: quoted property keys parse the same (#1343)', () => { + const source = ` +const entries = { + en: [ + { 'version': 'v0.44.0-beta.2', 'theme': 'x', 'state': 'next', 'stamp': 'CURRENT' }, + { 'version': 'v0.43.3', 'theme': 'y', 'state': 'stable' }, + ], +}; +`; + assertEquals(roadmapCurrentVersion(source), 'v0.44.0-beta.2'); +}); + Deno.test('findRouteLocaleFailures: an en-only content record fails (#1307)', () => { const masquerade = ` const content = { diff --git a/tools/check-www-truth.ts b/tools/check-www-truth.ts index ede196495..8307bb13c 100644 --- a/tools/check-www-truth.ts +++ b/tools/check-www-truth.ts @@ -270,7 +270,10 @@ export function roadmapCurrentVersion(source: string): string | undefined { let entryVersion: string | undefined; for (const property of node.properties) { if (!ts.isPropertyAssignment(property)) continue; - const key = ts.isIdentifier(property.name) ? property.name.text : undefined; + // Roadmap entries quote their keys ('stamp': …); accept both forms. + const key = ts.isIdentifier(property.name) || ts.isStringLiteral(property.name) + ? property.name.text + : undefined; if (!ts.isStringLiteralLike(property.initializer)) continue; if (key === 'stamp') stamp = property.initializer.text; if (key === 'version') entryVersion = property.initializer.text; diff --git a/tools/consumer-packaged-element.ts b/tools/consumer-packaged-element.ts new file mode 100644 index 000000000..1fadf5817 --- /dev/null +++ b/tools/consumer-packaged-element.ts @@ -0,0 +1,167 @@ +/** Packed Element author -> Vite compile -> separate plain-HTML browser proof (#1338). */ +import { assert, assertEquals } from '@std/assert'; +import { join, resolve } from '@std/path'; +import { chromium, firefox, webkit } from '@playwright/test'; +import ts from 'typescript'; +import { PACKAGE_VERSION } from './project-constants.ts'; + +const root = resolve(import.meta.dirname!, '..'); +const author = await Deno.makeTempDir({ prefix: 'oe-element-author-' }); +const consumer = await Deno.makeTempDir({ prefix: 'oe-element-html-' }); +async function run(args: string[]): Promise { + const output = await new Deno.Command(args[0], { + args: args.slice(1), + cwd: author, + stdout: 'piped', + stderr: 'piped', + }).output(); + if (!output.success) { + throw new Error( + new TextDecoder().decode(output.stdout) + new TextDecoder().decode(output.stderr), + ); + } +} +try { + await Deno.writeTextFile( + join(author, 'package.json'), + JSON.stringify({ + name: 'oe-standalone-author-proof', + version: '1.0.0', + private: true, + type: 'module', + dependencies: { + '@openelement/element': + `file:${root}/packages/element/openelement-element-${PACKAGE_VERSION}.tgz`, + }, + devDependencies: { + '@openelement/adapter-vite': + `file:${root}/packages/adapter-vite/openelement-adapter-vite-${PACKAGE_VERSION}.tgz`, + vite: '8.0.16', + }, + }), + ); + await Deno.writeTextFile( + join(author, 'counter.tsx'), + `import {element,property,OpenElement} from '@openelement/element'; +@element('proof-counter') +export class Counter extends OpenElement { + @property({reflect:true}) count = 0; + increment() { this.count++; } + render() { return ; } +} +`, + ); + await Deno.writeTextFile( + join(author, 'register.js'), + `import {Counter} from './counter.tsx'; customElements.define('proof-counter',Counter);`, + ); + await Deno.writeTextFile( + join(author, 'vite.config.js'), + `import {element} from '@openelement/adapter-vite/element'; +export default {plugins:[element(), {name:'proof-module-boundary',generateBundle(){for(const id of this.getModuleIds()){if(/compiler|adapter-vite|node:/.test(id))this.error('Browser tooling leak: '+id)}}}],build:{sourcemap:true,lib:{entry:'register.js',formats:['es'],fileName:'counter'}}};`, + ); + await run([ + 'npm', + 'install', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--fetch-retries=1', + '--fetch-timeout=30000', + ]); + assert( + !await Deno.stat(join(author, 'node_modules/@openelement/app')).then(() => true, () => false), + 'Router must not be installed', + ); + await run(['node', 'node_modules/vite/bin/vite.js', 'build']); + // Follow local and external declaration edges from the browser entry, rather than + // rejecting separate supported tooling declarations elsewhere in the package. + const seen = new Set(); + const declarations = async (path: string): Promise => { + if (seen.has(path)) return; + seen.add(path); + const text = await Deno.readTextFile(path); + for (const { fileName } of ts.preProcessFile(text).importedFiles) { + assert( + !/compiler|adapter-vite|\bvite\b|^node:|workspace:/.test(fileName), + `Browser declaration leak: ${path} -> ${fileName}`, + ); + const resolved = ts.resolveModuleName(fileName, path, { + moduleResolution: ts.ModuleResolutionKind.Bundler, + module: ts.ModuleKind.ESNext, + }, { + fileExists: (name) => { + try { + return Deno.statSync(name).isFile; + } catch { + return false; + } + }, + readFile: (name) => { + try { + return Deno.readTextFileSync(name); + } catch { + return undefined; + } + }, + }).resolvedModule; + assert(resolved, `Unresolved browser declaration: ${path} -> ${fileName}`); + await declarations(resolved.resolvedFileName); + } + }; + await declarations(join(author, 'node_modules/@openelement/element/src/index.d.ts')); + const map = JSON.parse(await Deno.readTextFile(join(author, 'dist/counter.js.map'))); + assert( + map.sources.some((s: string) => s.endsWith('counter.tsx')), + 'authored source map must survive', + ); + const js = await Deno.readTextFile(join(author, 'dist/counter.js')); + assert( + ts.preProcessFile(js).importedFiles.every(({ fileName }) => + !/workspace:|@openelement\/adapter-vite|^node:/.test(fileName) + ), + 'compiled browser artifact boundary', + ); + await Deno.writeTextFile(join(consumer, 'counter.js'), js); + await Deno.writeTextFile( + join(consumer, 'index.html'), + '', + ); + const server = Deno.serve({ hostname: '127.0.0.1', port: 0, onListen() {} }, async (request) => { + const script = new URL(request.url).pathname === '/counter.js'; + return new Response( + await Deno.readTextFile(join(consumer, script ? 'counter.js' : 'index.html')), + { headers: { 'content-type': script ? 'text/javascript' : 'text/html' } }, + ); + }); + try { + for (const type of [chromium, firefox, webkit]) { + const browser = await type.launch({ headless: true }); + try { + const page = await browser.newPage(); + await page.goto(`http://127.0.0.1:${server.addr.port}`); + const button = page.locator('proof-counter button'); + await button.waitFor(); + assertEquals(await button.textContent(), 'Count: 0'); + assertEquals(await button.getAttribute('title'), '0'); + await button.click(); + await page.waitForFunction('document.querySelector("proof-counter").count === 1'); + assertEquals(await button.textContent(), 'Count: 1'); + assertEquals(await button.getAttribute('title'), '1'); + console.log( + `PASS ${type.name()} ${browser.version()}: packed Element registration, attribute and event update`, + ); + } finally { + await browser.close(); + } + } + } finally { + await server.shutdown(); + } + console.log( + `PASS: Router absent, browser module graph clean, ${seen.size} declaration modules checked, source map retained`, + ); +} finally { + await Deno.remove(author, { recursive: true }); + await Deno.remove(consumer, { recursive: true }); +} diff --git a/tools/lib/npm-release-verifier.ts b/tools/lib/npm-release-verifier.ts index e1dc6dd9a..cb4a869bd 100644 --- a/tools/lib/npm-release-verifier.ts +++ b/tools/lib/npm-release-verifier.ts @@ -1,7 +1,7 @@ import { type PrereleaseChannel, prereleaseChannel, - prereleaseParts, + previousPrereleaseVersion, tryParseLineVersion, } from './version.ts'; @@ -67,10 +67,7 @@ export function prereleaseTag(version: string): PrereleaseChannel | null { // #869-2.5: the version immediately before the target on the same line, so a // release can never skip a number (alpha.8-style hole). export function previousPrerelease(version: string): string | null { - const parts = prereleaseParts(version); - const channel = prereleaseChannel(version); - if (!parts || !channel || parts.num <= 1) return null; - return `${parts.base}-${channel}.${parts.num - 1}`; + return previousPrereleaseVersion(version); } async function verifyField( diff --git a/tools/lib/version.test.ts b/tools/lib/version.test.ts index 9b83a0f3f..7c4fe89c1 100644 --- a/tools/lib/version.test.ts +++ b/tools/lib/version.test.ts @@ -1,12 +1,18 @@ import { assert, assertEquals, assertThrows } from '@std/assert'; import { + assertPublicReleaseVersion, compareVersions, FIRST_TAGGED_VERSION, + formatLineVersion, + isInternalAlphaWorkspace, + nextCheckpointVersion, nextPatchVersion, + nextProductStageVersion, normalizeReleaseVersion, parseLineVersion, prereleaseChannel, prereleaseParts, + previousPrereleaseVersion, tryParseLineVersion, } from './version.ts'; @@ -22,12 +28,15 @@ Deno.test('parseLineVersion parses stable and prerelease line versions', () => { minor: 44, patch: 0, prerelease: 'beta', + identifiers: ['beta', '1'], prereleaseNumber: 1, }); }); Deno.test('parseLineVersion rejects non-line versions', () => { - for (const bad of ['1.2', 'v1.2.3', '1.2.3+build', '1.2.3-alpha', '1.2.3-alpha.1.x', '01.2.3']) { + for ( + const bad of ['1.2', 'v1.2.3', '1.2.3+build', '1.2.3-alpha..1', '1.2.3-alpha.01', '01.2.3'] + ) { assertThrows(() => parseLineVersion(bad), Error, 'Invalid semver', bad); assertEquals(tryParseLineVersion(bad), undefined, bad); } @@ -73,3 +82,46 @@ Deno.test('FIRST_TAGGED_VERSION is the immutable-tag policy boundary (#855)', () assertEquals(FIRST_TAGGED_VERSION, '0.41.0-alpha.14'); assert(tryParseLineVersion(FIRST_TAGGED_VERSION) !== undefined); }); + +Deno.test('Beta checkpoint SemVer precedence includes all identifiers', () => { + const chain = [ + '0.44.0-beta.2', + '0.44.0-beta.2.1', + '0.44.0-beta.2.9', + '0.44.0-beta.2.10', + '0.44.0-beta.3', + '0.44.0', + '1.0.0-alpha.1', + ]; + for (let i = 1; i < chain.length; i++) assertEquals(compareVersions(chain[i - 1], chain[i]), -1); + assertEquals(prereleaseChannel('0.44.0-beta.2.1'), 'beta'); + assertEquals(prereleaseChannel('1.0.0-alpha.1'), 'alpha'); +}); + +Deno.test('checkpoint and product stage succession are different operations', () => { + assertEquals(nextCheckpointVersion('0.44.0-beta.2'), '0.44.0-beta.2.1'); + assertEquals(nextCheckpointVersion('0.44.0-beta.2.1'), '0.44.0-beta.2.2'); + assertEquals(nextCheckpointVersion('0.44.0-beta.2.2'), '0.44.0-beta.2.3'); + assertThrows(() => nextPatchVersion('0.44.0-beta.2.3')); + assertEquals(nextProductStageVersion('0.44.0-beta.2.3'), '1.0.0-alpha.1'); + assertEquals(isInternalAlphaWorkspace('0.44.0-alpha.10'), true); + assertEquals(isInternalAlphaWorkspace('1.0.0-alpha.1'), false); + for ( + const version of [ + '1.2.3-alpha', + '1.2.3-alpha.1.x', + '0.44.0-beta.2.10', + '1.2.3-12345678901234567890', + ] + ) assertEquals(formatLineVersion(parseLineVersion(version)), version); + assertEquals(compareVersions('1.2.3-9', '1.2.3-10'), -1); + assertEquals(compareVersions('1.2.3-1', '1.2.3-a'), -1); +}); + +Deno.test('release predecessor retains checkpoint identifiers and historic workspace prohibition is scoped', () => { + assertEquals(previousPrereleaseVersion('0.44.0-beta.2.2'), '0.44.0-beta.2.1'); + assertEquals(previousPrereleaseVersion('0.44.0-beta.2.1'), '0.44.0-beta.2'); + assertEquals(previousPrereleaseVersion('1.0.0-alpha.1'), null); + assertThrows(() => assertPublicReleaseVersion('0.44.0-alpha.10')); + assertPublicReleaseVersion('1.0.0-alpha.1'); +}); diff --git a/tools/lib/version.ts b/tools/lib/version.ts index ac44fba4f..1d98a97bf 100644 --- a/tools/lib/version.ts +++ b/tools/lib/version.ts @@ -2,8 +2,8 @@ * Canonical prerelease/version truth (#1231 M16; umbrella #1155). * * This module is the ONE implementation of the release line-version contract - * `x.y.z` or `x.y.z-