Merge remote-tracking branch 'origin/main' into pr-922
This commit is contained in:
@@ -59,7 +59,7 @@ RUNTIME_CONTRACT_END = "<!-- DWS_RUNTIME_CONTRACT_END -->"
|
||||
# here only after verifying that the product skill has its own reviewed routing
|
||||
# section and intent table; compacting a sparse skill without an alternative
|
||||
# route would make its shortcuts harder to discover.
|
||||
COMPACT_PRODUCT_SERVICES = {"chat"}
|
||||
COMPACT_PRODUCT_SERVICES = {"chat", "doc"}
|
||||
|
||||
|
||||
def md_escape(value: Any) -> str:
|
||||
@@ -121,7 +121,10 @@ def runtime_contract_block() -> str:
|
||||
|
||||
|
||||
def mono_overview(items: list[dict[str, Any]]) -> str:
|
||||
counts = Counter(item["service"] for item in items)
|
||||
# The source collector cannot see a small number of commands whose final
|
||||
# canonical names are normalized at runtime. Overview counts come from the
|
||||
# reviewed public catalog so they cannot under-report those declarations.
|
||||
counts = Counter(service for service, _ in load_public_catalog())
|
||||
rows = []
|
||||
for service, count in sorted(counts.items()):
|
||||
path = SERVICE_TO_SKILL.get(service)
|
||||
@@ -163,10 +166,18 @@ def product_section(service: str, rows: list[dict[str, Any]]) -> str:
|
||||
|
||||
|
||||
def compact_product_section(service: str, rows: list[dict[str, Any]]) -> str:
|
||||
# Compact skills intentionally do not depend on the source parser's ability
|
||||
# to recover every runtime-normalized declaration. The reviewed public
|
||||
# catalog is the count authority for this non-enumerating overview.
|
||||
public_count = sum(1 for item_service, _ in load_public_catalog() if item_service == service)
|
||||
if service == "doc":
|
||||
discovery = """已知意图按下方路由。"""
|
||||
else:
|
||||
discovery = """已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。"""
|
||||
return f"""{PRODUCT_START}
|
||||
## Shortcut 发现(按需)
|
||||
|
||||
`{md_escape(service)}` 当前有 {len(rows)} 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。
|
||||
`{md_escape(service)}` 当前有 {public_count} 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。{discovery}
|
||||
|
||||
仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service {md_escape(service)} --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。
|
||||
{PRODUCT_END}"""
|
||||
@@ -194,6 +205,7 @@ def update_runtime_contract(check: bool) -> list[Path]:
|
||||
changed = []
|
||||
targets = [
|
||||
ROOT / "skills" / "multi" / "dingtalk-chat" / "SKILL.md",
|
||||
ROOT / "skills" / "multi" / "dingtalk-doc" / "SKILL.md",
|
||||
SHARED_SKILL,
|
||||
]
|
||||
for path in targets:
|
||||
|
||||
@@ -7,12 +7,14 @@ cd "$ROOT"
|
||||
python3 scripts/gen_skill_shortcut_sections.py --check
|
||||
|
||||
chat_skill="skills/multi/dingtalk-chat/SKILL.md"
|
||||
doc_skill="skills/multi/dingtalk-doc/SKILL.md"
|
||||
event_skill="skills/multi/dingtalk-misc/references/event.md"
|
||||
mono_skill="skills/mono/SKILL.md"
|
||||
runtime_contract="skills/multi/dingtalk-shared/references/runtime-contract.md"
|
||||
chat_target_bytes=10000
|
||||
chat_max_overage_percent=5
|
||||
chat_max_bytes=$((chat_target_bytes * (100 + chat_max_overage_percent) / 100))
|
||||
doc_max_bytes=9000
|
||||
event_max_bytes=10000
|
||||
runtime_contract_max_bytes=3000
|
||||
|
||||
@@ -23,6 +25,13 @@ if [ "$chat_bytes" -gt "$chat_max_bytes" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
doc_bytes="$(wc -c < "$doc_skill" | tr -d ' ')"
|
||||
if [ "$doc_bytes" -gt "$doc_max_bytes" ]; then
|
||||
printf '%s\n' \
|
||||
"skill context budget exceeded: $doc_skill is ${doc_bytes} bytes (max ${doc_max_bytes})" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
event_bytes="$(wc -c < "$event_skill" | tr -d ' ')"
|
||||
if [ "$event_bytes" -gt "$event_max_bytes" ]; then
|
||||
printf '%s\n' \
|
||||
@@ -51,6 +60,20 @@ if [ "$shortcut_rows" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
doc_shortcut_rows="$(
|
||||
awk '
|
||||
/<!-- VISIBLE_SHORTCUTS_START -->/ { in_block = 1; next }
|
||||
/<!-- VISIBLE_SHORTCUTS_END -->/ { in_block = 0 }
|
||||
in_block && /^\| `dws doc \+/ { count++ }
|
||||
END { print count + 0 }
|
||||
' "$doc_skill"
|
||||
)"
|
||||
if [ "$doc_shortcut_rows" -ne 0 ]; then
|
||||
printf '%s\n' \
|
||||
"skill context budget exceeded: $doc_skill re-expanded $doc_shortcut_rows shortcut rows" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for required_heading in \
|
||||
"## 最小 DWS 执行契约" \
|
||||
"## Golden Route" \
|
||||
@@ -65,6 +88,67 @@ do
|
||||
fi
|
||||
done
|
||||
|
||||
for required_heading in \
|
||||
"## 最小 DWS 执行契约" \
|
||||
"## Golden Route" \
|
||||
"## 关键结果语义" \
|
||||
"## 按需加载" \
|
||||
"## 错误最短路径"
|
||||
do
|
||||
if ! grep -Fq "$required_heading" "$doc_skill"; then
|
||||
printf '%s\n' \
|
||||
"skill value regression: $doc_skill is missing $required_heading" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
for required_route in \
|
||||
"+search" \
|
||||
"+fetch" \
|
||||
"+create" \
|
||||
"+update" \
|
||||
"+checkpoint-update" \
|
||||
"+export"
|
||||
do
|
||||
if ! grep -Fq -- "$required_route" "$doc_skill"; then
|
||||
printf '%s\n' \
|
||||
"skill route regression: $doc_skill is missing $required_route" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
for forbidden_route in \
|
||||
"## 标准 SOP" \
|
||||
"doc_create_and_write.py" \
|
||||
"只在任务命中时读取一个精确 reference" \
|
||||
"MUST 先用 Read" \
|
||||
"先用 Read 工具完整读取"
|
||||
do
|
||||
if grep -Fq "$forbidden_route" "$doc_skill"; then
|
||||
printf '%s\n' \
|
||||
"skill route regression: $doc_skill restored legacy route: $forbidden_route" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
for required_reference_rule in \
|
||||
"Golden Route 已给出命令且参数足够时,禁止读取 reference" \
|
||||
"才最多读取一个 reference" \
|
||||
"\`+create\`、\`+fetch\`、\`+update\` append/overwrite、\`+export\`、\`+import\` 禁止读取 reference"
|
||||
do
|
||||
if ! grep -Fq "$required_reference_rule" "$doc_skill"; then
|
||||
printf '%s\n' \
|
||||
"skill reference budget regression: $doc_skill is missing $required_reference_rule" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if grep -Fq "../dingtalk-shared/SKILL.md" "$doc_skill"; then
|
||||
printf '%s\n' \
|
||||
"skill context regression: $doc_skill requires full dingtalk-shared cold-start loading" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for required_route in \
|
||||
"+dm" \
|
||||
"+send-to-group" \
|
||||
@@ -108,4 +192,4 @@ if grep -Fq "充分阅读产品参考文件" "$mono_skill"; then
|
||||
fi
|
||||
|
||||
printf '%s\n' \
|
||||
"skill context budget: ok (chat_bytes=$chat_bytes target=$chat_target_bytes max=$chat_max_bytes allowance=${chat_max_overage_percent}% 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)"
|
||||
"skill context budget: ok (chat_bytes=$chat_bytes target=$chat_target_bytes max=$chat_max_bytes allowance=${chat_max_overage_percent}% doc_bytes=$doc_bytes doc_max=$doc_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 doc_shortcut_rows=$doc_shortcut_rows)"
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
@@ -510,7 +511,8 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri
|
||||
}
|
||||
if oldTool.Constraints != newTool.Constraints &&
|
||||
!compatibleHiddenSiblingConstraintExpansion(oldTool, newTool) &&
|
||||
!compatibleAdditiveConstraintEvolution(oldTool, newTool) {
|
||||
!compatibleAdditiveConstraintEvolution(oldTool, newTool) &&
|
||||
!compatibleReviewedConstraintTransition(toolPath, oldTool, newTool) {
|
||||
failures = append(failures, fmt.Sprintf("schema tool %q changed constraints", toolPath))
|
||||
}
|
||||
if !compatiblePositionals(oldTool.Positionals, newTool.Positionals) {
|
||||
@@ -562,6 +564,31 @@ var reviewedInterfaceRefRedirect = map[string]map[string]string{
|
||||
},
|
||||
}
|
||||
|
||||
// reviewedConstraintTransition enumerates exact constraint changes that were
|
||||
// explicitly reviewed even though the compatibility gate cannot infer their
|
||||
// business semantics. Keep this narrow: removing a constraint can expose a new
|
||||
// runtime route, so arbitrary removals must not pass as harmless drift.
|
||||
var reviewedConstraintTransition = map[string]map[string]string{
|
||||
// PR #933 aligned the Shortcut and Schema with the existing doc import
|
||||
// runtime: omitting both targets imports into the default root. Keeping the
|
||||
// historical require_one_of made the documented Golden Route unreachable.
|
||||
"doc/doc.shortcut_import": {
|
||||
`{"require_one_of":[["folder","workspace"]]}`: "",
|
||||
},
|
||||
}
|
||||
|
||||
func compatibleReviewedConstraintTransition(toolPath string, oldTool, newTool toolSchema) bool {
|
||||
transitions, ok := reviewedConstraintTransition[toolPath]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
want, ok := transitions[oldTool.Constraints]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return want == newTool.Constraints
|
||||
}
|
||||
|
||||
// compatibleInterfaceRefRedirect accepts repointing a tool at a different
|
||||
// backing RPC when the migration is an explicitly reviewed entry in
|
||||
// reviewedInterfaceRefRedirect **and** the CLI-facing contract is provably
|
||||
@@ -814,14 +841,120 @@ func compatiblePositionals(oldPositionals, newPositionals []positionalSchema) bo
|
||||
return true
|
||||
}
|
||||
|
||||
// parameterTypeChange identifies one published parameter type migration
|
||||
// exactly: which tool, which parameter, and in which direction. Nothing is
|
||||
// matched by wildcard — an entry differing in any of the four fields does not
|
||||
// apply.
|
||||
//
|
||||
// ToolPath is "<product id>/<tool id>" exactly as the comparison builds it (see
|
||||
// where toolPath is assembled), e.g.
|
||||
// "minutes/minutes.apply_minutes_permission". From and To are the canonical
|
||||
// type values schemaType produces — the JSON encoding of the "type" keyword,
|
||||
// so `"string"` **with** its quotes, not a bare string. Spelling either one any
|
||||
// other way silently disables the exemption instead of failing loudly (exactly
|
||||
// how reviewedInterfaceRefRedirect was broken twice), so
|
||||
// TestCrossPlatformCoverageReviewedParameterTypeChangeKeysAreCanonical
|
||||
// recomputes both through schemaType rather than trusting the table.
|
||||
type parameterTypeChange struct {
|
||||
ToolPath string
|
||||
Parameter string
|
||||
From string
|
||||
To string
|
||||
}
|
||||
|
||||
// reviewedParameterTypeChanges enumerates the individually reviewed parameter
|
||||
// type migrations this gate accepts. A published type is part of the Schema
|
||||
// contract and a swap cannot be proven safe from the type names alone, so a
|
||||
// migration is accepted only when this exact tool, parameter and old→new pair
|
||||
// appear here. Adding an entry is a contract decision and belongs in review,
|
||||
// not in a feature change.
|
||||
//
|
||||
// Entries are direction-sensitive by construction: "string" → "integer" is a
|
||||
// separate key from "integer" → "string", and only the reviewed direction is
|
||||
// accepted.
|
||||
//
|
||||
// The table alone does not admit a change: the migration is rejected unless
|
||||
// every other published field of the parameter is identical — not merely free
|
||||
// of compatibility failures. See compatibleReviewedParameterTypeChange.
|
||||
var reviewedParameterTypeChanges = map[parameterTypeChange]struct{}{
|
||||
// "dws minutes permission apply --policy" moved from a String flag to a
|
||||
// native Int flag, so the published type follows it from "string" to
|
||||
// "integer". A parameter's type is projected from the Cobra flag type
|
||||
// (provenance source cobra_flag_type), so it describes how the CLI accepts
|
||||
// the value, not how the backing RPC receives it.
|
||||
//
|
||||
// Accepted because no historical caller is invalidated:
|
||||
//
|
||||
// - Consumers read this Schema to construct CLI invocations, and a command
|
||||
// line is text either way: "--policy 4" is the same argv under both
|
||||
// declarations, and a quoted "--policy \"4\"" still reaches pflag as 4.
|
||||
// - The flag keeps enforcing the same [2,4] domain in RunE, and pflag's
|
||||
// base-0 parse accepts every base-10 spelling the previous
|
||||
// strconv.ParseInt(v, 10, 64) accepted, so the set of successful
|
||||
// invocations only widens.
|
||||
// - "integer" also matches what actually goes on the wire: this parameter
|
||||
// maps to property "policyId", which the command has always sent as a
|
||||
// number, so the new declaration is closer to the request than the old.
|
||||
//
|
||||
// The parameter's default stays absent on both sides here (a zero default is
|
||||
// not published) and every other published field is identical, which is why
|
||||
// the equality guard admits this entry. A migration that also moved the
|
||||
// default — or relaxed required, cleared required_when, widened enum, cleared
|
||||
// interface_type — would be rejected even though several of those are
|
||||
// individually compatible, because none of them were reviewed under this
|
||||
// entry.
|
||||
{
|
||||
ToolPath: "minutes/minutes.apply_minutes_permission",
|
||||
Parameter: "policy",
|
||||
From: `"string"`,
|
||||
To: `"integer"`,
|
||||
}: {},
|
||||
}
|
||||
|
||||
// parameterContractUnchangedExceptType reports whether every published field of
|
||||
// the parameter other than its type is identical.
|
||||
//
|
||||
// This is a full equality check rather than "the gate recorded no other
|
||||
// failure", because several field changes are individually *compatible* and so
|
||||
// produce no failure at all: relaxing required or cli_required, clearing
|
||||
// required_when, widening enum, clearing interface_type, and clearing property
|
||||
// through a reviewed mapping exclusion. Keying the carve-out on the failure list
|
||||
// would let any of those ride along with a reviewed type migration without ever
|
||||
// having been reviewed under that entry — the exemption would be wider than what
|
||||
// it documents.
|
||||
//
|
||||
// Comparing the struct also means a field added to parameterSchema later is
|
||||
// covered here automatically, instead of silently widening every existing entry.
|
||||
func parameterContractUnchangedExceptType(oldParameter, newParameter parameterSchema) bool {
|
||||
oldParameter.Type = ""
|
||||
newParameter.Type = ""
|
||||
return reflect.DeepEqual(oldParameter, newParameter)
|
||||
}
|
||||
|
||||
// compatibleReviewedParameterTypeChange accepts a published parameter type
|
||||
// migration when it is an explicitly reviewed entry **and** the rest of the
|
||||
// parameter's published contract is unchanged.
|
||||
func compatibleReviewedParameterTypeChange(toolPath, name string, oldParameter, newParameter parameterSchema) bool {
|
||||
_, reviewed := reviewedParameterTypeChanges[parameterTypeChange{
|
||||
ToolPath: toolPath,
|
||||
Parameter: name,
|
||||
From: oldParameter.Type,
|
||||
To: newParameter.Type,
|
||||
}]
|
||||
return reviewed && parameterContractUnchangedExceptType(oldParameter, newParameter)
|
||||
}
|
||||
|
||||
func checkParameterCompatibility(toolPath, name string, oldParameter, newParameter parameterSchema) []string {
|
||||
var failures []string
|
||||
if oldParameter.Type != newParameter.Type &&
|
||||
!compatibleReviewedParameterTypeChange(toolPath, name, oldParameter, newParameter) {
|
||||
failures = append(failures, fmt.Sprintf("schema tool %q parameter %q changed type", toolPath, name))
|
||||
}
|
||||
for _, field := range []struct {
|
||||
name string
|
||||
old string
|
||||
new string
|
||||
}{
|
||||
{name: "type", old: oldParameter.Type, new: newParameter.Type},
|
||||
{name: "default", old: oldParameter.Default, new: newParameter.Default},
|
||||
{name: "interface_default", old: oldParameter.InterfaceDefault, new: newParameter.InterfaceDefault},
|
||||
{name: "format", old: oldParameter.Format, new: newParameter.Format},
|
||||
|
||||
@@ -430,6 +430,239 @@ func TestMergeContracts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// registerReviewedParameterTypeFixture puts a fixture entry in the reviewed
|
||||
// table for the duration of one test, so the behaviour tests exercise the real
|
||||
// lookup instead of depending on whichever production entries happen to exist.
|
||||
func registerReviewedParameterTypeFixture(t *testing.T, change parameterTypeChange) {
|
||||
t.Helper()
|
||||
if _, exists := reviewedParameterTypeChanges[change]; exists {
|
||||
t.Fatalf("fixture %+v collides with a production entry", change)
|
||||
}
|
||||
reviewedParameterTypeChanges[change] = struct{}{}
|
||||
t.Cleanup(func() { delete(reviewedParameterTypeChanges, change) })
|
||||
}
|
||||
|
||||
func reviewedFormatTypeFixture() parameterTypeChange {
|
||||
return parameterTypeChange{
|
||||
ToolPath: "doc/doc.create",
|
||||
Parameter: "format",
|
||||
From: `"string"`,
|
||||
To: `"integer"`,
|
||||
}
|
||||
}
|
||||
|
||||
const docCreateFormatTypeFailure = `schema tool "doc/doc.create" parameter "format" changed type`
|
||||
|
||||
func assertSchemaFailureContains(t *testing.T, failures []string, want string) {
|
||||
t.Helper()
|
||||
for _, failure := range failures {
|
||||
if strings.Contains(failure, want) {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("failures %v do not contain %q", failures, want)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaCompatReviewedParameterTypeChange(t *testing.T) {
|
||||
baseline := cloneContract(baselineContract())
|
||||
current := cloneContract(baseline)
|
||||
mutateParameter(¤t, func(parameter *parameterSchema) { parameter.Type = `"integer"` })
|
||||
|
||||
// Without an entry the migration is still a blocking change.
|
||||
assertSchemaFailureContains(t, checkCompatibility(baseline, current), docCreateFormatTypeFailure)
|
||||
|
||||
registerReviewedParameterTypeFixture(t, reviewedFormatTypeFixture())
|
||||
if failures := checkCompatibility(baseline, current); len(failures) != 0 {
|
||||
t.Fatalf("a reviewed parameter type migration should pass: %v", failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaCompatReviewedParameterTypeChangeIsDirectionSensitive(t *testing.T) {
|
||||
registerReviewedParameterTypeFixture(t, reviewedFormatTypeFixture())
|
||||
|
||||
// The reverse migration shares tool and parameter but not direction, so the
|
||||
// string->integer entry must not admit it.
|
||||
baseline := cloneContract(baselineContract())
|
||||
mutateParameter(&baseline, func(parameter *parameterSchema) { parameter.Type = `"integer"` })
|
||||
current := cloneContract(baselineContract())
|
||||
assertSchemaFailureContains(t, checkCompatibility(baseline, current), docCreateFormatTypeFailure)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaCompatReviewedParameterTypeChangeRejectsOtherToolsAndParameters(t *testing.T) {
|
||||
baseline := cloneContract(baselineContract())
|
||||
current := cloneContract(baseline)
|
||||
mutateParameter(¤t, func(parameter *parameterSchema) { parameter.Type = `"integer"` })
|
||||
|
||||
// Same parameter and direction, different tool.
|
||||
other := reviewedFormatTypeFixture()
|
||||
other.ToolPath = "doc/doc.elsewhere"
|
||||
registerReviewedParameterTypeFixture(t, other)
|
||||
assertSchemaFailureContains(t, checkCompatibility(baseline, current), docCreateFormatTypeFailure)
|
||||
|
||||
// Same tool and direction, different parameter.
|
||||
elsewhere := reviewedFormatTypeFixture()
|
||||
elsewhere.Parameter = "title"
|
||||
registerReviewedParameterTypeFixture(t, elsewhere)
|
||||
assertSchemaFailureContains(t, checkCompatibility(baseline, current), docCreateFormatTypeFailure)
|
||||
}
|
||||
|
||||
// A reviewed migration is only accepted when the rest of the parameter's
|
||||
// contract held still. Each case bundles one unrelated regression with the
|
||||
// reviewed type change and requires the type failure to reappear, so the
|
||||
// exemption cannot carry a second change in behind it.
|
||||
func TestCrossPlatformCoverageSchemaCompatReviewedParameterTypeChangeRejectsBundledRegression(t *testing.T) {
|
||||
registerReviewedParameterTypeFixture(t, reviewedFormatTypeFixture())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*parameterSchema)
|
||||
}{
|
||||
{name: "changed default", mutate: func(p *parameterSchema) { p.Default = `"text"` }},
|
||||
{name: "changed interface_default", mutate: func(p *parameterSchema) { p.InterfaceDefault = `"text"` }},
|
||||
{name: "changed format", mutate: func(p *parameterSchema) { p.Format = "uri" }},
|
||||
{name: "redirected property", mutate: func(p *parameterSchema) { p.Property = "renamed" }},
|
||||
{name: "redirected interface_type", mutate: func(p *parameterSchema) { p.InterfaceType = "integer" }},
|
||||
{name: "newly required", mutate: func(p *parameterSchema) { p.Required = true }},
|
||||
{name: "newly cli_required", mutate: func(p *parameterSchema) { p.CLIRequired = true }},
|
||||
{name: "changed required_when", mutate: func(p *parameterSchema) { p.RequiredWhen = "always" }},
|
||||
{name: "narrowed enum", mutate: func(p *parameterSchema) { p.Enum = []string{"markdown"} }},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
baseline := cloneContract(baselineContract())
|
||||
current := cloneContract(baseline)
|
||||
mutateParameter(¤t, func(parameter *parameterSchema) {
|
||||
parameter.Type = `"integer"`
|
||||
test.mutate(parameter)
|
||||
})
|
||||
assertSchemaFailureContains(t, checkCompatibility(baseline, current), docCreateFormatTypeFailure)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Every drift below is individually *compatible* — on its own it produces no
|
||||
// failure at all — which is precisely why the carve-out cannot be keyed on "the
|
||||
// gate recorded no other failure for this parameter". Bundled with a reviewed
|
||||
// type migration they must still be rejected: none of them were reviewed under
|
||||
// that entry, and admitting them would make the exemption wider than what the
|
||||
// table documents.
|
||||
//
|
||||
// Each case first asserts that the drift alone really is compatible, so a future
|
||||
// rule change that starts reporting it turns into an explicit failure here
|
||||
// rather than quietly turning this test into a duplicate of the bundled-
|
||||
// regression cases above.
|
||||
func TestCrossPlatformCoverageSchemaCompatReviewedParameterTypeChangeRejectsIndividuallyCompatibleDrift(t *testing.T) {
|
||||
registerReviewedParameterTypeFixture(t, reviewedFormatTypeFixture())
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
baseline func(*parameterSchema)
|
||||
current func(*parameterSchema)
|
||||
}{
|
||||
{
|
||||
name: "relaxed required",
|
||||
baseline: func(p *parameterSchema) { p.Required = true },
|
||||
current: func(p *parameterSchema) { p.Required = false },
|
||||
},
|
||||
{
|
||||
name: "relaxed cli_required",
|
||||
baseline: func(p *parameterSchema) { p.CLIRequired = true },
|
||||
current: func(p *parameterSchema) { p.CLIRequired = false },
|
||||
},
|
||||
{
|
||||
name: "cleared required_when",
|
||||
baseline: func(p *parameterSchema) { p.RequiredWhen = "always" },
|
||||
current: func(p *parameterSchema) { p.RequiredWhen = "" },
|
||||
},
|
||||
{
|
||||
name: "widened enum",
|
||||
current: func(p *parameterSchema) { p.Enum = []string{"html", "markdown", "text"} },
|
||||
},
|
||||
{
|
||||
name: "cleared interface_type",
|
||||
baseline: func(p *parameterSchema) { p.InterfaceType = "string" },
|
||||
current: func(p *parameterSchema) { p.InterfaceType = "" },
|
||||
},
|
||||
{
|
||||
name: "cleared property through reviewed mapping exclusion",
|
||||
current: func(p *parameterSchema) {
|
||||
p.Property = ""
|
||||
p.PropertySource = propertySourceReviewedMappingExclusion
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
baseline := cloneContract(baselineContract())
|
||||
if test.baseline != nil {
|
||||
mutateParameter(&baseline, test.baseline)
|
||||
}
|
||||
|
||||
driftOnly := cloneContract(baseline)
|
||||
mutateParameter(&driftOnly, test.current)
|
||||
if failures := checkCompatibility(baseline, driftOnly); len(failures) != 0 {
|
||||
t.Fatalf("前提不成立:该变化本身已不兼容,用例测不到相等性守卫: %v", failures)
|
||||
}
|
||||
|
||||
bundled := cloneContract(baseline)
|
||||
mutateParameter(&bundled, func(parameter *parameterSchema) {
|
||||
parameter.Type = `"integer"`
|
||||
test.current(parameter)
|
||||
})
|
||||
assertSchemaFailureContains(t, checkCompatibility(baseline, bundled), docCreateFormatTypeFailure)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The behaviour tests above register their own fixtures, so they cannot catch a
|
||||
// production entry whose type values are spelled in the wrong form. That mistake
|
||||
// disables the exemption silently and only a real gate run reports it — exactly
|
||||
// how reviewedInterfaceRefRedirect was broken twice. Recompute both values
|
||||
// through schemaType instead of trusting the spelling in the table.
|
||||
func TestCrossPlatformCoverageReviewedParameterTypeChangeKeysAreCanonical(t *testing.T) {
|
||||
if len(reviewedParameterTypeChanges) == 0 {
|
||||
t.Fatal("豁免表为空:若确已清空,请连这条守卫一起删除")
|
||||
}
|
||||
for change := range reviewedParameterTypeChanges {
|
||||
if strings.Count(change.ToolPath, "/") != 1 ||
|
||||
strings.HasPrefix(change.ToolPath, "/") || strings.HasSuffix(change.ToolPath, "/") {
|
||||
t.Errorf("tool path %q 不是 \"<product id>/<tool id>\" 形态", change.ToolPath)
|
||||
}
|
||||
if strings.TrimSpace(change.Parameter) != change.Parameter || change.Parameter == "" ||
|
||||
strings.HasPrefix(change.Parameter, "-") {
|
||||
t.Errorf("%s: 参数名应是 Schema 里的裸名,登记为 %q", change.ToolPath, change.Parameter)
|
||||
}
|
||||
if change.From == change.To {
|
||||
t.Errorf("%s %s: From 与 To 相同,不构成迁移", change.ToolPath, change.Parameter)
|
||||
}
|
||||
for _, side := range []struct {
|
||||
label string
|
||||
value string
|
||||
}{{"From", change.From}, {"To", change.To}} {
|
||||
var name string
|
||||
if err := json.Unmarshal([]byte(side.value), &name); err != nil {
|
||||
t.Errorf("%s %s 的 %s=%q 不是合法的 JSON type 值(schemaType 产出带引号的形态,如 `\"string\"`): %v",
|
||||
change.ToolPath, change.Parameter, side.label, side.value, err)
|
||||
continue
|
||||
}
|
||||
if got := schemaType(map[string]any{"type": name}); got != side.value {
|
||||
t.Errorf("%s %s 的 %s 不是规范形态\n 登记: %s\n 规范: %s",
|
||||
change.ToolPath, change.Parameter, side.label, side.value, got)
|
||||
}
|
||||
// 复算只能证明引号形态对,证明不了类型名本身没拼错——"integar" 同样
|
||||
// 能通过复算。JSON Schema 的 type 取值是封闭集合,直接校验。
|
||||
switch name {
|
||||
case "string", "integer", "number", "boolean", "array", "object", "null":
|
||||
default:
|
||||
t.Errorf("%s %s 的 %s=%q 不是 JSON Schema 的合法 type 名",
|
||||
change.ToolPath, change.Parameter, side.label, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func baselineContract() schemaContract {
|
||||
return schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{
|
||||
"doc": {Tools: map[string]toolSchema{
|
||||
@@ -693,6 +926,37 @@ func TestCrossPlatformCoverageSchemaCompatAdditiveConstraintEvolution(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaCompatReviewedConstraintTransition(t *testing.T) {
|
||||
const toolPath = "doc/doc.shortcut_import"
|
||||
oldTool := toolSchema{Constraints: `{"require_one_of":[["folder","workspace"]]}`}
|
||||
newTool := oldTool
|
||||
newTool.Constraints = ""
|
||||
|
||||
if !compatibleReviewedConstraintTransition(toolPath, oldTool, newTool) {
|
||||
t.Fatal("reviewed doc import target removal must be accepted")
|
||||
}
|
||||
if failures := checkToolCompatibility(toolPath, oldTool, newTool); len(failures) != 0 {
|
||||
t.Fatalf("reviewed doc import constraint transition failed: %v", failures)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
path string
|
||||
old string
|
||||
new string
|
||||
}{
|
||||
{name: "unlisted tool", path: "doc/doc.other", old: oldTool.Constraints, new: ""},
|
||||
{name: "unlisted source", path: toolPath, old: `{"require_one_of":[["folder","workspace","name"]]}`, new: ""},
|
||||
{name: "unlisted target", path: toolPath, old: oldTool.Constraints, new: `{}`},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if compatibleReviewedConstraintTransition(test.path, toolSchema{Constraints: test.old}, toolSchema{Constraints: test.new}) {
|
||||
t.Fatal("unreviewed constraint transition unexpectedly passed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Clearing a property through the reviewed mapping exclusion table is the one
|
||||
// accepted shape, mirroring the interface_type retirement allowance. A leaf
|
||||
// whose backing RPC moved to a nested payload has no honest flat property to
|
||||
|
||||
Reference in New Issue
Block a user