Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/language/conformance.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,21 @@ code appears among the diagnostics for that file. Diagnostic codes are the ones
registered in `internal/diagnostics/registry.go` and documented in
`docs/reference/diagnostic-codes.md`.

## Coverage

`TestConformanceCorpusCoversRejectionContracts` fails when a rejection contract
that surfaces a specific stable code through the single-file check loses its
reject case (`unsupported_top_level_block`, `old_action_block_syntax`,
`old_api_block_syntax`, `malformed_legacy_metadata`, `malformed_gowdk_use`).

Markup directive and foreign-syntax rejections currently surface as the generic
`parse_error` through this path rather than `unsupported_markup_directive` /
`unsupported_markup_syntax`. Their reject cases pin `parse_error` for now and
will be updated to the specific code once markup rejections carry their own code
(tracked alongside parser recovery in #250). The corpus ratchets that
improvement: when the specific code lands, the `parse_error` expectation fails
until the case is updated.

## Adding a corpus case

New or changed `.gwdk` syntax must come with a corpus case. Adding accepted
Expand Down
27 changes: 17 additions & 10 deletions docs/language/stability.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@ have an `accept/` case, and a `Planned`/`Deprecated` construct should have a
- **Deprecated**: previously accepted spelling that is now rejected with a
migration diagnostic.

The canonical construct names below are the source of truth in code
(`lang.MetadataKeywords` and `view.SupportedDirectiveNames()`) and are
cross-checked against this page by `TestStabilityTableCoversConstructs`.
The tiers below are the code-level registry `lang.ConstructStabilities()` (with
metadata keywords derived from `lang.MetadataKeywords` and directives checked
against `view.SupportedDirectiveNames()`). `TestStabilityRegistryCoversCodeConstructs`
asserts the registry covers every keyword and directive in code, and
`TestStabilityTableMatchesRegistry` asserts this page matches the registry, so
neither the table nor the registry can drift without failing a test.

## Top-Level Blocks

Expand Down Expand Up @@ -88,7 +91,10 @@ Supported exact-name directives (the closed set in
| `g:ref` | Partial | Client reference. |
| `g:slot` | Partial | Named/scoped slot. |

Planned directives are rejected with `unsupported_markup_directive`:
Planned directives are rejected. They currently surface as the generic
`parse_error` rather than the intended `unsupported_markup_directive` code; that
code lands when markup rejections carry their own code (see
[Conformance Corpus](conformance.md)).

| Directive family | Tier | Replacement |
| --- | --- | --- |
Expand All @@ -98,14 +104,15 @@ Planned directives are rejected with `unsupported_markup_directive`:
| `g:use`, `g:action`, `g:attach` | Planned | `client {}` with `g:ref`. |

Foreign template syntax (`{#if}`, `{@html}`, and similar) is **Planned/Unsupported**
and rejected with `unsupported_markup_syntax`.
and likewise currently surfaces as `parse_error` (intended:
`unsupported_markup_syntax`).

## Endpoint Declarations

| Construct | Tier | Notes |
| --- | --- | --- |
| `act <Name> POST "<path>"` | Stable | POST only today. |
| `api <Name> <METHOD> "<path>"` | Stable | GET/POST/PUT/PATCH/DELETE. |
| Fragment endpoints | Partial | First-slice partial updates. |
| `act <name> { ... }` block form | Deprecated | Rejected with `old_action_block_syntax`. |
| `api <name> { ... }` block form | Deprecated | Rejected with `old_api_block_syntax`. |
| `act` | Stable | `act <Name> POST "<path>"`; POST only today. |
| `api` | Stable | `api <Name> <METHOD> "<path>"`; GET/POST/PUT/PATCH/DELETE. |
| `fragment` | Partial | First-slice partial updates. |
| `act` block form | Deprecated | `act <name> { ... }`; rejected with `old_action_block_syntax`. |
| `api` block form | Deprecated | `api <name> { ... }`; rejected with `old_api_block_syntax`. |
32 changes: 32 additions & 0 deletions internal/compiler/routes_related_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,38 @@ func TestDuplicateRouteCarriesRelatedFirstDeclaration(t *testing.T) {
}
}

func TestDuplicatePageIDCarriesRelatedFirstDeclaration(t *testing.T) {
pages := []gwdkir.Page{
{ID: "home", Source: "a.page.gwdk", Spans: gwdkir.PageSpans{Page: span(1, 1, 9)}},
{ID: "home", Source: "b.page.gwdk", Spans: gwdkir.PageSpans{Page: span(1, 1, 9)}},
}
diagnostic, ok := findByCode(validateUniquePages(pages), "duplicate_page_id")
if !ok {
t.Fatal("expected a duplicate_page_id diagnostic")
}
if len(diagnostic.Related) != 1 || diagnostic.Related[0].Source != "a.page.gwdk" {
t.Fatalf("expected related location at first page; got %+v", diagnostic.Related)
}
}

func TestDuplicatePageStoreCarriesRelatedFirstDeclaration(t *testing.T) {
page := gwdkir.Page{
ID: "home",
Source: "home.page.gwdk",
Stores: []gwdkir.Store{
{Name: "Counter", Span: span(3, 1, 8)},
{Name: "Counter", Span: span(6, 1, 8)},
},
}
diagnostic, ok := findByCode(validatePageStores(page), "duplicate_page_store")
if !ok {
t.Fatal("expected a duplicate_page_store diagnostic")
}
if len(diagnostic.Related) != 1 || diagnostic.Related[0].Span != span(3, 1, 8) {
t.Fatalf("expected related location at first store; got %+v", diagnostic.Related)
}
}

func TestContractRouteConflictCarriesRelatedFirstDeclaration(t *testing.T) {
// Two differently-named query contracts on the same GET route conflict
// through the shared route-registration path; the conflict must point back
Expand Down
18 changes: 11 additions & 7 deletions internal/compiler/validate_identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@ func validateUniquePages(pages []gwdkir.Page) []ValidationError {
continue
}
diagnostics = append(diagnostics, ValidationError{
Code: "duplicate_page_id",
PageID: page.ID,
Source: page.Source,
Span: page.Spans.Page,
Code: "duplicate_page_id",
PageID: page.ID,
Source: page.Source,
Span: page.Spans.Page,
Related: relatedSpan(first.Source, first.Spans.Page, fmt.Sprintf("page ID %q first declared here", page.ID)),
Message: duplicateIdentityMessage(
"page ID",
page.ID,
Expand All @@ -50,9 +51,10 @@ func validateUniqueLayouts(layouts []gwdkir.Layout) []ValidationError {
continue
}
diagnostics = append(diagnostics, ValidationError{
Code: "duplicate_layout_id",
Source: layout.Source,
Span: layout.Span,
Code: "duplicate_layout_id",
Source: layout.Source,
Span: layout.Span,
Related: relatedSpan(first.Source, first.Span, fmt.Sprintf("layout %q first declared here", layoutDisplayName(layout.Package, layout.ID))),
Message: duplicateIdentityMessage(
"layout ID",
layoutDisplayName(layout.Package, layout.ID),
Expand Down Expand Up @@ -367,6 +369,7 @@ func validateUniqueComponents(components []gwdkir.Component) []ValidationError {
ComponentName: component.Name,
Source: component.Source,
Span: component.Span,
Related: relatedSpan(first.Source, first.Span, fmt.Sprintf("component %q first declared here", component.Name)),
Message: duplicateIdentityMessage(
"component name",
component.Name,
Expand Down Expand Up @@ -396,6 +399,7 @@ func validateComponentEmits(components []gwdkir.Component) []ValidationError {
ComponentName: component.Name,
Source: component.Source,
Span: event.Span,
Related: relatedSpan(component.Source, first.Span, fmt.Sprintf("emit %q first declared here", event.Name)),
Message: fmt.Sprintf(
"component %s declares duplicate emit %q; first declared at line %d and duplicated at line %d",
component.Name,
Expand Down
9 changes: 5 additions & 4 deletions internal/compiler/validate_page.go
Original file line number Diff line number Diff line change
Expand Up @@ -360,10 +360,11 @@ func validatePageStores(page gwdkir.Page) []ValidationError {
for _, store := range page.Stores {
if first, exists := seen[store.Name]; exists {
diagnostics = append(diagnostics, ValidationError{
Code: "duplicate_page_store",
PageID: page.ID,
Source: page.Source,
Span: store.Span,
Code: "duplicate_page_store",
PageID: page.ID,
Source: page.Source,
Span: store.Span,
Related: relatedSpan(page.Source, first.Span, fmt.Sprintf("store %q first declared here", store.Name)),
Message: fmt.Sprintf(
"%s declares duplicate store %q; first declared at line %d and duplicated at line %d",
page.ID,
Expand Down
10 changes: 6 additions & 4 deletions internal/compiler/validate_source_uses.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,11 @@ func validateGOWDKUses(app gwdkir.Program, crossFile bool) []ValidationError {
for _, use := range page.Uses {
if first, exists := usesByAlias[use.Alias]; exists {
diagnostics = append(diagnostics, ValidationError{
Code: "duplicate_gowdk_use_alias",
PageID: page.ID,
Source: page.Source,
Span: use.Span,
Code: "duplicate_gowdk_use_alias",
PageID: page.ID,
Source: page.Source,
Span: use.Span,
Related: relatedSpan(page.Source, first.Span, fmt.Sprintf("alias %q first declared here", use.Alias)),
Message: fmt.Sprintf(
"%s declares duplicate GOWDK use alias %q; first declared at line %d",
page.ID,
Expand Down Expand Up @@ -102,6 +103,7 @@ func validateComponentUses(component gwdkir.Component, usesByAlias map[string]gw
ComponentName: component.Name,
Source: component.Source,
Span: use.Span,
Related: relatedSpan(component.Source, first.Span, fmt.Sprintf("alias %q first declared here", use.Alias)),
Message: fmt.Sprintf(
"component %s declares duplicate GOWDK use alias %q; first declared at line %d",
component.Name,
Expand Down
30 changes: 30 additions & 0 deletions internal/lang/conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,36 @@ func TestConformanceCorpusReject(t *testing.T) {
}
}

// TestConformanceCorpusCoversRejectionContracts fails when a rejection contract
// that surfaces a specific stable code through the single-file check loses its
// reject case. Markup directive/syntax rejections currently surface as
// parse_error (a tracked limitation), so they are pinned by their own reject
// cases rather than listed here.
func TestConformanceCorpusCoversRejectionContracts(t *testing.T) {
required := []string{
"unsupported_top_level_block",
"old_action_block_syntax",
"old_api_block_syntax",
"malformed_legacy_metadata",
"malformed_gowdk_use",
}

covered := map[string]bool{}
dir := filepath.FromSlash("testdata/conformance/reject")
for _, name := range conformanceFiles(t, dir) {
source := readConformanceFile(t, filepath.Join(dir, name))
for _, code := range conformanceExpectedCodes(source) {
covered[code] = true
}
}

for _, code := range required {
if !covered[code] {
t.Errorf("no reject corpus case expects required rejection code %q", code)
}
}
}

func conformanceFiles(t *testing.T, dir string) []string {
t.Helper()
entries, err := os.ReadDir(dir)
Expand Down
120 changes: 120 additions & 0 deletions internal/lang/stability.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package lang

// StabilityTier is the per-construct stability classification, mirroring the
// Stability field the diagnostics registry records for diagnostic codes.
type StabilityTier string

const (
// TierStable: accepted today and not expected to change shape within 0.x
// without a deprecation step.
TierStable StabilityTier = "stable"
// TierPartial: accepted for a narrower slice than the final contract.
TierPartial StabilityTier = "partial"
// TierPlanned: not accepted yet; using it is rejected with DiagnosticCode.
TierPlanned StabilityTier = "planned"
// TierDeprecated: previously accepted spelling, now rejected with
// DiagnosticCode.
TierDeprecated StabilityTier = "deprecated"
)

// ConstructKind groups language constructs by surface.
type ConstructKind string

const (
ConstructBlock ConstructKind = "block"
ConstructKeyword ConstructKind = "metadata-keyword"
ConstructDirective ConstructKind = "g-directive"
ConstructEndpoint ConstructKind = "endpoint"
)

// ConstructStability is the stability record for one language construct. For
// planned and deprecated constructs DiagnosticCode names the diagnostic emitted
// when the construct is used.
type ConstructStability struct {
Name string
Kind ConstructKind
Tier StabilityTier
DiagnosticCode string
}

// ConstructStabilities returns the code-level source of truth for per-construct
// stability tiers. The published table in docs/language/stability.md is verified
// against it by TestStabilityTableMatchesRegistry. Metadata keywords are derived
// from MetadataKeywords so the two cannot drift.
func ConstructStabilities() []ConstructStability {
constructs := []ConstructStability{
// Top-level blocks.
{Name: "package", Kind: ConstructBlock, Tier: TierStable},
{Name: "import", Kind: ConstructBlock, Tier: TierStable},
{Name: "use", Kind: ConstructBlock, Tier: TierStable},
{Name: "paths {}", Kind: ConstructBlock, Tier: TierPartial},
{Name: "build {}", Kind: ConstructBlock, Tier: TierPartial},
{Name: "load {}", Kind: ConstructBlock, Tier: TierPartial},
{Name: "view {}", Kind: ConstructBlock, Tier: TierStable},
{Name: "style {}", Kind: ConstructBlock, Tier: TierStable},
{Name: "client {}", Kind: ConstructBlock, Tier: TierPartial},
{Name: "go {}", Kind: ConstructBlock, Tier: TierPartial},
{Name: "store", Kind: ConstructBlock, Tier: TierPartial},
{Name: "props", Kind: ConstructBlock, Tier: TierPartial},
{Name: "state", Kind: ConstructBlock, Tier: TierPartial},
{Name: "emits", Kind: ConstructBlock, Tier: TierPartial},
{Name: "unsupported top-level block", Kind: ConstructBlock, Tier: TierPlanned, DiagnosticCode: "unsupported_top_level_block"},

// Supported g: directives. Flow control is stable; the rest are partial.
{Name: "g:if", Kind: ConstructDirective, Tier: TierStable},
{Name: "g:else-if", Kind: ConstructDirective, Tier: TierStable},
{Name: "g:else", Kind: ConstructDirective, Tier: TierStable},
{Name: "g:for", Kind: ConstructDirective, Tier: TierStable},
{Name: "g:key", Kind: ConstructDirective, Tier: TierStable},
{Name: "g:html", Kind: ConstructDirective, Tier: TierStable},
{Name: "g:bind:value", Kind: ConstructDirective, Tier: TierPartial},
{Name: "g:bind:checked", Kind: ConstructDirective, Tier: TierPartial},
{Name: "g:post", Kind: ConstructDirective, Tier: TierPartial},
{Name: "g:target", Kind: ConstructDirective, Tier: TierPartial},
{Name: "g:swap", Kind: ConstructDirective, Tier: TierPartial},
{Name: "g:island", Kind: ConstructDirective, Tier: TierPartial},
{Name: "g:command", Kind: ConstructDirective, Tier: TierPartial},
{Name: "g:query", Kind: ConstructDirective, Tier: TierPartial},
{Name: "g:event", Kind: ConstructDirective, Tier: TierPartial},
{Name: "g:ref", Kind: ConstructDirective, Tier: TierPartial},
{Name: "g:slot", Kind: ConstructDirective, Tier: TierPartial},

// Supported g: directive families (validated separately from the
// exact-name set, so view.SupportedDirectiveNames() excludes them).
{Name: "g:on:*", Kind: ConstructDirective, Tier: TierPartial},
{Name: "g:message:*", Kind: ConstructDirective, Tier: TierPartial},

// Planned g: directives, rejected on use. They currently surface as the
// generic parse_error rather than a typed code (see
// docs/language/conformance.md), so DiagnosticCode is left unset until
// markup rejections carry their own code.
{Name: "g:transition", Kind: ConstructDirective, Tier: TierPlanned},
{Name: "g:animate", Kind: ConstructDirective, Tier: TierPlanned},
{Name: "g:window", Kind: ConstructDirective, Tier: TierPlanned},
{Name: "g:document", Kind: ConstructDirective, Tier: TierPlanned},
{Name: "g:body", Kind: ConstructDirective, Tier: TierPlanned},
{Name: "g:head", Kind: ConstructDirective, Tier: TierPlanned},
{Name: "g:await", Kind: ConstructDirective, Tier: TierPlanned},
{Name: "g:async", Kind: ConstructDirective, Tier: TierPlanned},
{Name: "g:use", Kind: ConstructDirective, Tier: TierPlanned},
{Name: "g:action", Kind: ConstructDirective, Tier: TierPlanned},
{Name: "g:attach", Kind: ConstructDirective, Tier: TierPlanned},

// Endpoint declaration forms.
{Name: "act", Kind: ConstructEndpoint, Tier: TierStable},
{Name: "api", Kind: ConstructEndpoint, Tier: TierStable},
{Name: "fragment", Kind: ConstructEndpoint, Tier: TierPartial},
{Name: "act block form", Kind: ConstructEndpoint, Tier: TierDeprecated, DiagnosticCode: "old_action_block_syntax"},
{Name: "api block form", Kind: ConstructEndpoint, Tier: TierDeprecated, DiagnosticCode: "old_api_block_syntax"},
}

for _, keyword := range MetadataKeywords {
constructs = append(constructs, ConstructStability{
Name: keyword,
Kind: ConstructKeyword,
Tier: TierStable,
})
}

return constructs
}
Loading