- Relocate the registry-agnostic side-guards from the deleted check-schema-command-registry.sh into check-schema-catalog.sh: legacy hint/visibility source ban, go:generate single-track checks, agent-metadata embed/loader bans, lazy-loader reference-count checks, package-scope eager-initializer ban, internal/app loader ban, and the two fresh-process laziness tests (TestRuntimeSchemaMetadataLoadsOnlyOnDemand, TestOrdinaryRootCommandsDoNotLoadSchemaMetadata). Drop the guards that only protected the retired reviewed registry (JSON Schema/product shard presence, registry-overwrite go:generate ban, registry-count test runs); the native materialization ban already lived in check-schema-catalog.sh. - Rename the wire-visible CommandSpec.Source label from "reviewed_command_registry" to "contract_identity" (new exported constant CommandSourceContractIdentity): identity is collected from ContractFinal.Identity declarations, the registry is gone. Source is a provenance label excluded from the identity SourceHash (surface hash is unchanged); the catalog content hash shifts with the delivered bytes as expected. Updated every assignment and every test pin consistently. - Update docs/schema-dynamic-endpoint-design.md, docs/rfc-command-framework-convergence.md and docs/flag-help-schema-homology.md: collector is the single identity source, reviewed registry retired; keep genuine historical context.
259 lines
10 KiB
Go
259 lines
10 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
|
)
|
|
|
|
// Command identity collection — the single identity source.
|
|
//
|
|
// CollectIdentitySpecs walks the live Cobra leaves carrying
|
|
// ContractFinal.Identity and produces the CommandSpec set consumed by
|
|
// BuildEffectiveCommandRegistry. The reviewed schema_command_registry was
|
|
// retired together with this switchover; the standing gate in internal/app
|
|
// asserts the collector is a valid, stable single source.
|
|
|
|
// IdentityCollectionReport summarises a collection walk.
|
|
type IdentityCollectionReport struct {
|
|
Leaves int // runnable leaves walked (hidden included)
|
|
WithIdentity int // primary leaves carrying a ContractFinal.Identity
|
|
HiddenPrimaries int // collected primaries that are Hidden deprecated/migration shims
|
|
Excluded int // leaves matched by reviewed exclusions
|
|
NoIdentity []string // leaves without Identity (alias / compatibility / deprecated; informational)
|
|
MissingPrimary []string // canonicals with no collected primary (populated by spec-set comparisons)
|
|
}
|
|
|
|
// BuildEffectiveFromSpecs wraps the shared indexing so collected specs are
|
|
// normalised by the exact same rules used for effective registry assembly.
|
|
func BuildEffectiveFromSpecs(specs []CommandSpec) (EffectiveCommandRegistry, error) {
|
|
return newEffectiveCommandRegistry(specs)
|
|
}
|
|
|
|
// primaryCLIPathFromIdentity resolves the primary leaf path declared by an
|
|
// identity: PrimaryCLIPath wins, CLIPath fills when primary is empty.
|
|
func primaryCLIPathFromIdentity(id contract.ToolIdentitySpec) string {
|
|
primary := strings.TrimSpace(id.PrimaryCLIPath)
|
|
if primary == "" {
|
|
primary = strings.TrimSpace(id.CLIPath)
|
|
}
|
|
return normalizeSchemaCLIPath(primary)
|
|
}
|
|
|
|
// walkIdentityLeaves visits every runnable leaf under cmd, INCLUDING Hidden
|
|
// ones. The production bind path (bindCommandRegistryPath →
|
|
// resolveExactCobraPath) resolves identity entries through raw Commands() with
|
|
// no availability filter, so hidden deprecated shims that still carry
|
|
// ContractFinal.Identity are part of the current effective surface. The
|
|
// collector must match that reachability; filtering on IsAvailableCommand (as
|
|
// the public-surface completeness walk does) would silently drop them.
|
|
func walkIdentityLeaves(cmd *cobra.Command, fn func(*cobra.Command)) {
|
|
if cmd == nil {
|
|
return
|
|
}
|
|
if cmd.Runnable() && !cmd.HasSubCommands() {
|
|
fn(cmd)
|
|
return
|
|
}
|
|
for _, child := range cmd.Commands() {
|
|
if child.Name() == "help" {
|
|
continue
|
|
}
|
|
walkIdentityLeaves(child, fn)
|
|
}
|
|
}
|
|
|
|
// CollectIdentitySpecs walks the runnable leaves under root and synthesises a
|
|
// CommandSpec from each leaf's ContractFinal.Identity (public visibility;
|
|
// SourceProductID defaulting is handled by the shared indexer). It is the
|
|
// single identity source consumed by BuildEffectiveCommandRegistry.
|
|
func CollectIdentitySpecs(root *cobra.Command) ([]CommandSpec, IdentityCollectionReport, error) {
|
|
report := IdentityCollectionReport{}
|
|
if root == nil {
|
|
return nil, report, fmt.Errorf("collect identity specs: root is nil")
|
|
}
|
|
exclusions, err := ReviewedRuntimeSchemaExclusions()
|
|
if err != nil {
|
|
return nil, report, err
|
|
}
|
|
excluded := make(map[string]bool, len(exclusions))
|
|
for _, exclusion := range exclusions {
|
|
path := normalizeSchemaCLIPath(exclusion.CLIPath)
|
|
if path != "" {
|
|
excluded[path] = true
|
|
}
|
|
}
|
|
|
|
specs := []CommandSpec{}
|
|
walkIdentityLeaves(root, func(leaf *cobra.Command) {
|
|
report.Leaves++
|
|
path := normalizeSchemaCLIPath(strings.Join(commandPathParts(leaf), " "))
|
|
if path != "" && excluded[path] {
|
|
report.Excluded++
|
|
return
|
|
}
|
|
final, ok := contractfinal.RuntimeContractFinal(leaf)
|
|
if !ok || final.Identity == nil {
|
|
// Alias / compatibility / deprecated leaves carry no Identity of
|
|
// their own; they resolve to a primary. Informational only.
|
|
report.NoIdentity = append(report.NoIdentity, path)
|
|
return
|
|
}
|
|
report.WithIdentity++
|
|
if leaf.Hidden {
|
|
report.HiddenPrimaries++
|
|
}
|
|
id := *final.Identity
|
|
specs = append(specs, CommandSpec{
|
|
CanonicalPath: strings.TrimSpace(id.CanonicalPath),
|
|
SourceProductID: strings.TrimSpace(id.SourceProductID),
|
|
PrimaryCLIPath: primaryCLIPathFromIdentity(id),
|
|
Aliases: append([]string(nil), id.Aliases...),
|
|
Visibility: SchemaVisibilityPublic,
|
|
Source: CommandSourceContractIdentity,
|
|
})
|
|
})
|
|
sort.Slice(specs, func(i, j int) bool { return specs[i].CanonicalPath < specs[j].CanonicalPath })
|
|
sort.Strings(report.NoIdentity)
|
|
if err := validateCollectedUniqueness(specs); err != nil {
|
|
return nil, report, err
|
|
}
|
|
return specs, report, nil
|
|
}
|
|
|
|
// validateCollectedUniqueness fails closed on identity collisions among the
|
|
// collected primaries: duplicate canonical paths, duplicate primary CLI paths,
|
|
// or an alias that collides with any primary path or another alias. These are
|
|
// authoring drift the collector must surface before the specs are trusted.
|
|
func validateCollectedUniqueness(specs []CommandSpec) error {
|
|
canonicals := make(map[string]bool, len(specs))
|
|
primaryPaths := make(map[string]bool, len(specs))
|
|
aliasOwners := make(map[string]string)
|
|
for _, spec := range specs {
|
|
canonical := strings.TrimSpace(spec.CanonicalPath)
|
|
if canonicals[canonical] {
|
|
return fmt.Errorf("collect identity specs: duplicate canonical path %q", canonical)
|
|
}
|
|
canonicals[canonical] = true
|
|
primary := normalizeSchemaCLIPath(spec.PrimaryCLIPath)
|
|
if primary != "" {
|
|
if primaryPaths[primary] {
|
|
return fmt.Errorf("collect identity specs: duplicate primary CLI path %q", primary)
|
|
}
|
|
primaryPaths[primary] = true
|
|
}
|
|
for _, rawAlias := range spec.Aliases {
|
|
alias := normalizeSchemaCLIPath(rawAlias)
|
|
if alias == "" {
|
|
continue
|
|
}
|
|
if primaryPaths[alias] {
|
|
return fmt.Errorf("collect identity specs: alias %q collides with a primary CLI path", alias)
|
|
}
|
|
if owner, taken := aliasOwners[alias]; taken {
|
|
return fmt.Errorf("collect identity specs: alias %q declared by both %q and %q", alias, owner, canonical)
|
|
}
|
|
aliasOwners[alias] = canonical
|
|
}
|
|
}
|
|
for alias, owner := range aliasOwners {
|
|
if primaryPaths[alias] {
|
|
return fmt.Errorf("collect identity specs: alias %q (from %q) collides with a primary CLI path", alias, owner)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DiagnoseMissingPrimaries resolves each spec that has no collected primary
|
|
// and reports why: the primary CLI path may not exist as a Cobra leaf, the
|
|
// leaf may lack ContractFinal, or its declared canonical may drift from the
|
|
// expected one.
|
|
func DiagnoseMissingPrimaries(root *cobra.Command, missing []CommandSpec) []string {
|
|
var diagnostics []string
|
|
for _, spec := range missing {
|
|
path := normalizeSchemaCLIPath(spec.PrimaryCLIPath)
|
|
match, err := resolveExactCobraPath(root, path)
|
|
if err != nil || match.Command == nil {
|
|
diagnostics = append(diagnostics, fmt.Sprintf("%s: primary path %q has no Cobra leaf (%v)", spec.CanonicalPath, path, err))
|
|
continue
|
|
}
|
|
final, ok := contractfinal.RuntimeContractFinal(match.Command)
|
|
if !ok || final.Identity == nil {
|
|
diagnostics = append(diagnostics, fmt.Sprintf("%s: leaf %q has no ContractFinal.Identity", spec.CanonicalPath, path))
|
|
continue
|
|
}
|
|
declared := strings.TrimSpace(final.Identity.CanonicalPath)
|
|
diagnostics = append(diagnostics, fmt.Sprintf("%s: leaf %q declares canonical %q (reviewed %q)", spec.CanonicalPath, path, declared, spec.CanonicalPath))
|
|
}
|
|
sort.Strings(diagnostics)
|
|
return diagnostics
|
|
}
|
|
|
|
// CompareCommandSpecEquivalence returns a human-readable, deterministic diff
|
|
// between two CommandSpec sets keyed by canonical path. An empty slice means
|
|
// the two sets agree on every compared field.
|
|
func CompareCommandSpecEquivalence(collected, reviewed []CommandSpec) []string {
|
|
byCanonical := func(specs []CommandSpec) map[string]CommandSpec {
|
|
m := make(map[string]CommandSpec, len(specs))
|
|
for _, spec := range specs {
|
|
m[strings.TrimSpace(spec.CanonicalPath)] = spec
|
|
}
|
|
return m
|
|
}
|
|
collectedMap := byCanonical(collected)
|
|
reviewedMap := byCanonical(reviewed)
|
|
|
|
var problems []string
|
|
for canonical := range reviewedMap {
|
|
if _, present := collectedMap[canonical]; !present {
|
|
problems = append(problems, fmt.Sprintf("missing from collected: %s", canonical))
|
|
}
|
|
}
|
|
for canonical := range collectedMap {
|
|
if _, present := reviewedMap[canonical]; !present {
|
|
problems = append(problems, fmt.Sprintf("extra in collected (not reviewed): %s", canonical))
|
|
}
|
|
}
|
|
for canonical, got := range collectedMap {
|
|
want, present := reviewedMap[canonical]
|
|
if !present {
|
|
continue
|
|
}
|
|
if got.PrimaryCLIPath != want.PrimaryCLIPath {
|
|
problems = append(problems, fmt.Sprintf("%s primary_cli_path: collected %q, reviewed %q", canonical, got.PrimaryCLIPath, want.PrimaryCLIPath))
|
|
}
|
|
if got.SourceProductID != want.SourceProductID {
|
|
problems = append(problems, fmt.Sprintf("%s source_product_id: collected %q, reviewed %q", canonical, got.SourceProductID, want.SourceProductID))
|
|
}
|
|
if got.Source != want.Source {
|
|
problems = append(problems, fmt.Sprintf("%s source: collected %q, reviewed %q", canonical, got.Source, want.Source))
|
|
}
|
|
if got.Visibility != want.Visibility {
|
|
problems = append(problems, fmt.Sprintf("%s visibility: collected %q, reviewed %q", canonical, got.Visibility, want.Visibility))
|
|
}
|
|
if !stringSlicesEqualAsSet(got.Aliases, want.Aliases) {
|
|
problems = append(problems, fmt.Sprintf("%s aliases: collected %v, reviewed %v", canonical, got.Aliases, want.Aliases))
|
|
}
|
|
}
|
|
sort.Strings(problems)
|
|
return problems
|
|
}
|