diff --git a/docs/language/conformance.md b/docs/language/conformance.md index b61cf85..d788977 100644 --- a/docs/language/conformance.md +++ b/docs/language/conformance.md @@ -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 diff --git a/docs/language/stability.md b/docs/language/stability.md index 2a1a6b5..14e6e9e 100644 --- a/docs/language/stability.md +++ b/docs/language/stability.md @@ -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 @@ -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 | | --- | --- | --- | @@ -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 POST ""` | Stable | POST only today. | -| `api ""` | Stable | GET/POST/PUT/PATCH/DELETE. | -| Fragment endpoints | Partial | First-slice partial updates. | -| `act { ... }` block form | Deprecated | Rejected with `old_action_block_syntax`. | -| `api { ... }` block form | Deprecated | Rejected with `old_api_block_syntax`. | +| `act` | Stable | `act POST ""`; POST only today. | +| `api` | Stable | `api ""`; GET/POST/PUT/PATCH/DELETE. | +| `fragment` | Partial | First-slice partial updates. | +| `act` block form | Deprecated | `act { ... }`; rejected with `old_action_block_syntax`. | +| `api` block form | Deprecated | `api { ... }`; rejected with `old_api_block_syntax`. | diff --git a/internal/compiler/routes_related_test.go b/internal/compiler/routes_related_test.go index 5b1178f..0b5d532 100644 --- a/internal/compiler/routes_related_test.go +++ b/internal/compiler/routes_related_test.go @@ -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 diff --git a/internal/compiler/validate_identity.go b/internal/compiler/validate_identity.go index 20a3dba..8631c05 100644 --- a/internal/compiler/validate_identity.go +++ b/internal/compiler/validate_identity.go @@ -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, @@ -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), @@ -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, @@ -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, diff --git a/internal/compiler/validate_page.go b/internal/compiler/validate_page.go index 96708af..47e306a 100644 --- a/internal/compiler/validate_page.go +++ b/internal/compiler/validate_page.go @@ -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, diff --git a/internal/compiler/validate_source_uses.go b/internal/compiler/validate_source_uses.go index 68a5f96..e192f0b 100644 --- a/internal/compiler/validate_source_uses.go +++ b/internal/compiler/validate_source_uses.go @@ -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, @@ -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, diff --git a/internal/lang/conformance_test.go b/internal/lang/conformance_test.go index bbc28dc..809904f 100644 --- a/internal/lang/conformance_test.go +++ b/internal/lang/conformance_test.go @@ -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) diff --git a/internal/lang/stability.go b/internal/lang/stability.go new file mode 100644 index 0000000..179c2a7 --- /dev/null +++ b/internal/lang/stability.go @@ -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 +} diff --git a/internal/lang/stability_doc_test.go b/internal/lang/stability_doc_test.go index 2c9df9b..1303336 100644 --- a/internal/lang/stability_doc_test.go +++ b/internal/lang/stability_doc_test.go @@ -3,17 +3,56 @@ package lang import ( "os" "path/filepath" + "regexp" "strings" "testing" "github.com/cssbruno/gowdk/internal/view" ) -// TestStabilityTableCoversConstructs guards against the published stability -// table drifting from the code registries: every metadata keyword and every -// supported g: directive must appear in docs/language/stability.md, so adding a -// construct in code without documenting its tier fails here. -func TestStabilityTableCoversConstructs(t *testing.T) { +var backtickedToken = regexp.MustCompile("`([^`]+)`") + +// TestStabilityRegistryCoversCodeConstructs guards the construct stability +// registry against the code: every metadata keyword and every supported g: +// directive must have a tier, so adding a construct without classifying it +// fails here. +func TestStabilityRegistryCoversCodeConstructs(t *testing.T) { + registry := map[string]ConstructStability{} + for _, construct := range ConstructStabilities() { + registry[construct.Name] = construct + } + + for _, keyword := range MetadataKeywords { + construct, ok := registry[keyword] + if !ok { + t.Errorf("metadata keyword %q has no stability entry", keyword) + continue + } + if construct.Kind != ConstructKeyword || construct.Tier != TierStable { + t.Errorf("metadata keyword %q has unexpected entry %+v", keyword, construct) + } + } + + for _, directive := range view.SupportedDirectiveNames() { + if _, ok := registry[directive]; !ok { + t.Errorf("supported g: directive %q has no stability entry", directive) + } + } + + // SupportedDirectiveNames excludes the accepted directive families, so guard + // them explicitly to keep the registry a complete source of truth. + for _, family := range []string{"g:on:*", "g:message:*"} { + if _, ok := registry[family]; !ok { + t.Errorf("supported g: directive family %q has no stability entry", family) + } + } +} + +// TestStabilityTableMatchesRegistry guards the published table against the +// registry: every construct (or, for planned/deprecated constructs, its +// diagnostic code) must appear in docs/language/stability.md, and every tier +// used must be documented as a status term. +func TestStabilityTableMatchesRegistry(t *testing.T) { path := filepath.FromSlash("../../docs/language/stability.md") content, err := os.ReadFile(path) if err != nil { @@ -21,15 +60,30 @@ func TestStabilityTableCoversConstructs(t *testing.T) { } doc := string(content) - for _, keyword := range MetadataKeywords { - if !strings.Contains(doc, "`"+keyword+"`") { - t.Errorf("metadata keyword %q is missing from %s", keyword, path) + // Collect the exact backticked tokens (construct names and codes) from the + // table, so a match proves a real table cell rather than incidental prose: + // short names like `go`, `act`, and `api` must not pass by appearing inside + // words such as "diagnostics" or "contract". + tokens := map[string]bool{} + for _, match := range backtickedToken.FindAllStringSubmatch(doc, -1) { + tokens[match[1]] = true + } + + for _, construct := range ConstructStabilities() { + // Constructs with a diagnostic code are identified in the table by that + // code (planned blocks, deprecated endpoint forms); the rest by name. + want := construct.Name + if construct.DiagnosticCode != "" { + want = construct.DiagnosticCode + } + if !tokens[want] { + t.Errorf("construct %q (%s): expected backticked token %q in %s", construct.Name, construct.Kind, want, path) } } - for _, directive := range view.SupportedDirectiveNames() { - if !strings.Contains(doc, directive) { - t.Errorf("g: directive %q is missing from %s", directive, path) + for _, tier := range []StabilityTier{TierStable, TierPartial, TierPlanned, TierDeprecated} { + if !strings.Contains(strings.ToLower(doc), string(tier)) { + t.Errorf("stability tier %q is not documented in %s", tier, path) } } } diff --git a/internal/lang/testdata/conformance/accept/build_data.gwdk b/internal/lang/testdata/conformance/accept/build_data.gwdk new file mode 100644 index 0000000..c334f0e --- /dev/null +++ b/internal/lang/testdata/conformance/accept/build_data.gwdk @@ -0,0 +1,13 @@ +package pages + +route "/welcome" + +build { + => { title: "Welcome" } +} + +view { +
+

{title}

+
+} diff --git a/internal/lang/testdata/conformance/reject/foreign_template.gwdk b/internal/lang/testdata/conformance/reject/foreign_template.gwdk new file mode 100644 index 0000000..20cd853 --- /dev/null +++ b/internal/lang/testdata/conformance/reject/foreign_template.gwdk @@ -0,0 +1,11 @@ +// expect: parse_error +// Foreign template syntax is rejected. It currently surfaces as the generic +// parse_error through the single-file check; when markup carries its own code +// this should become unsupported_markup_syntax and this case updates. +package pages + +route "/" + +view { + {#if ok}

x

{/if} +} diff --git a/internal/lang/testdata/conformance/reject/legacy_metadata.gwdk b/internal/lang/testdata/conformance/reject/legacy_metadata.gwdk new file mode 100644 index 0000000..010bce9 --- /dev/null +++ b/internal/lang/testdata/conformance/reject/legacy_metadata.gwdk @@ -0,0 +1,10 @@ +// expect: malformed_legacy_metadata +package pages + +@page Home + +route "/" + +view { +
+} diff --git a/internal/lang/testdata/conformance/reject/planned_directive.gwdk b/internal/lang/testdata/conformance/reject/planned_directive.gwdk new file mode 100644 index 0000000..19bdddc --- /dev/null +++ b/internal/lang/testdata/conformance/reject/planned_directive.gwdk @@ -0,0 +1,11 @@ +// expect: parse_error +// A planned g: directive is rejected. It currently surfaces as the generic +// parse_error through the single-file check; when markup directives carry their +// own code this should become unsupported_markup_directive and this case updates. +package pages + +route "/" + +view { +
+}