Merge remote-tracking branch 'origin/main' into fix/param-hallucination
# Conflicts: # internal/cli/param_concepts.json
This commit is contained in:
commit
1231e500a3
130 files changed
+11058
-3804
No files matched your search
@@ -24,7 +24,10 @@ import gen_shortcut_comparison as shortcut_source # noqa: E402
|
||||
|
||||
CATALOG_PATH = ROOT / "docs" / "shortcut-public-catalog.json"
|
||||
MONO_SKILL = ROOT / "skills" / "mono" / "SKILL.md"
|
||||
|
||||
SHARED_SKILL = ROOT / "skills" / "multi" / "dws-shared" / "SKILL.md"
|
||||
RUNTIME_CONTRACT_SOURCE = (
|
||||
ROOT / "skills" / "multi" / "dws-shared" / "references" / "runtime-contract.md"
|
||||
)
|
||||
SERVICE_TO_SKILL = {
|
||||
"aitable": ROOT / "skills" / "multi" / "dingtalk-aitable" / "SKILL.md",
|
||||
"attendance": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "attendance.md",
|
||||
@@ -48,6 +51,8 @@ MONO_START = "<!-- VISIBLE_SHORTCUTS_OVERVIEW_START -->"
|
||||
MONO_END = "<!-- VISIBLE_SHORTCUTS_OVERVIEW_END -->"
|
||||
PRODUCT_START = "<!-- VISIBLE_SHORTCUTS_START -->"
|
||||
PRODUCT_END = "<!-- VISIBLE_SHORTCUTS_END -->"
|
||||
RUNTIME_CONTRACT_START = "<!-- DWS_RUNTIME_CONTRACT_START -->"
|
||||
RUNTIME_CONTRACT_END = "<!-- DWS_RUNTIME_CONTRACT_END -->"
|
||||
|
||||
# Large, high-frequency product skills should route known intents directly and
|
||||
# keep their full shortcut inventory in Runtime Catalog/Schema. Add services
|
||||
@@ -92,6 +97,29 @@ def replace_block(text: str, start: str, end: str, block: str, fallback_anchor:
|
||||
return text.replace(fallback_anchor, block + "\n\n" + fallback_anchor, 1)
|
||||
|
||||
|
||||
def replace_required_block(text: str, start: str, end: str, block: str) -> str:
|
||||
if text.count(start) != 1 or text.count(end) != 1:
|
||||
raise RuntimeError(
|
||||
f"expected exactly one generated block {start!r} ... {end!r}"
|
||||
)
|
||||
before = text.split(start, 1)[0]
|
||||
after = text.split(end, 1)[1]
|
||||
return before + block + after
|
||||
|
||||
|
||||
def runtime_contract_block() -> str:
|
||||
contract = RUNTIME_CONTRACT_SOURCE.read_text(encoding="utf-8").strip()
|
||||
if not contract.startswith("## 最小 DWS 执行契约"):
|
||||
raise RuntimeError(
|
||||
f"runtime contract must start with its canonical heading: {RUNTIME_CONTRACT_SOURCE}"
|
||||
)
|
||||
return (
|
||||
f"{RUNTIME_CONTRACT_START}\n"
|
||||
f"{contract}\n"
|
||||
f"{RUNTIME_CONTRACT_END}"
|
||||
)
|
||||
|
||||
|
||||
def mono_overview(items: list[dict[str, Any]]) -> str:
|
||||
counts = Counter(item["service"] for item in items)
|
||||
rows = []
|
||||
@@ -161,6 +189,26 @@ def update_mono(items: list[dict[str, Any]], check: bool) -> list[Path]:
|
||||
return [MONO_SKILL] if apply_update(MONO_SKILL, text, updated, check) else []
|
||||
|
||||
|
||||
def update_runtime_contract(check: bool) -> list[Path]:
|
||||
block = runtime_contract_block()
|
||||
changed = []
|
||||
targets = [
|
||||
ROOT / "skills" / "multi" / "dingtalk-chat" / "SKILL.md",
|
||||
SHARED_SKILL,
|
||||
]
|
||||
for path in targets:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
updated = replace_required_block(
|
||||
text,
|
||||
RUNTIME_CONTRACT_START,
|
||||
RUNTIME_CONTRACT_END,
|
||||
block,
|
||||
)
|
||||
if apply_update(path, text, updated, check):
|
||||
changed.append(path)
|
||||
return changed
|
||||
|
||||
|
||||
def update_product_skills(items: list[dict[str, Any]], check: bool) -> list[Path]:
|
||||
by_service: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
||||
for item in items:
|
||||
@@ -190,7 +238,8 @@ def main() -> int:
|
||||
args = parser.parse_args()
|
||||
|
||||
items = collect_visible()
|
||||
changed = update_mono(items, args.check)
|
||||
changed = update_runtime_contract(args.check)
|
||||
changed.extend(update_mono(items, args.check))
|
||||
changed.extend(update_product_skills(items, args.check))
|
||||
if args.check and changed:
|
||||
print("run: python3 scripts/gen_skill_shortcut_sections.py", file=sys.stderr)
|
||||
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
cd "$ROOT"
|
||||
. "$ROOT/scripts/policy/policy-runtime.sh"
|
||||
policy_prepare_runtime "$ROOT"
|
||||
|
||||
TMP_ROOT="$(policy_runtime_mktemp_dir dws-multi-im-skill-chain)"
|
||||
CHECKER="$TMP_ROOT/multi-im-skill-chain"
|
||||
CHECK_HOME="$TMP_ROOT/home"
|
||||
mkdir -p "$CHECK_HOME"
|
||||
trap 'rm -rf "$TMP_ROOT"' EXIT HUP INT TERM
|
||||
|
||||
go build -o "$CHECKER" ./scripts/policy/multi-im-skill-chain
|
||||
HOME="$CHECK_HOME" DWS_LANG=zh "$CHECKER"
|
||||
@@ -7,8 +7,12 @@ cd "$ROOT"
|
||||
python3 scripts/gen_skill_shortcut_sections.py --check
|
||||
|
||||
chat_skill="skills/multi/dingtalk-chat/SKILL.md"
|
||||
event_skill="skills/multi/dingtalk-event/SKILL.md"
|
||||
mono_skill="skills/mono/SKILL.md"
|
||||
chat_max_bytes=14000
|
||||
runtime_contract="skills/multi/dws-shared/references/runtime-contract.md"
|
||||
chat_max_bytes=10000
|
||||
event_max_bytes=10000
|
||||
runtime_contract_max_bytes=3000
|
||||
|
||||
chat_bytes="$(wc -c < "$chat_skill" | tr -d ' ')"
|
||||
if [ "$chat_bytes" -gt "$chat_max_bytes" ]; then
|
||||
@@ -17,6 +21,20 @@ if [ "$chat_bytes" -gt "$chat_max_bytes" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
event_bytes="$(wc -c < "$event_skill" | tr -d ' ')"
|
||||
if [ "$event_bytes" -gt "$event_max_bytes" ]; then
|
||||
printf '%s\n' \
|
||||
"skill context budget exceeded: $event_skill is ${event_bytes} bytes (max ${event_max_bytes})" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
runtime_contract_bytes="$(wc -c < "$runtime_contract" | tr -d ' ')"
|
||||
if [ "$runtime_contract_bytes" -gt "$runtime_contract_max_bytes" ]; then
|
||||
printf '%s\n' \
|
||||
"skill context budget exceeded: $runtime_contract is ${runtime_contract_bytes} bytes (max ${runtime_contract_max_bytes})" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
shortcut_rows="$(
|
||||
awk '
|
||||
/<!-- VISIBLE_SHORTCUTS_START -->/ { in_block = 1; next }
|
||||
@@ -31,6 +49,56 @@ if [ "$shortcut_rows" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for required_heading in \
|
||||
"## 最小 DWS 执行契约" \
|
||||
"## Golden Route" \
|
||||
"## 关键结果语义" \
|
||||
"## 按需加载" \
|
||||
"## 错误最短路径"
|
||||
do
|
||||
if ! grep -Fq "$required_heading" "$chat_skill"; then
|
||||
printf '%s\n' \
|
||||
"skill value regression: $chat_skill is missing $required_heading" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
for required_route in \
|
||||
"+dm" \
|
||||
"+send-to-group" \
|
||||
"+messages-send" \
|
||||
"+chat-messages" \
|
||||
"+search-msg" \
|
||||
"--download-resources" \
|
||||
"+conversation-list-top"
|
||||
do
|
||||
if ! grep -Fq -- "$required_route" "$chat_skill"; then
|
||||
printf '%s\n' \
|
||||
"skill route regression: $chat_skill is missing $required_route" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
for forbidden_route in \
|
||||
"## 标准 SOP" \
|
||||
"dws aisearch person --keyword" \
|
||||
"dws chat message send-by-webhook" \
|
||||
"dt_media_upload" \
|
||||
"MUST 先用 Read"
|
||||
do
|
||||
if grep -Fq "$forbidden_route" "$chat_skill"; then
|
||||
printf '%s\n' \
|
||||
"skill route regression: $chat_skill restored legacy route: $forbidden_route" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if grep -Fq "../dws-shared/SKILL.md" "$chat_skill"; then
|
||||
printf '%s\n' \
|
||||
"skill context regression: $chat_skill requires full dws-shared cold-start loading" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Fq "充分阅读产品参考文件" "$mono_skill"; then
|
||||
printf '%s\n' \
|
||||
"skill context budget regression: $mono_skill requires full product-reference loading" >&2
|
||||
@@ -38,4 +106,4 @@ if grep -Fq "充分阅读产品参考文件" "$mono_skill"; then
|
||||
fi
|
||||
|
||||
printf '%s\n' \
|
||||
"skill context budget: ok (chat_bytes=$chat_bytes max=$chat_max_bytes shortcut_rows=$shortcut_rows)"
|
||||
"skill context budget: ok (chat_bytes=$chat_bytes max=$chat_max_bytes event_bytes=$event_bytes event_max=$event_max_bytes runtime_contract_bytes=$runtime_contract_bytes runtime_contract_max=$runtime_contract_max_bytes shortcut_rows=$shortcut_rows)"
|
||||
@@ -0,0 +1,495 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
// Command multi-im-skill-chain verifies that reviewed high-frequency IM
|
||||
// intents keep one default route across Skill references and Agent selection.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const manifestRelativePath = "scripts/policy/multi-im-skill-chain/testdata/intent_routes.json"
|
||||
|
||||
var (
|
||||
intentIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*$`)
|
||||
reasonCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
|
||||
intentMarker = regexp.MustCompile(`<!--\s*dws-intent:\s*([a-z0-9][a-z0-9._-]*)\s*-->`)
|
||||
mainGetwd = os.Getwd
|
||||
mainExit = os.Exit
|
||||
markerOpen = os.Open
|
||||
buildEffective = cli.BuildEffectiveCommandRegistry
|
||||
bindEffective = cli.BindEffectiveCommandRegistry
|
||||
)
|
||||
|
||||
type routeManifest struct {
|
||||
Version int `json:"version"`
|
||||
MarkerRoots []string `json:"marker_roots"`
|
||||
Intents []intentRoute `json:"intents"`
|
||||
RetiredScripts []string `json:"retired_scripts"`
|
||||
ContractReference string `json:"contract_reference"`
|
||||
HandoffReference string `json:"handoff_reference"`
|
||||
}
|
||||
|
||||
type intentRoute struct {
|
||||
ID string `json:"id"`
|
||||
PreferredTool string `json:"preferred_tool"`
|
||||
AllowedFallbacks []routeFallback `json:"allowed_fallbacks,omitempty"`
|
||||
ForbiddenDefaultTools []string `json:"forbidden_default_tools,omitempty"`
|
||||
References []string `json:"references"`
|
||||
}
|
||||
|
||||
type routeFallback struct {
|
||||
Tool string `json:"tool"`
|
||||
ReasonCode string `json:"reason_code"`
|
||||
}
|
||||
|
||||
type toolFact struct {
|
||||
Canonical string
|
||||
PrimaryPath string
|
||||
Confirmation string
|
||||
UseWhen []string
|
||||
AvoidWhen []string
|
||||
HasMeta bool
|
||||
}
|
||||
|
||||
func main() {
|
||||
rootPath, err := mainGetwd()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
mainExit(2)
|
||||
return
|
||||
}
|
||||
mainExit(run(rootPath, app.NewRootCommand(), os.Stdout, os.Stderr))
|
||||
}
|
||||
|
||||
func run(rootPath string, root *cobra.Command, stdout, stderr io.Writer) int {
|
||||
manifest, err := loadManifest(filepath.Join(rootPath, manifestRelativePath))
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return 2
|
||||
}
|
||||
effective, err := buildEffective(root)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "build effective CommandRegistry: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
bound, err := bindEffective(root, effective)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "bind effective CommandRegistry: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
|
||||
tools := make(map[string]toolFact, len(bound.ByCanonical))
|
||||
for canonical, item := range bound.ByCanonical {
|
||||
meta, ok := cli.ResolveMeta(item.PrimaryCLIPath)
|
||||
tools[canonical] = toolFact{
|
||||
Canonical: canonical,
|
||||
PrimaryPath: item.PrimaryCLIPath,
|
||||
Confirmation: meta.Safety.Confirmation,
|
||||
UseWhen: append([]string(nil), meta.Selection.UseWhen...),
|
||||
AvoidWhen: append([]string(nil), meta.Selection.AvoidWhen...),
|
||||
HasMeta: ok,
|
||||
}
|
||||
}
|
||||
|
||||
failures := validateManifest(rootPath, manifest, tools)
|
||||
sort.Strings(failures)
|
||||
if len(failures) > 0 {
|
||||
fmt.Fprintf(stderr, "multi IM Skill chain check failed (%d problems):\n", len(failures))
|
||||
for _, failure := range failures {
|
||||
fmt.Fprintf(stderr, " - %s\n", failure)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(stdout, "multi IM Skill chain check: ok (%d intents)\n", len(manifest.Intents))
|
||||
return 0
|
||||
}
|
||||
|
||||
func loadManifest(path string) (routeManifest, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return routeManifest{}, fmt.Errorf("read IM intent route manifest: %w", err)
|
||||
}
|
||||
decoder := json.NewDecoder(strings.NewReader(string(data)))
|
||||
decoder.DisallowUnknownFields()
|
||||
var manifest routeManifest
|
||||
if err := decoder.Decode(&manifest); err != nil {
|
||||
return routeManifest{}, fmt.Errorf("decode IM intent route manifest: %w", err)
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func validateManifest(rootPath string, manifest routeManifest, tools map[string]toolFact) []string {
|
||||
var failures []string
|
||||
if manifest.Version != 3 {
|
||||
failures = append(failures, fmt.Sprintf("manifest version = %d, want 3", manifest.Version))
|
||||
}
|
||||
if len(manifest.MarkerRoots) == 0 {
|
||||
failures = append(failures, "manifest marker_roots must not be empty")
|
||||
}
|
||||
if len(manifest.Intents) == 0 {
|
||||
failures = append(failures, "manifest intents must not be empty")
|
||||
}
|
||||
failures = append(failures, validateRetiredScripts(rootPath, manifest.RetiredScripts)...)
|
||||
failures = append(failures, validateTypedContractReference(rootPath, manifest.ContractReference)...)
|
||||
failures = append(failures, validateEventHandoffReference(rootPath, manifest.HandoffReference)...)
|
||||
|
||||
intentByID := make(map[string]intentRoute, len(manifest.Intents))
|
||||
for _, route := range manifest.Intents {
|
||||
if !intentIDPattern.MatchString(route.ID) {
|
||||
failures = append(failures, fmt.Sprintf("intent id %q is invalid", route.ID))
|
||||
continue
|
||||
}
|
||||
if _, exists := intentByID[route.ID]; exists {
|
||||
failures = append(failures, fmt.Sprintf("duplicate intent id %q", route.ID))
|
||||
continue
|
||||
}
|
||||
intentByID[route.ID] = route
|
||||
preferred, ok := tools[route.PreferredTool]
|
||||
if !ok {
|
||||
failures = append(failures, fmt.Sprintf("intent %s preferred tool %q is absent from BoundCommandRegistry", route.ID, route.PreferredTool))
|
||||
continue
|
||||
}
|
||||
if !preferred.HasMeta || preferred.Confirmation == "" {
|
||||
failures = append(failures, fmt.Sprintf("intent %s preferred tool %q is absent from ResolveMeta delivery", route.ID, route.PreferredTool))
|
||||
}
|
||||
if len(preferred.UseWhen) == 0 || len(preferred.AvoidWhen) == 0 {
|
||||
failures = append(failures, fmt.Sprintf("intent %s preferred tool %q needs non-empty use_when and avoid_when", route.ID, route.PreferredTool))
|
||||
}
|
||||
|
||||
seenFallbacks := map[string]bool{}
|
||||
for _, fallback := range route.AllowedFallbacks {
|
||||
if fallback.Tool == "" || seenFallbacks[fallback.Tool] {
|
||||
failures = append(failures, fmt.Sprintf("intent %s has empty or duplicate allowed fallback %q", route.ID, fallback.Tool))
|
||||
continue
|
||||
}
|
||||
seenFallbacks[fallback.Tool] = true
|
||||
if !reasonCodePattern.MatchString(fallback.ReasonCode) {
|
||||
failures = append(failures, fmt.Sprintf("intent %s fallback %q has invalid reason_code %q", route.ID, fallback.Tool, fallback.ReasonCode))
|
||||
}
|
||||
fact, exists := tools[fallback.Tool]
|
||||
if !exists {
|
||||
failures = append(failures, fmt.Sprintf("intent %s fallback tool %q is absent from BoundCommandRegistry", route.ID, fallback.Tool))
|
||||
continue
|
||||
}
|
||||
if !fact.HasMeta || fact.Confirmation == "" {
|
||||
failures = append(failures, fmt.Sprintf("intent %s fallback tool %q is absent from ResolveMeta delivery", route.ID, fallback.Tool))
|
||||
} else if fact.Confirmation != preferred.Confirmation {
|
||||
failures = append(failures, fmt.Sprintf("intent %s fallback %q confirmation %q differs from preferred %q confirmation %q", route.ID, fallback.Tool, fact.Confirmation, route.PreferredTool, preferred.Confirmation))
|
||||
}
|
||||
}
|
||||
|
||||
seenForbidden := map[string]bool{}
|
||||
for _, canonical := range route.ForbiddenDefaultTools {
|
||||
if canonical == "" || seenForbidden[canonical] {
|
||||
failures = append(failures, fmt.Sprintf("intent %s has empty or duplicate forbidden default %q", route.ID, canonical))
|
||||
continue
|
||||
}
|
||||
seenForbidden[canonical] = true
|
||||
entry, exists := tools[canonical]
|
||||
if !exists {
|
||||
failures = append(failures, fmt.Sprintf("intent %s forbidden default tool %q is absent from BoundCommandRegistry", route.ID, canonical))
|
||||
continue
|
||||
}
|
||||
if !entry.HasMeta || entry.Confirmation == "" {
|
||||
failures = append(failures, fmt.Sprintf("intent %s forbidden default tool %q is absent from ResolveMeta delivery", route.ID, canonical))
|
||||
}
|
||||
if len(entry.UseWhen) == 0 || len(entry.AvoidWhen) == 0 {
|
||||
failures = append(failures, fmt.Sprintf("intent %s forbidden default tool %q needs non-empty use_when and avoid_when", route.ID, canonical))
|
||||
}
|
||||
}
|
||||
|
||||
seenReferences := map[string]bool{}
|
||||
for _, reference := range route.References {
|
||||
if !safeRepositoryPath(reference) || filepath.Ext(reference) != ".md" {
|
||||
failures = append(failures, fmt.Sprintf("intent %s has invalid reference %q", route.ID, reference))
|
||||
continue
|
||||
}
|
||||
if seenReferences[reference] {
|
||||
failures = append(failures, fmt.Sprintf("intent %s repeats reference %q", route.ID, reference))
|
||||
continue
|
||||
}
|
||||
seenReferences[reference] = true
|
||||
if _, err := os.Stat(filepath.Join(rootPath, filepath.FromSlash(reference))); err != nil {
|
||||
failures = append(failures, fmt.Sprintf("intent %s reference %q does not exist", route.ID, reference))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
markerFailures, markers := scanMarkers(rootPath, manifest.MarkerRoots, intentByID, tools)
|
||||
failures = append(failures, markerFailures...)
|
||||
for _, route := range manifest.Intents {
|
||||
for _, reference := range route.References {
|
||||
key := route.ID + "\x00" + filepath.ToSlash(reference)
|
||||
switch markers[key] {
|
||||
case 0:
|
||||
failures = append(failures, fmt.Sprintf("intent %s reference %s is missing its dws-intent marker", route.ID, reference))
|
||||
case 1:
|
||||
default:
|
||||
failures = append(failures, fmt.Sprintf("intent %s reference %s has %d dws-intent markers, want 1", route.ID, reference, markers[key]))
|
||||
}
|
||||
}
|
||||
}
|
||||
return failures
|
||||
}
|
||||
|
||||
func validateRetiredScripts(rootPath string, paths []string) []string {
|
||||
var failures []string
|
||||
if len(paths) == 0 {
|
||||
return []string{"manifest retired_scripts must not be empty"}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, path := range paths {
|
||||
if !safeRepositoryPath(path) || filepath.Ext(path) != ".py" {
|
||||
failures = append(failures, fmt.Sprintf("invalid retired script path %q", path))
|
||||
continue
|
||||
}
|
||||
if seen[path] {
|
||||
failures = append(failures, fmt.Sprintf("duplicate retired script path %q", path))
|
||||
continue
|
||||
}
|
||||
seen[path] = true
|
||||
if _, err := os.Stat(filepath.Join(rootPath, filepath.FromSlash(path))); err == nil {
|
||||
failures = append(failures, fmt.Sprintf("retired script %s was republished", path))
|
||||
} else if !os.IsNotExist(err) {
|
||||
failures = append(failures, fmt.Sprintf("inspect retired script %s: %v", path, err))
|
||||
}
|
||||
}
|
||||
return failures
|
||||
}
|
||||
|
||||
func validateTypedContractReference(rootPath, relative string) []string {
|
||||
if !safeRepositoryPath(relative) || filepath.Ext(relative) != ".md" {
|
||||
return []string{fmt.Sprintf("invalid contract_reference %q", relative)}
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(rootPath, filepath.FromSlash(relative)))
|
||||
if err != nil {
|
||||
return []string{fmt.Sprintf("read typed contract reference %s: %v", relative, err)}
|
||||
}
|
||||
data = bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n"))
|
||||
blocks := []struct {
|
||||
name string
|
||||
expected string
|
||||
}{
|
||||
{name: "MESSAGE_RESULT", expected: renderMessageResultContract()},
|
||||
{name: "IDENTITY_CAPABILITY", expected: renderIdentityCapabilityContract()},
|
||||
{name: "CARD_WORKFLOW", expected: renderCardWorkflowContract()},
|
||||
{name: "CAPABILITY_BOUNDARY", expected: renderCapabilityBoundaryContract()},
|
||||
}
|
||||
var failures []string
|
||||
for _, block := range blocks {
|
||||
start := "<!-- DWS_" + block.name + "_CONTRACT_START -->"
|
||||
end := "<!-- DWS_" + block.name + "_CONTRACT_END -->"
|
||||
expected := start + "\n" + block.expected + "\n" + end
|
||||
if bytes.Count(data, []byte(start)) != 1 || bytes.Count(data, []byte(end)) != 1 {
|
||||
failures = append(failures, fmt.Sprintf("%s must contain exactly one %s contract marker pair", relative, block.name))
|
||||
continue
|
||||
}
|
||||
startAt := bytes.Index(data, []byte(start))
|
||||
endAt := bytes.Index(data[startAt:], []byte(end))
|
||||
if endAt < 0 {
|
||||
failures = append(failures, fmt.Sprintf("%s has malformed %s contract markers", relative, block.name))
|
||||
continue
|
||||
}
|
||||
actual := string(data[startAt : startAt+endAt+len(end)])
|
||||
if actual != expected {
|
||||
failures = append(failures, fmt.Sprintf("%s %s contract differs from Runtime typed descriptor", relative, block.name))
|
||||
}
|
||||
}
|
||||
return failures
|
||||
}
|
||||
|
||||
func validateEventHandoffReference(rootPath, relative string) []string {
|
||||
if !safeRepositoryPath(relative) || filepath.Ext(relative) != ".md" {
|
||||
return []string{fmt.Sprintf("invalid handoff_reference %q", relative)}
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(rootPath, filepath.FromSlash(relative)))
|
||||
if err != nil {
|
||||
return []string{fmt.Sprintf("read event handoff reference %s: %v", relative, err)}
|
||||
}
|
||||
data = bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n"))
|
||||
const expected = `<!-- DWS_EVENT_CHAT_HANDOFF_START -->
|
||||
| event field | exact chat target |
|
||||
|---|---|
|
||||
| ` + "`conversation_id`" + ` | ` + "`dws chat +messages-send --as user --group <conversation_id>`" + ` |
|
||||
| ` + "`sender_open_dingtalk_id`" + ` | ` + "`dws chat +messages-send --as user --open-dingtalk-id <sender_open_dingtalk_id>`" + ` |
|
||||
<!-- DWS_EVENT_CHAT_HANDOFF_END -->`
|
||||
start := bytes.Index(data, []byte("<!-- DWS_EVENT_CHAT_HANDOFF_START -->"))
|
||||
endMarker := "<!-- DWS_EVENT_CHAT_HANDOFF_END -->"
|
||||
if start < 0 || bytes.Count(data, []byte("<!-- DWS_EVENT_CHAT_HANDOFF_START -->")) != 1 ||
|
||||
bytes.Count(data, []byte(endMarker)) != 1 {
|
||||
return []string{fmt.Sprintf("%s must contain exactly one event-to-chat handoff marker pair", relative)}
|
||||
}
|
||||
end := bytes.Index(data[start:], []byte(endMarker))
|
||||
if end < 0 || string(data[start:start+end+len(endMarker)]) != expected {
|
||||
return []string{fmt.Sprintf("%s event-to-chat handoff differs from exact stable-ID mapping", relative)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderMessageResultContract() string {
|
||||
contract := chatmsg.CurrentMessageResultContract()
|
||||
return fmt.Sprintf("- `version`: `%s`\n- `message_fields`: %s\n- `envelope_fields`: %s",
|
||||
contract.Version, markdownCodeList(contract.MessageFields), markdownCodeList(contract.EnvelopeFields))
|
||||
}
|
||||
|
||||
func renderIdentityCapabilityContract() string {
|
||||
var rows []string
|
||||
for _, capability := range chatshortcut.MessageIdentityCapabilities() {
|
||||
rows = append(rows, fmt.Sprintf("| `%s` | %s | %s | %s | %s | `%t` | `%t` |",
|
||||
capability.Identity, markdownBreakList(capability.Targets), markdownBreakList(capability.ContentTypes),
|
||||
markdownBreakList(capability.NaturalTargets), markdownBreakList(capability.MentionTargets),
|
||||
capability.IdempotencyKeys, capability.BatchLedger))
|
||||
}
|
||||
return "| identity | targets | content types | natural targets | mention targets | idempotency keys | batch ledger |\n" +
|
||||
"|---|---|---|---|---|---:|---:|\n" + strings.Join(rows, "\n")
|
||||
}
|
||||
|
||||
func renderCardWorkflowContract() string {
|
||||
contract := chatshortcut.CurrentCardWorkflowContract()
|
||||
statuses := make([]string, 0, len(contract.FlowStatuses))
|
||||
for _, status := range contract.FlowStatuses {
|
||||
statuses = append(statuses, fmt.Sprintf("%d=%s", status.Value, status.Name))
|
||||
}
|
||||
return fmt.Sprintf("- `version`: `%s`\n- `targets`: %s\n- `content_types`: %s\n- `flow_statuses`: %s\n- `callback_supported`: `%t`",
|
||||
contract.Version, markdownCodeList(contract.Targets), markdownCodeList(contract.ContentTypes),
|
||||
markdownCodeList(statuses), contract.CallbackSupported)
|
||||
}
|
||||
|
||||
func renderCapabilityBoundaryContract() string {
|
||||
rows := make([]string, 0)
|
||||
for _, boundary := range chatshortcut.CurrentIMCapabilityBoundaries() {
|
||||
rows = append(rows, fmt.Sprintf("| `%s` | `%t` | %s |", boundary.Capability, boundary.Supported, boundary.Alternative))
|
||||
}
|
||||
return "| capability | supported | current route / boundary |\n|---|---:|---|\n" + strings.Join(rows, "\n")
|
||||
}
|
||||
|
||||
func markdownCodeList(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return "—"
|
||||
}
|
||||
quoted := make([]string, len(values))
|
||||
for index, value := range values {
|
||||
quoted[index] = "`" + value + "`"
|
||||
}
|
||||
return strings.Join(quoted, ", ")
|
||||
}
|
||||
|
||||
func markdownBreakList(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return "—"
|
||||
}
|
||||
quoted := make([]string, len(values))
|
||||
for index, value := range values {
|
||||
quoted[index] = "`" + value + "`"
|
||||
}
|
||||
return strings.Join(quoted, "<br>")
|
||||
}
|
||||
|
||||
func scanMarkers(rootPath string, roots []string, intents map[string]intentRoute, tools map[string]toolFact) ([]string, map[string]int) {
|
||||
var failures []string
|
||||
markers := map[string]int{}
|
||||
for _, relativeRoot := range roots {
|
||||
if !safeRepositoryPath(relativeRoot) {
|
||||
failures = append(failures, fmt.Sprintf("invalid marker root %q", relativeRoot))
|
||||
continue
|
||||
}
|
||||
absoluteRoot := filepath.Join(rootPath, filepath.FromSlash(relativeRoot))
|
||||
err := filepath.WalkDir(absoluteRoot, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() || filepath.Ext(path) != ".md" {
|
||||
return nil
|
||||
}
|
||||
file, err := markerOpen(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
relative, _ := filepath.Rel(rootPath, path)
|
||||
relative = filepath.ToSlash(relative)
|
||||
scanner := bufio.NewScanner(file)
|
||||
scanner.Buffer(make([]byte, 64*1024), 4*1024*1024)
|
||||
lineNumber := 0
|
||||
for scanner.Scan() {
|
||||
lineNumber++
|
||||
line := scanner.Text()
|
||||
for _, match := range intentMarker.FindAllStringSubmatch(line, -1) {
|
||||
id := match[1]
|
||||
route, ok := intents[id]
|
||||
if !ok {
|
||||
failures = append(failures, fmt.Sprintf("%s:%d uses unknown dws-intent %q", relative, lineNumber, id))
|
||||
continue
|
||||
}
|
||||
if !stringSliceContains(route.References, relative) {
|
||||
failures = append(failures, fmt.Sprintf("%s:%d uses undeclared dws-intent %q", relative, lineNumber, id))
|
||||
}
|
||||
markers[id+"\x00"+relative]++
|
||||
preferred, exists := tools[route.PreferredTool]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
if !containsCLIPath(line, preferred.PrimaryPath) {
|
||||
failures = append(failures, fmt.Sprintf("%s:%d intent %s must contain preferred path `dws %s` on the marker line", relative, lineNumber, id, preferred.PrimaryPath))
|
||||
}
|
||||
for _, canonical := range route.ForbiddenDefaultTools {
|
||||
if fact, ok := tools[canonical]; ok && containsCLIPath(line, fact.PrimaryPath) {
|
||||
failures = append(failures, fmt.Sprintf("%s:%d intent %s uses forbidden default `dws %s`", relative, lineNumber, id, fact.PrimaryPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return scanner.Err()
|
||||
})
|
||||
if err != nil {
|
||||
failures = append(failures, fmt.Sprintf("scan marker root %s: %v", relativeRoot, err))
|
||||
}
|
||||
}
|
||||
return failures, markers
|
||||
}
|
||||
|
||||
func safeRepositoryPath(path string) bool {
|
||||
path = filepath.ToSlash(strings.TrimSpace(path))
|
||||
return path != "" && path != "." && !filepath.IsAbs(path) && path != ".." && !strings.HasPrefix(path, "../") && !strings.Contains(path, "/../")
|
||||
}
|
||||
|
||||
func containsCLIPath(line, path string) bool {
|
||||
needle := "dws " + strings.TrimSpace(path)
|
||||
index := strings.Index(line, needle)
|
||||
if index < 0 {
|
||||
return false
|
||||
}
|
||||
end := index + len(needle)
|
||||
if end == len(line) {
|
||||
return true
|
||||
}
|
||||
next := line[end]
|
||||
return next == ' ' || next == '`' || next == '<' || next == '|' || next == ','
|
||||
}
|
||||
|
||||
func stringSliceContains(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if filepath.ToSlash(value) == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRunRepositoryAndSetupFailures(t *testing.T) {
|
||||
root, err := filepath.Abs("../../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := run(root, app.NewRootCommand(), &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("repository policy run = %d, stdout=%s stderr=%s", code, stdout.String(), stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "multi IM Skill chain check: ok") {
|
||||
t.Fatalf("success output = %q", stdout.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := run(t.TempDir(), &cobra.Command{Use: "dws"}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "read IM intent route manifest") {
|
||||
t.Fatalf("missing-manifest run = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := run(root, &cobra.Command{Use: "dws"}, &stdout, &stderr); code != 1 || !strings.Contains(stderr.String(), "absent from BoundCommandRegistry") {
|
||||
t.Fatalf("unbound-root run = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
|
||||
oldBuild, oldBind := buildEffective, bindEffective
|
||||
t.Cleanup(func() { buildEffective, bindEffective = oldBuild, oldBind })
|
||||
buildEffective = func(*cobra.Command) (cli.EffectiveCommandRegistry, error) {
|
||||
return cli.EffectiveCommandRegistry{}, os.ErrInvalid
|
||||
}
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := run(root, app.NewRootCommand(), &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "build effective CommandRegistry") {
|
||||
t.Fatalf("registry-build-failure run = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
buildEffective = oldBuild
|
||||
|
||||
bindEffective = func(*cobra.Command, cli.EffectiveCommandRegistry) (cli.BoundCommandRegistry, error) {
|
||||
return cli.BoundCommandRegistry{}, os.ErrInvalid
|
||||
}
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := run(root, app.NewRootCommand(), &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "bind effective CommandRegistry") {
|
||||
t.Fatalf("binding-failure run = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
bindEffective = oldBind
|
||||
|
||||
failureRoot := t.TempDir()
|
||||
writeTestFile(t, failureRoot, manifestRelativePath, `{"version":1}`)
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := run(failureRoot, app.NewRootCommand(), &stdout, &stderr); code != 1 || !strings.Contains(stderr.String(), "multi IM Skill chain check failed") {
|
||||
t.Fatalf("validation-failure run = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMainUsesInjectableProcessBoundaries(t *testing.T) {
|
||||
oldGetwd, oldExit := mainGetwd, mainExit
|
||||
t.Cleanup(func() { mainGetwd, mainExit = oldGetwd, oldExit })
|
||||
var exitCode int
|
||||
mainExit = func(code int) { exitCode = code }
|
||||
mainGetwd = func() (string, error) { return "", os.ErrPermission }
|
||||
main()
|
||||
if exitCode != 2 {
|
||||
t.Fatalf("getwd failure exit = %d", exitCode)
|
||||
}
|
||||
root, err := filepath.Abs("../../..")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mainGetwd = func() (string, error) { return root, nil }
|
||||
main()
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("successful main exit = %d", exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateManifestAcceptsExactReviewedRoute(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, root, "skills/multi/dingtalk-chat/SKILL.md", "| 发消息 | `dws chat +send` | <!-- dws-intent: chat.send -->\n")
|
||||
writeTypedContractReference(t, root, "skills/multi/dingtalk-chat/references/contracts.md")
|
||||
writeEventHandoffReference(t, root, "skills/multi/dingtalk-event/references/event-im-output.md")
|
||||
manifest := routeManifest{
|
||||
Version: 3,
|
||||
MarkerRoots: []string{"skills/multi/dingtalk-chat"},
|
||||
RetiredScripts: []string{"skills/multi/dingtalk-chat/scripts/retired.py"},
|
||||
ContractReference: "skills/multi/dingtalk-chat/references/contracts.md",
|
||||
HandoffReference: "skills/multi/dingtalk-event/references/event-im-output.md",
|
||||
Intents: []intentRoute{{
|
||||
ID: "chat.send",
|
||||
PreferredTool: "chat.shortcut_send",
|
||||
ForbiddenDefaultTools: []string{"chat.atomic_send"},
|
||||
References: []string{"skills/multi/dingtalk-chat/SKILL.md"},
|
||||
}},
|
||||
}
|
||||
tools := map[string]toolFact{
|
||||
"chat.shortcut_send": {Canonical: "chat.shortcut_send", PrimaryPath: "chat +send", Confirmation: "user_required", UseWhen: []string{"普通发送"}, AvoidWhen: []string{"底层字段另选"}, HasMeta: true},
|
||||
"chat.atomic_send": {Canonical: "chat.atomic_send", PrimaryPath: "chat message send", Confirmation: "not_required", UseWhen: []string{"底层字段"}, AvoidWhen: []string{"普通发送使用 +send"}, HasMeta: true},
|
||||
}
|
||||
if failures := validateManifest(root, manifest, tools); len(failures) != 0 {
|
||||
t.Fatalf("failures = %v", failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateManifestRejectsWrongMarkerAndSafetyDowngrade(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, root, "skills/multi/dingtalk-chat/SKILL.md", "| 发消息 | `dws chat message send` | <!-- dws-intent: chat.send -->\n")
|
||||
writeTypedContractReference(t, root, "skills/multi/dingtalk-chat/references/contracts.md")
|
||||
writeEventHandoffReference(t, root, "skills/multi/dingtalk-event/references/event-im-output.md")
|
||||
manifest := routeManifest{
|
||||
Version: 3,
|
||||
MarkerRoots: []string{"skills/multi/dingtalk-chat"},
|
||||
RetiredScripts: []string{"skills/multi/dingtalk-chat/scripts/retired.py"},
|
||||
ContractReference: "skills/multi/dingtalk-chat/references/contracts.md",
|
||||
HandoffReference: "skills/multi/dingtalk-event/references/event-im-output.md",
|
||||
Intents: []intentRoute{{
|
||||
ID: "chat.send",
|
||||
PreferredTool: "chat.shortcut_send",
|
||||
AllowedFallbacks: []routeFallback{{Tool: "chat.atomic_send", ReasonCode: "raw_field"}},
|
||||
ForbiddenDefaultTools: []string{"chat.atomic_send"},
|
||||
References: []string{"skills/multi/dingtalk-chat/SKILL.md"},
|
||||
}},
|
||||
}
|
||||
tools := map[string]toolFact{
|
||||
"chat.shortcut_send": {Canonical: "chat.shortcut_send", PrimaryPath: "chat +send", Confirmation: "user_required", UseWhen: []string{"普通发送"}, AvoidWhen: []string{"底层字段另选"}, HasMeta: true},
|
||||
"chat.atomic_send": {Canonical: "chat.atomic_send", PrimaryPath: "chat message send", Confirmation: "not_required", UseWhen: []string{"普通发送"}, HasMeta: true},
|
||||
}
|
||||
failures := strings.Join(validateManifest(root, manifest, tools), "\n")
|
||||
for _, want := range []string{"confirmation", "needs non-empty use_when and avoid_when", "must contain preferred path", "uses forbidden default"} {
|
||||
if !strings.Contains(failures, want) {
|
||||
t.Fatalf("failures = %s, want %q", failures, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLoadManifestRejectsUnknownFields(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "manifest.json")
|
||||
if err := os.WriteFile(path, []byte(`{"version":1,"unknown":true}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadManifest(path); err == nil || !strings.Contains(err.Error(), "unknown field") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateRetiredScriptsRejectsRepublishedAndDuplicatePaths(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, root, "skills/multi/dingtalk-chat/scripts/export.py", "republished\n")
|
||||
failures := strings.Join(validateRetiredScripts(root, []string{
|
||||
"skills/multi/dingtalk-chat/scripts/export.py",
|
||||
"skills/multi/dingtalk-chat/scripts/export.py",
|
||||
"../unsafe.py",
|
||||
}), "\n")
|
||||
for _, want := range []string{"was republished", "duplicate retired", "invalid retired"} {
|
||||
if !strings.Contains(failures, want) {
|
||||
t.Fatalf("failures = %s, want %q", failures, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateTypedContractReferenceRejectsDrift(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTypedContractReference(t, root, "contracts.md")
|
||||
path := filepath.Join(root, "contracts.md")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data = []byte(strings.Replace(string(data), "`im.message-list.v1`", "`drifted`", 1))
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failures := strings.Join(validateTypedContractReference(root, "contracts.md"), "\n")
|
||||
if !strings.Contains(failures, "MESSAGE_RESULT contract differs") {
|
||||
t.Fatalf("failures = %s", failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateEventHandoffReferenceRejectsNaturalTargetDrift(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeEventHandoffReference(t, root, "handoff.md")
|
||||
path := filepath.Join(root, "handoff.md")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data = []byte(strings.Replace(string(data), "--group <conversation_id>", "--chat-query <display_name>", 1))
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failures := strings.Join(validateEventHandoffReference(root, "handoff.md"), "\n")
|
||||
if !strings.Contains(failures, "exact stable-ID mapping") {
|
||||
t.Fatalf("failures = %s", failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateReferencesAcceptCRLF(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
write func(*testing.T, string, string)
|
||||
validate func(string, string) []string
|
||||
}{
|
||||
{path: "contracts.md", write: writeTypedContractReference, validate: validateTypedContractReference},
|
||||
{path: "handoff.md", write: writeEventHandoffReference, validate: validateEventHandoffReference},
|
||||
} {
|
||||
tc.write(t, root, tc.path)
|
||||
path := filepath.Join(root, tc.path)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data = bytes.ReplaceAll(data, []byte("\n"), []byte("\r\n"))
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failures := tc.validate(root, tc.path); len(failures) != 0 {
|
||||
t.Errorf("%s CRLF failures = %v", tc.path, failures)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageManifestFailureMatrix(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTypedContractReference(t, root, "contracts.md")
|
||||
writeEventHandoffReference(t, root, "handoff.md")
|
||||
writeTestFile(t, root, "refs/declared.md", "<!-- dws-intent: declared --> missing preferred; dws chat forbidden\n<!-- dws-intent: declared --> duplicate\n")
|
||||
writeTestFile(t, root, "refs/markers.md", "<!-- dws-intent: unknown -->\n<!-- dws-intent: declared -->\n<!-- dws-intent: preferred-absent -->\n")
|
||||
tools := map[string]toolFact{
|
||||
"preferred.empty": {PrimaryPath: "chat preferred", HasMeta: false},
|
||||
"fallback.empty": {PrimaryPath: "chat fallback", HasMeta: false},
|
||||
"fallback.different": {PrimaryPath: "chat fallback-different", HasMeta: true, Confirmation: "not_required"},
|
||||
"forbidden.no-route": {PrimaryPath: "chat forbidden", HasMeta: true, Confirmation: "not_required", AvoidWhen: []string{"keep using lower tool"}},
|
||||
"forbidden.no-entry": {PrimaryPath: "chat forbidden-missing"},
|
||||
}
|
||||
manifest := routeManifest{
|
||||
Version: 1,
|
||||
MarkerRoots: []string{"refs", "../unsafe", "missing-root"},
|
||||
RetiredScripts: nil,
|
||||
ContractReference: "contracts.md",
|
||||
HandoffReference: "handoff.md",
|
||||
Intents: []intentRoute{
|
||||
{ID: "INVALID ID"},
|
||||
{ID: "preferred-absent", PreferredTool: "absent"},
|
||||
{ID: "preferred-no-selection", PreferredTool: "forbidden.no-entry"},
|
||||
{
|
||||
ID: "declared", PreferredTool: "preferred.empty",
|
||||
AllowedFallbacks: []routeFallback{
|
||||
{},
|
||||
{Tool: "fallback.empty", ReasonCode: "BAD-CODE"},
|
||||
{Tool: "fallback.empty", ReasonCode: "duplicate"},
|
||||
{Tool: "fallback.absent", ReasonCode: "missing"},
|
||||
{Tool: "fallback.different", ReasonCode: "raw_field"},
|
||||
},
|
||||
ForbiddenDefaultTools: []string{"", "forbidden.absent", "forbidden.no-entry", "forbidden.no-route", "forbidden.no-route"},
|
||||
References: []string{"../unsafe.md", "refs/wrong.txt", "refs/missing.md", "refs/declared.md", "refs/declared.md"},
|
||||
},
|
||||
{ID: "declared"},
|
||||
},
|
||||
}
|
||||
failures := strings.Join(validateManifest(root, manifest, tools), "\n")
|
||||
for _, want := range []string{
|
||||
"manifest version", "retired_scripts", "intent id",
|
||||
"preferred tool", "ResolveMeta", "needs non-empty", "allowed fallback", "reason_code", "fallback tool",
|
||||
"confirmation", "forbidden default", "invalid reference", "does not exist", "repeats reference",
|
||||
"unknown dws-intent", "undeclared dws-intent", "missing its dws-intent marker", "has 2 dws-intent markers",
|
||||
"invalid marker root", "scan marker root", "duplicate intent id",
|
||||
} {
|
||||
if !strings.Contains(failures, want) {
|
||||
t.Errorf("failure matrix missing %q:\n%s", want, failures)
|
||||
}
|
||||
}
|
||||
if failures := validateManifest(root, routeManifest{}, nil); len(failures) == 0 {
|
||||
t.Fatal("empty manifest unexpectedly passed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReferenceFailureBranches(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if got := validateTypedContractReference(root, "../unsafe.md"); len(got) == 0 || !strings.Contains(got[0], "invalid contract_reference") {
|
||||
t.Fatalf("unsafe contract result = %#v", got)
|
||||
}
|
||||
if got := validateTypedContractReference(root, "missing.md"); len(got) == 0 || !strings.Contains(got[0], "read typed contract") {
|
||||
t.Fatalf("missing contract result = %#v", got)
|
||||
}
|
||||
writeTestFile(t, root, "malformed.md", "<!-- DWS_MESSAGE_RESULT_CONTRACT_END -->\n<!-- DWS_MESSAGE_RESULT_CONTRACT_START -->")
|
||||
if got := strings.Join(validateTypedContractReference(root, "malformed.md"), "\n"); !strings.Contains(got, "malformed MESSAGE_RESULT") || !strings.Contains(got, "marker pair") {
|
||||
t.Fatalf("malformed contract result = %s", got)
|
||||
}
|
||||
if got := validateEventHandoffReference(root, "../unsafe.md"); len(got) == 0 || !strings.Contains(got[0], "invalid handoff_reference") {
|
||||
t.Fatalf("unsafe handoff result = %#v", got)
|
||||
}
|
||||
if got := validateEventHandoffReference(root, "missing-handoff.md"); len(got) == 0 || !strings.Contains(got[0], "read event handoff") {
|
||||
t.Fatalf("missing handoff result = %#v", got)
|
||||
}
|
||||
writeTestFile(t, root, "handoff-markers.md", "no markers")
|
||||
if got := strings.Join(validateEventHandoffReference(root, "handoff-markers.md"), "\n"); !strings.Contains(got, "marker pair") {
|
||||
t.Fatalf("handoff marker result = %s", got)
|
||||
}
|
||||
|
||||
if markdownCodeList(nil) != "—" || markdownBreakList(nil) != "—" {
|
||||
t.Fatal("empty markdown list rendering drifted")
|
||||
}
|
||||
tooLong := strings.Repeat("x", 5000) + ".py"
|
||||
if got := strings.Join(validateRetiredScripts(root, []string{tooLong}), "\n"); !strings.Contains(got, "inspect retired script") {
|
||||
t.Fatalf("long retired script result = %s", got)
|
||||
}
|
||||
for _, path := range []string{"", ".", "..", "../x", "a/../b"} {
|
||||
if safeRepositoryPath(path) {
|
||||
t.Errorf("unsafe repository path accepted: %q", path)
|
||||
}
|
||||
}
|
||||
if containsCLIPath("run dws chat send-more", "chat send") || !containsCLIPath("run dws chat send`", "chat send") {
|
||||
t.Fatal("CLI path boundary check drifted")
|
||||
}
|
||||
if stringSliceContains([]string{"a/b"}, "missing") {
|
||||
t.Fatal("string slice containment false positive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMarkerOpenFailure(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, root, "refs/marker.md", "<!-- dws-intent: declared --> dws chat preferred\n")
|
||||
oldOpen := markerOpen
|
||||
markerOpen = func(string) (*os.File, error) { return nil, os.ErrPermission }
|
||||
t.Cleanup(func() { markerOpen = oldOpen })
|
||||
failures, _ := scanMarkers(root, []string{"refs"}, map[string]intentRoute{
|
||||
"declared": {ID: "declared", PreferredTool: "preferred", References: []string{"refs/marker.md"}},
|
||||
}, map[string]toolFact{"preferred": {PrimaryPath: "chat preferred"}})
|
||||
if len(failures) == 0 || !strings.Contains(failures[0], "scan marker root") {
|
||||
t.Fatalf("marker open failures = %#v", failures)
|
||||
}
|
||||
}
|
||||
|
||||
func writeTypedContractReference(t *testing.T, root, relative string) {
|
||||
t.Helper()
|
||||
var content strings.Builder
|
||||
for _, block := range []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{name: "MESSAGE_RESULT", body: renderMessageResultContract()},
|
||||
{name: "IDENTITY_CAPABILITY", body: renderIdentityCapabilityContract()},
|
||||
{name: "CARD_WORKFLOW", body: renderCardWorkflowContract()},
|
||||
{name: "CAPABILITY_BOUNDARY", body: renderCapabilityBoundaryContract()},
|
||||
} {
|
||||
content.WriteString("<!-- DWS_" + block.name + "_CONTRACT_START -->\n")
|
||||
content.WriteString(block.body)
|
||||
content.WriteString("\n<!-- DWS_" + block.name + "_CONTRACT_END -->\n")
|
||||
}
|
||||
writeTestFile(t, root, relative, content.String())
|
||||
}
|
||||
|
||||
func writeEventHandoffReference(t *testing.T, root, relative string) {
|
||||
t.Helper()
|
||||
writeTestFile(t, root, relative, `<!-- DWS_EVENT_CHAT_HANDOFF_START -->
|
||||
| event field | exact chat target |
|
||||
|---|---|
|
||||
| `+"`conversation_id`"+` | `+"`dws chat +messages-send --as user --group <conversation_id>`"+` |
|
||||
| `+"`sender_open_dingtalk_id`"+` | `+"`dws chat +messages-send --as user --open-dingtalk-id <sender_open_dingtalk_id>`"+` |
|
||||
<!-- DWS_EVENT_CHAT_HANDOFF_END -->
|
||||
`)
|
||||
}
|
||||
|
||||
func writeTestFile(t *testing.T, root, relative, content string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(root, filepath.FromSlash(relative))
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
{
|
||||
"version": 3,
|
||||
"marker_roots": [
|
||||
"skills/multi/dingtalk-chat",
|
||||
"skills/multi/dingtalk-event"
|
||||
],
|
||||
"retired_scripts": [
|
||||
"skills/multi/dingtalk-chat/scripts/_chat_message_export_common.py",
|
||||
"skills/multi/dingtalk-chat/scripts/chat_export_messages.py",
|
||||
"skills/multi/dingtalk-chat/scripts/chat_history_with_user.py",
|
||||
"skills/multi/dingtalk-chat/scripts/bot_broadcast.py",
|
||||
"skills/mono/scripts/_chat_message_export_common.py",
|
||||
"skills/mono/scripts/chat_export_messages.py",
|
||||
"skills/mono/scripts/chat_history_with_user.py",
|
||||
"skills/mono/scripts/bot_broadcast.py"
|
||||
],
|
||||
"contract_reference": "skills/multi/dingtalk-chat/references/contracts.md",
|
||||
"handoff_reference": "skills/multi/dingtalk-event/references/event-im-output.md",
|
||||
"intents": [
|
||||
{
|
||||
"id": "chat.send.dm",
|
||||
"preferred_tool": "chat.shortcut_dm",
|
||||
"forbidden_default_tools": [
|
||||
"chat.send_personal_message"
|
||||
],
|
||||
"references": [
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/01-messaging.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-message.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "chat.send.group",
|
||||
"preferred_tool": "chat.shortcut_send_to_group",
|
||||
"forbidden_default_tools": [
|
||||
"chat.send_personal_message"
|
||||
],
|
||||
"references": [
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/01-messaging.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-message.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "chat.send.advanced",
|
||||
"preferred_tool": "chat.shortcut_messages_send",
|
||||
"forbidden_default_tools": [
|
||||
"chat.send_personal_message",
|
||||
"chat.send_robot_message",
|
||||
"chat.send_message_by_custom_robot",
|
||||
"chat.shortcut_messages_send_by_webhook"
|
||||
],
|
||||
"references": [
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/01-messaging.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-message.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-bot.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "chat.read.conversation",
|
||||
"preferred_tool": "chat.shortcut_chat_messages",
|
||||
"allowed_fallbacks": [
|
||||
{
|
||||
"tool": "chat.list_conversation_message_v2",
|
||||
"reason_code": "raw_page_control"
|
||||
}
|
||||
],
|
||||
"forbidden_default_tools": [
|
||||
"chat.list_conversation_message_v2"
|
||||
],
|
||||
"references": [
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/01-messaging.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-message.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-conversation.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "chat.search.cross-conversation",
|
||||
"preferred_tool": "chat.shortcut_search_msg",
|
||||
"allowed_fallbacks": [
|
||||
{
|
||||
"tool": "chat.search_messages",
|
||||
"reason_code": "raw_filter_control"
|
||||
}
|
||||
],
|
||||
"forbidden_default_tools": [
|
||||
"chat.search_messages",
|
||||
"chat.search_messages_by_keyword"
|
||||
],
|
||||
"references": [
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-message.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "chat.create.group",
|
||||
"preferred_tool": "chat.shortcut_chat_create",
|
||||
"forbidden_default_tools": [
|
||||
"chat.create_group_conversation"
|
||||
],
|
||||
"references": [
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/01-messaging.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-group.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "chat.reply.quote",
|
||||
"preferred_tool": "chat.shortcut_messages_reply",
|
||||
"forbidden_default_tools": [
|
||||
"chat.reply_personal_message"
|
||||
],
|
||||
"references": [
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/01-messaging.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-message.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "chat.conversation.list-top",
|
||||
"preferred_tool": "chat.shortcut_conversation_list_top",
|
||||
"allowed_fallbacks": [
|
||||
{
|
||||
"tool": "chat.list_top_conversations",
|
||||
"reason_code": "raw_response_required"
|
||||
}
|
||||
],
|
||||
"forbidden_default_tools": [
|
||||
"chat.list_top_conversations"
|
||||
],
|
||||
"references": [
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-conversation.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "event.listen.im",
|
||||
"preferred_tool": "event.listen_im",
|
||||
"allowed_fallbacks": [
|
||||
{
|
||||
"tool": "event.consume",
|
||||
"reason_code": "advanced_event_control"
|
||||
}
|
||||
],
|
||||
"forbidden_default_tools": [
|
||||
"event.consume"
|
||||
],
|
||||
"references": [
|
||||
"skills/multi/dingtalk-event/SKILL.md",
|
||||
"skills/multi/dingtalk-event/references/event-im.md"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -492,7 +492,7 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri
|
||||
}
|
||||
if oldTool.Constraints != newTool.Constraints &&
|
||||
!compatibleHiddenSiblingConstraintExpansion(oldTool, newTool) &&
|
||||
!compatibleConstraintMemberExpansion(oldTool.Constraints, newTool.Constraints) {
|
||||
!compatibleAdditiveConstraintEvolution(oldTool, newTool) {
|
||||
failures = append(failures, fmt.Sprintf("schema tool %q changed constraints", toolPath))
|
||||
}
|
||||
if !compatiblePositionals(oldTool.Positionals, newTool.Positionals) {
|
||||
@@ -514,6 +514,86 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri
|
||||
return failures
|
||||
}
|
||||
|
||||
// compatibleAdditiveConstraintEvolution accepts constraint evolution that
|
||||
// cannot invalidate an invocation expressible by the historical public
|
||||
// parameter contract. Existing groups may only gain members; additions to a
|
||||
// mutually-exclusive or require-together group must not be historical public
|
||||
// parameters, because that would reject an invocation expressible by the old
|
||||
// contract. Adding a member to require-one-of only loosens the group. A newly
|
||||
// added mutually-exclusive group is safe when it contains at most one
|
||||
// historical public parameter: aliases and newly added parameters could not
|
||||
// have appeared together in an old invocation. A new require-together group is
|
||||
// safe only when it contains no historical public parameter. A new
|
||||
// require-one-of group always adds a requirement and is therefore incompatible.
|
||||
func compatibleAdditiveConstraintEvolution(oldTool, newTool toolSchema) bool {
|
||||
oldGroups, okOld := parseConstraintGroups(oldTool.Constraints)
|
||||
newGroups, okNew := parseConstraintGroups(newTool.Constraints)
|
||||
if !okOld || !okNew {
|
||||
return false
|
||||
}
|
||||
for _, key := range []string{"mutually_exclusive", "require_one_of", "require_together"} {
|
||||
used := make([]bool, len(newGroups[key]))
|
||||
for _, oldGroup := range oldGroups[key] {
|
||||
oldSet := stringSet(oldGroup)
|
||||
if len(oldSet) == 0 {
|
||||
return false
|
||||
}
|
||||
matched := false
|
||||
for index, newGroup := range newGroups[key] {
|
||||
newSet := stringSet(newGroup)
|
||||
if used[index] || !stringSetContainsAll(newSet, oldSet) {
|
||||
continue
|
||||
}
|
||||
if key == "mutually_exclusive" || key == "require_together" {
|
||||
safe := true
|
||||
for member := range newSet {
|
||||
if oldSet[member] {
|
||||
continue
|
||||
}
|
||||
if _, historical := oldTool.Parameters[member]; historical {
|
||||
safe = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !safe {
|
||||
continue
|
||||
}
|
||||
}
|
||||
used[index] = true
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
if !matched {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for index, newGroup := range newGroups[key] {
|
||||
if used[index] {
|
||||
continue
|
||||
}
|
||||
historicalMembers := 0
|
||||
for member := range stringSet(newGroup) {
|
||||
if _, existed := oldTool.Parameters[member]; existed {
|
||||
historicalMembers++
|
||||
}
|
||||
}
|
||||
switch key {
|
||||
case "mutually_exclusive":
|
||||
if historicalMembers > 1 {
|
||||
return false
|
||||
}
|
||||
case "require_together":
|
||||
if historicalMembers > 0 {
|
||||
return false
|
||||
}
|
||||
default: // require_one_of
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// compatibleHiddenSiblingConstraintExpansion allows declare≡execute repairs:
|
||||
// Schema may start projecting full constraint groups that include unpublished
|
||||
// (hidden) execute-side siblings when the previous contract collapsed the sole
|
||||
@@ -574,23 +654,6 @@ func compatibleHiddenSiblingConstraintExpansion(oldTool, newTool toolSchema) boo
|
||||
return true
|
||||
}
|
||||
|
||||
// compatibleConstraintMemberExpansion allows declare≡execute / alias-surface
|
||||
// repairs that only add members to existing constraint groups. Removing a
|
||||
// member, dropping a group, or adding a new group remains incompatible.
|
||||
func compatibleConstraintMemberExpansion(oldConstraints, newConstraints string) bool {
|
||||
oldGroups, okOld := parseConstraintGroups(oldConstraints)
|
||||
newGroups, okNew := parseConstraintGroups(newConstraints)
|
||||
if !okOld || !okNew {
|
||||
return false
|
||||
}
|
||||
for _, key := range []string{"mutually_exclusive", "require_one_of", "require_together"} {
|
||||
if !constraintGroupsAreMemberExpansions(oldGroups[key], newGroups[key]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseConstraintGroups(raw string) (map[string][][]string, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
@@ -611,36 +674,6 @@ func parseConstraintGroups(raw string) (map[string][][]string, bool) {
|
||||
}, true
|
||||
}
|
||||
|
||||
func constraintGroupsAreMemberExpansions(oldGroups, newGroups [][]string) bool {
|
||||
if len(oldGroups) != len(newGroups) {
|
||||
return false
|
||||
}
|
||||
used := make([]bool, len(newGroups))
|
||||
for _, oldGroup := range oldGroups {
|
||||
oldSet := stringSet(oldGroup)
|
||||
if len(oldSet) == 0 {
|
||||
return false
|
||||
}
|
||||
matched := false
|
||||
for index, newGroup := range newGroups {
|
||||
if used[index] {
|
||||
continue
|
||||
}
|
||||
newSet := stringSet(newGroup)
|
||||
if !stringSetContainsAll(newSet, oldSet) {
|
||||
continue
|
||||
}
|
||||
used[index] = true
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
if !matched {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func stringSetContainsAll(superset, subset map[string]bool) bool {
|
||||
for value := range subset {
|
||||
if !superset[value] {
|
||||
|
||||
@@ -492,10 +492,10 @@ func TestCrossPlatformCoverageSchemaCompatMCPRetirementAndConstraintExpansion(t
|
||||
parameter.InterfaceType = ""
|
||||
})
|
||||
mutateTool(¤t, func(tool *toolSchema) {
|
||||
tool.Constraints = `{"require_one_of":[["title","format","legacy-format"]],"mutually_exclusive":[["title","legacy-title"]]}`
|
||||
tool.Constraints = `{"require_one_of":[["title","format","legacy-format"]],"mutually_exclusive":[["title","format","legacy-title"]]}`
|
||||
})
|
||||
// Baseline has require_one_of only; expanding with a new mutually_exclusive
|
||||
// group must remain incompatible.
|
||||
// Baseline has require_one_of only; a new mutually-exclusive group that
|
||||
// restricts two historical public parameters must remain incompatible.
|
||||
if failures := checkCompatibility(baseline, current); len(failures) == 0 {
|
||||
t.Fatal("adding a new constraint group must fail compatibility")
|
||||
}
|
||||
@@ -532,36 +532,9 @@ func TestCrossPlatformCoverageSchemaCompatMCPRetirementAndConstraintExpansion(t
|
||||
t.Fatalf("hidden-sibling constraint expansion should pass: %v", failures)
|
||||
}
|
||||
|
||||
if compatibleConstraintMemberExpansion("{", `{"require_one_of":[["a"]]}`) {
|
||||
t.Fatal("invalid old constraints must not expand")
|
||||
}
|
||||
if compatibleConstraintMemberExpansion(`{"require_one_of":[["a"]]}`, "{") {
|
||||
t.Fatal("invalid new constraints must not expand")
|
||||
}
|
||||
if groups, ok := parseConstraintGroups(""); !ok || len(groups["require_one_of"]) != 0 {
|
||||
t.Fatalf("empty constraints parse = %#v ok=%v", groups, ok)
|
||||
}
|
||||
if constraintGroupsAreMemberExpansions([][]string{{}}, [][]string{{"a"}}) {
|
||||
t.Fatal("empty old group must not expand")
|
||||
}
|
||||
if constraintGroupsAreMemberExpansions(
|
||||
[][]string{{"a"}, {"b"}},
|
||||
[][]string{{"a", "b"}},
|
||||
) {
|
||||
t.Fatal("group-count reduction must not expand")
|
||||
}
|
||||
if constraintGroupsAreMemberExpansions(
|
||||
[][]string{{"a"}, {"c"}},
|
||||
[][]string{{"a", "b"}, {"x", "y"}},
|
||||
) {
|
||||
t.Fatal("unmatched old group must not expand")
|
||||
}
|
||||
if !constraintGroupsAreMemberExpansions(
|
||||
[][]string{{"a"}, {"b"}},
|
||||
[][]string{{"a", "b", "extra"}, {"b", "other"}},
|
||||
) {
|
||||
t.Fatal("used-group skip should still allow alternate match")
|
||||
}
|
||||
if !stringSetContainsAll(map[string]bool{"a": true, "b": true}, map[string]bool{"a": true}) ||
|
||||
stringSetContainsAll(map[string]bool{"a": true}, map[string]bool{"a": true, "b": true}) {
|
||||
t.Fatal("stringSetContainsAll classification is incorrect")
|
||||
@@ -622,3 +595,87 @@ func TestCrossPlatformCoverageSchemaCompatMCPRetirementAndConstraintExpansion(t
|
||||
t.Fatalf("non-empty interface_type change should fail: %v", failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaCompatAdditiveConstraintEvolution(t *testing.T) {
|
||||
oldTool := toolSchema{
|
||||
Parameters: map[string]parameterSchema{
|
||||
"target": {},
|
||||
"limit": {},
|
||||
},
|
||||
Constraints: `{"require_one_of":[["target","legacy-target"]]}`,
|
||||
}
|
||||
compatible := oldTool
|
||||
compatible.Constraints = `{"require_one_of":[["target","legacy-target","new-target"]],"mutually_exclusive":[["target","new-target"],["new-a","new-b"]],"require_together":[["new-a","new-b"]]}`
|
||||
if !compatibleAdditiveConstraintEvolution(oldTool, compatible) {
|
||||
t.Fatal("member expansion and additive alias-only groups must remain compatible")
|
||||
}
|
||||
|
||||
twoHistorical := compatible
|
||||
twoHistorical.Constraints = `{"require_one_of":[["target","legacy-target","new-target"]],"mutually_exclusive":[["target","limit","new-target"]]}`
|
||||
if compatibleAdditiveConstraintEvolution(oldTool, twoHistorical) {
|
||||
t.Fatal("new mutex group restricting two historical parameters must fail")
|
||||
}
|
||||
newRequirement := compatible
|
||||
newRequirement.Constraints = `{"require_one_of":[["target","legacy-target","new-target"],["new-a","new-b"]]}`
|
||||
if compatibleAdditiveConstraintEvolution(oldTool, newRequirement) {
|
||||
t.Fatal("new require_one_of group must fail")
|
||||
}
|
||||
requireHistoricalTogether := compatible
|
||||
requireHistoricalTogether.Constraints = `{"require_one_of":[["target","legacy-target","new-target"]],"require_together":[["limit","new-limit"]]}`
|
||||
if compatibleAdditiveConstraintEvolution(oldTool, requireHistoricalTogether) {
|
||||
t.Fatal("new require_together group containing a historical parameter must fail")
|
||||
}
|
||||
removedOldGroup := compatible
|
||||
removedOldGroup.Constraints = `{"mutually_exclusive":[["new-a","new-b"]]}`
|
||||
if compatibleAdditiveConstraintEvolution(oldTool, removedOldGroup) {
|
||||
t.Fatal("removing a historical group must fail")
|
||||
}
|
||||
invalid := compatible
|
||||
invalid.Constraints = "{"
|
||||
if compatibleAdditiveConstraintEvolution(oldTool, invalid) {
|
||||
t.Fatal("invalid constraints must fail closed")
|
||||
}
|
||||
emptyHistoricalGroup := oldTool
|
||||
emptyHistoricalGroup.Constraints = `{"require_one_of":[[]]}`
|
||||
if compatibleAdditiveConstraintEvolution(emptyHistoricalGroup, compatible) {
|
||||
t.Fatal("empty historical constraint group must fail closed")
|
||||
}
|
||||
historicalMutex := oldTool
|
||||
historicalMutex.Constraints = `{"mutually_exclusive":[["target","legacy-target"]]}`
|
||||
historicalMutexExpanded := oldTool
|
||||
historicalMutexExpanded.Constraints = `{"mutually_exclusive":[["target","legacy-target","limit"]]}`
|
||||
if compatibleAdditiveConstraintEvolution(historicalMutex, historicalMutexExpanded) {
|
||||
t.Fatal("adding a historical parameter to an existing mutex group must fail")
|
||||
}
|
||||
historicalTogether := oldTool
|
||||
historicalTogether.Constraints = `{"require_together":[["target","legacy-target"]]}`
|
||||
historicalTogetherExpanded := oldTool
|
||||
historicalTogetherExpanded.Constraints = `{"require_together":[["target","legacy-target","limit"]]}`
|
||||
if compatibleAdditiveConstraintEvolution(historicalTogether, historicalTogetherExpanded) {
|
||||
t.Fatal("adding a historical parameter to an existing require-together group must fail")
|
||||
}
|
||||
historicalOneOf := oldTool
|
||||
historicalOneOf.Constraints = `{"require_one_of":[["target","legacy-target","limit"]]}`
|
||||
if !compatibleAdditiveConstraintEvolution(oldTool, historicalOneOf) {
|
||||
t.Fatal("adding a historical parameter to require-one-of only loosens the contract")
|
||||
}
|
||||
gateBaseline := baselineContract()
|
||||
mutateTool(&gateBaseline, func(tool *toolSchema) {
|
||||
tool.Parameters["folder"] = parameterSchema{Type: `"string"`}
|
||||
tool.Constraints = `{"mutually_exclusive":[["title","format"]]}`
|
||||
})
|
||||
gateCurrent := cloneContract(gateBaseline)
|
||||
mutateTool(&gateCurrent, func(tool *toolSchema) {
|
||||
tool.Constraints = `{"mutually_exclusive":[["title","format","folder"]]}`
|
||||
})
|
||||
if failures := checkCompatibility(gateBaseline, gateCurrent); len(failures) == 0 {
|
||||
t.Fatal("compatibility gate must reject an existing mutex group gaining a historical parameter")
|
||||
}
|
||||
multipleHistoricalGroups := oldTool
|
||||
multipleHistoricalGroups.Constraints = `{"require_one_of":[["target"],["limit"]]}`
|
||||
multipleExpandedGroups := oldTool
|
||||
multipleExpandedGroups.Constraints = `{"require_one_of":[["target","limit"],["limit","new-limit"]]}`
|
||||
if !compatibleAdditiveConstraintEvolution(multipleHistoricalGroups, multipleExpandedGroups) {
|
||||
t.Fatal("each historical group must match a distinct expanded group")
|
||||
}
|
||||
}
|
||||
@@ -306,6 +306,23 @@ def reaction_message_count(value: Any, *, upper: bool) -> int:
|
||||
return count
|
||||
|
||||
|
||||
def primary_projection_for_message_metrics(value: Any) -> Any:
|
||||
"""Select one canonical projected collection for message-level metrics.
|
||||
|
||||
Some stable Shortcut payloads publish compatibility aliases such as
|
||||
``messages`` and ``items`` with the same rows. Walking the entire envelope
|
||||
would count reactions and thread IDs once per alias and report a false
|
||||
projection mismatch even though the lower and upper message sets agree.
|
||||
"""
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
for key in ("messages", "replies", "results", "items"):
|
||||
items = value.get(key)
|
||||
if isinstance(items, list):
|
||||
return items
|
||||
return value
|
||||
|
||||
|
||||
def thread_message_count(value: Any, *, upper: bool) -> int:
|
||||
keys = {"threadId"} if upper else {
|
||||
"openConvThreadId",
|
||||
@@ -393,9 +410,10 @@ def summarize_capture(capture: Capture) -> dict[str, Any]:
|
||||
else 0
|
||||
)
|
||||
exact_passthrough = bool(capture.raw_calls and upper == raw)
|
||||
upper_reactions = reaction_message_count(upper, upper=True)
|
||||
upper_message_projection = primary_projection_for_message_metrics(upper)
|
||||
upper_reactions = reaction_message_count(upper_message_projection, upper=True)
|
||||
lower_reactions = reaction_message_count(raw, upper=False)
|
||||
upper_threads = thread_message_count(upper, upper=True)
|
||||
upper_threads = thread_message_count(upper_message_projection, upper=True)
|
||||
lower_threads = thread_message_count(raw, upper=False)
|
||||
upper_pagination = pagination_meta(upper)
|
||||
lower_pagination = pagination_meta(pagination_raw)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic regression checks for the live Chat audit summarizer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from run_chat_shortcut_live_audit import (
|
||||
primary_projection_for_message_metrics,
|
||||
reaction_message_count,
|
||||
thread_message_count,
|
||||
)
|
||||
|
||||
|
||||
def check(name: str, condition: bool) -> None:
|
||||
if not condition:
|
||||
raise AssertionError(f"FAIL: {name}")
|
||||
print(f" ok: {name}")
|
||||
|
||||
|
||||
def test_compatibility_aliases_are_counted_once() -> None:
|
||||
rows = [{"threadId": "thread-1", "reactions": [{"type": "LIKE"}]}]
|
||||
payload = {"messages": rows, "items": rows, "count": 1}
|
||||
projection = primary_projection_for_message_metrics(payload)
|
||||
|
||||
check("messages is the canonical projection", projection is rows)
|
||||
check("reaction-bearing message counted once", reaction_message_count(projection, upper=True) == 1)
|
||||
check("thread-bearing message counted once", thread_message_count(projection, upper=True) == 1)
|
||||
|
||||
|
||||
def test_projection_fallback_order() -> None:
|
||||
replies = [{"threadId": "thread-2"}]
|
||||
items = [{"threadId": "thread-3"}]
|
||||
check(
|
||||
"replies precedes items",
|
||||
primary_projection_for_message_metrics({"replies": replies, "items": items}) is replies,
|
||||
)
|
||||
check("items remains a supported fallback", primary_projection_for_message_metrics({"items": items}) is items)
|
||||
|
||||
|
||||
def test_non_envelope_passthrough() -> None:
|
||||
rows = [{"threadId": "thread-4"}]
|
||||
check("bare list passes through", primary_projection_for_message_metrics(rows) is rows)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
tests = [
|
||||
test_compatibility_aliases_are_counted_once,
|
||||
test_projection_fallback_order,
|
||||
test_non_envelope_passthrough,
|
||||
]
|
||||
for test in tests:
|
||||
print(f"{test.__name__}:")
|
||||
test()
|
||||
print(f"\nAll {len(tests)} test groups passed.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -474,9 +474,22 @@ def run_live(binary: Path, timeout: int) -> list[dict[str, Any]]:
|
||||
)
|
||||
role_id = ""
|
||||
|
||||
# The platform only permits the @all and red-envelope sub-switches
|
||||
# while the conversation-wide notification-off switch is enabled.
|
||||
# Keep that prerequisite active across both cases, then restore it.
|
||||
conversation_mute_captures = [
|
||||
run_capture(
|
||||
binary,
|
||||
"+conversation-mute",
|
||||
["--conversation-id", group_id, "--yes"],
|
||||
timeout,
|
||||
)
|
||||
]
|
||||
# Red-envelope must be exercised first. Toggling the @all sub-switch
|
||||
# can make the service clear the prerequisite state for the next call.
|
||||
shortcut_case(
|
||||
"+conversation-mute",
|
||||
"update_notification_off",
|
||||
"+conversation-mute-red-envelope",
|
||||
"update_red_env_notification_off",
|
||||
[
|
||||
["--conversation-id", group_id],
|
||||
["--conversation-id", group_id, "--off"],
|
||||
@@ -490,13 +503,20 @@ def run_live(binary: Path, timeout: int) -> list[dict[str, Any]]:
|
||||
["--conversation-id", group_id, "--off"],
|
||||
],
|
||||
)
|
||||
shortcut_case(
|
||||
"+conversation-mute-red-envelope",
|
||||
"update_red_env_notification_off",
|
||||
[
|
||||
["--conversation-id", group_id],
|
||||
["--conversation-id", group_id, "--off"],
|
||||
],
|
||||
conversation_mute_captures.append(
|
||||
run_capture(
|
||||
binary,
|
||||
"+conversation-mute",
|
||||
["--conversation-id", group_id, "--off", "--yes"],
|
||||
timeout,
|
||||
)
|
||||
)
|
||||
results.append(
|
||||
summarize_action(
|
||||
"+conversation-mute",
|
||||
"update_notification_off",
|
||||
conversation_mute_captures,
|
||||
)
|
||||
)
|
||||
shortcut_case(
|
||||
"+conversation-set-top",
|
||||
|
||||
Reference in new issue
Block a user