From 21cd3f8bc4df424d9dac6e2f15b05acdb7c67b12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Tue, 21 Jul 2026 19:18:07 +0800 Subject: [PATCH 1/3] fix(schema): accept compatible CLI path separators --- internal/cli/canonical.go | 11 +++++++ internal/cli/schema_catalog.go | 2 +- .../cli/schema_catalog_coverage_more_test.go | 17 +++++++++++ internal/cli/schema_contract_model.go | 16 ++++++++++ internal/cli/schema_contract_model_test.go | 6 ++++ .../cli/schema_delivery_invariants_test.go | 29 +++++++++++++++++++ internal/cli/schema_runtime_registry.go | 4 +-- 7 files changed, 82 insertions(+), 3 deletions(-) diff --git a/internal/cli/canonical.go b/internal/cli/canonical.go index 4cf1bc22..69cd71dd 100644 --- a/internal/cli/canonical.go +++ b/internal/cli/canonical.go @@ -304,3 +304,14 @@ func splitSchemaPathTokens(raw string) []string { } return out } + +// normalizeSchemaQueryCLIPath accepts the historical query spellings while +// keeping authored Registry CLI paths strict and space-separated. Canonical +// identity lookup still runs before this compatibility normalization. +func normalizeSchemaQueryCLIPath(path string) string { + parts := splitSchemaPathTokens(strings.TrimSpace(path)) + if len(parts) > 0 && parts[0] == "dws" { + parts = parts[1:] + } + return strings.Join(parts, " ") +} diff --git a/internal/cli/schema_catalog.go b/internal/cli/schema_catalog.go index 563f2df2..1649146e 100644 --- a/internal/cli/schema_catalog.go +++ b/internal/cli/schema_catalog.go @@ -290,7 +290,7 @@ func schemaPayloadFromLoadedCatalog(loaded loadedSchemaCatalog, args []string) ( return payload, nil } raw := strings.TrimSpace(args[0]) - if tool, ok := loaded.Index.Resolve(raw); ok { + if tool, ok := loaded.Index.ResolveQuery(raw); ok { return schemaToolForResolvedPath(tool, raw).ToPayload() } tokens := splitSchemaPathTokens(raw) diff --git a/internal/cli/schema_catalog_coverage_more_test.go b/internal/cli/schema_catalog_coverage_more_test.go index a2e5d4c3..8baf0538 100644 --- a/internal/cli/schema_catalog_coverage_more_test.go +++ b/internal/cli/schema_catalog_coverage_more_test.go @@ -194,6 +194,23 @@ func TestCrossPlatformCoverageSchemaCatalogLookupAndConversionEdges(t *testing.T } } +func TestEmbeddedSchemaLookupAcceptsCompatibleCLIPathSeparators(t *testing.T) { + loaded := embeddedSchemaCatalog() + for _, path := range []string{ + "dev app list", + "dev.app.list", + "dev/app/list", + } { + payload, err := schemaPayloadFromLoadedCatalog(loaded, []string{path}) + if err != nil { + t.Fatalf("schemaPayloadFromLoadedCatalog(%q) error = %v", path, err) + } + if got := schemaString(payload["canonical_path"]); got != "dev.list_dev_app" { + t.Fatalf("schemaPayloadFromLoadedCatalog(%q) canonical_path = %q, want %q", path, got, "dev.list_dev_app") + } + } +} + type catalogHookSnapshot struct { parameterBindings func(BoundCommandRegistry, SchemaRegistry) error dryRun func(SchemaRegistry) error diff --git a/internal/cli/schema_contract_model.go b/internal/cli/schema_contract_model.go index ccaa239b..51170479 100644 --- a/internal/cli/schema_contract_model.go +++ b/internal/cli/schema_contract_model.go @@ -617,6 +617,22 @@ func (i SchemaIndex) Resolve(path string) (ToolSpec, bool) { return i.registry.Products[location.product].Tools[location.tool], true } +// ResolveQuery adds compatibility for dotted and slash-separated CLI paths at +// the user-facing query boundary. Resolve remains strict because Registry +// validation uses it to detect missing canonical identities without falling +// through to a similarly spelled CLI path. +func (i SchemaIndex) ResolveQuery(path string) (ToolSpec, bool) { + if tool, ok := i.Resolve(path); ok { + return tool, true + } + canonical, ok := i.byCLIPath[normalizeSchemaQueryCLIPath(path)] + if !ok { + return ToolSpec{}, false + } + location := i.byCanonical[canonical] + return i.registry.Products[location.product].Tools[location.tool], true +} + // CanonicalPaths returns the complete tool identity set in stable order. func (i SchemaIndex) CanonicalPaths() []string { paths := make([]string, 0, len(i.byCanonical)) diff --git a/internal/cli/schema_contract_model_test.go b/internal/cli/schema_contract_model_test.go index 5abbbcf3..9dd94223 100644 --- a/internal/cli/schema_contract_model_test.go +++ b/internal/cli/schema_contract_model_test.go @@ -514,6 +514,12 @@ func TestSchemaRegistryIndexResolvesCanonicalCLIAndAlias(t *testing.T) { t.Fatalf("Resolve(%q) = %#v, %v", path, resolved.Identity, ok) } } + for _, path := range []string{"calendar.attendee.delete", "calendar/attendee/delete"} { + resolved, ok := index.ResolveQuery(path) + if !ok || resolved.Identity.CanonicalPath != "calendar.attendee_delete" { + t.Fatalf("ResolveQuery(%q) = %#v, %v", path, resolved.Identity, ok) + } + } if got := index.CanonicalPaths(); !reflect.DeepEqual(got, []string{"calendar.attendee_delete"}) { t.Fatalf("CanonicalPaths() = %#v", got) } diff --git a/internal/cli/schema_delivery_invariants_test.go b/internal/cli/schema_delivery_invariants_test.go index 043637af..b819efd9 100644 --- a/internal/cli/schema_delivery_invariants_test.go +++ b/internal/cli/schema_delivery_invariants_test.go @@ -154,6 +154,35 @@ func TestSelectionExplicitEmptyListSurvivesFinalDelivery(t *testing.T) { } } +func TestCompatibleSchemaAliasSeparatorsSurviveFinalDelivery(t *testing.T) { + snapshot := schemaDeliveryTestSnapshot(schemaDeliveryTestTool{ + Canonical: "sample.run", + CLIPath: "sample category run", + Aliases: []string{"sample legacy execute"}, + }) + encoded, err := json.Marshal(snapshot) + if err != nil { + t.Fatal(err) + } + loaded, err := decodeSchemaCatalogSnapshot(encoded) + if err != nil { + t.Fatalf("decodeSchemaCatalogSnapshot(): %v", err) + } + canonical, err := schemaPayloadFromLoadedCatalog(loaded, []string{"sample.run"}) + if err != nil { + t.Fatalf("canonical query: %v", err) + } + for _, path := range []string{"sample legacy execute", "sample.legacy.execute", "sample/legacy/execute"} { + alias, aliasErr := schemaPayloadFromLoadedCatalog(loaded, []string{path}) + if aliasErr != nil { + t.Fatalf("alias query %q: %v", path, aliasErr) + } + if problem := schemaAliasViewProblem(canonical, alias, "sample legacy execute"); problem != "" { + t.Fatalf("alias projection for %q: %s", path, problem) + } + } +} + func TestValidateSchemaDeliveryInvariantsAllowsOnlyEnvelopeHashes(t *testing.T) { snapshot := schemaDeliveryTestSnapshot(schemaDeliveryTestTool{Canonical: "sample.run", CLIPath: "sample run"}) snapshot.SurfaceHash = "sha256:reviewed-command-registry" diff --git a/internal/cli/schema_runtime_registry.go b/internal/cli/schema_runtime_registry.go index 95390b19..46a59207 100644 --- a/internal/cli/schema_runtime_registry.go +++ b/internal/cli/schema_runtime_registry.go @@ -284,7 +284,7 @@ func runtimeSchemaPayloadFromRegistry(registry SchemaRegistry, args []string) (m } raw := strings.TrimSpace(args[0]) - if tool, ok := index.Resolve(raw); ok { + if tool, ok := index.ResolveQuery(raw); ok { tool = schemaToolForResolvedPath(tool, raw) return renderRegistryToolPayload(tool) } @@ -338,7 +338,7 @@ func runtimeSchemaAllPayloadFromRegistry(registry SchemaRegistry) (map[string]an } func schemaToolForResolvedPath(tool ToolSpec, raw string) ToolSpec { - normalized := normalizeSchemaCLIPath(raw) + normalized := normalizeSchemaQueryCLIPath(raw) if normalized == "" || normalized == tool.Identity.CLIPath || normalized == tool.Identity.PrimaryCLIPath { return tool } From 089caa92a88f9e9c576da270f65ce7b9271fcbba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Tue, 21 Jul 2026 19:41:39 +0800 Subject: [PATCH 2/3] test(schema): cover prefixed compatibility query --- internal/cli/schema_contract_model_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/cli/schema_contract_model_test.go b/internal/cli/schema_contract_model_test.go index 9dd94223..07bb6197 100644 --- a/internal/cli/schema_contract_model_test.go +++ b/internal/cli/schema_contract_model_test.go @@ -514,7 +514,11 @@ func TestSchemaRegistryIndexResolvesCanonicalCLIAndAlias(t *testing.T) { t.Fatalf("Resolve(%q) = %#v, %v", path, resolved.Identity, ok) } } - for _, path := range []string{"calendar.attendee.delete", "calendar/attendee/delete"} { + for _, path := range []string{ + "calendar.attendee.delete", + "calendar/attendee/delete", + "dws.calendar.attendee.delete", + } { resolved, ok := index.ResolveQuery(path) if !ok || resolved.Identity.CanonicalPath != "calendar.attendee_delete" { t.Fatalf("ResolveQuery(%q) = %#v, %v", path, resolved.Identity, ok) From d03823d772aefa2d086ba5fc4af6520aab8036e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=89=E6=BE=9C?= Date: Tue, 21 Jul 2026 20:50:34 +0800 Subject: [PATCH 3/3] test(schema): cover unknown compatibility query --- CHANGELOG.md | 4 ++++ internal/cli/schema_contract_model_test.go | 3 +++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b274ee9c..7295acdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +### Fixed + +- **Schema CLI path compatibility** — user-facing Schema lookups once again accept space-, dot-, and slash-separated CLI paths without weakening strict canonical identity resolution. + ## [1.0.53] - 2026-07-21 This release promotes the validated `v1.0.53-beta.7` baseline to stable. It adds enterprise onboarding, declarative shortcuts, Sheet/Aitable writes, multi-account profiles, and broader personal IM events, while hardening authentication and the guarded release path. diff --git a/internal/cli/schema_contract_model_test.go b/internal/cli/schema_contract_model_test.go index 07bb6197..d82491a7 100644 --- a/internal/cli/schema_contract_model_test.go +++ b/internal/cli/schema_contract_model_test.go @@ -524,6 +524,9 @@ func TestSchemaRegistryIndexResolvesCanonicalCLIAndAlias(t *testing.T) { t.Fatalf("ResolveQuery(%q) = %#v, %v", path, resolved.Identity, ok) } } + if _, ok := index.ResolveQuery("calendar.attendee.unknown"); ok { + t.Fatal("unknown dotted CLI path unexpectedly resolved") + } if got := index.CanonicalPaths(); !reflect.DeepEqual(got, []string{"calendar.attendee_delete"}) { t.Fatalf("CanonicalPaths() = %#v", got) }