From 12745b14c35600ab2407bb9f4c4ef429c78760ca Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Wed, 20 May 2026 12:12:48 +0300 Subject: [PATCH] fix(skd-validate): handle composite valueType + system-type namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calibrated against ~868 real ERP/БП reports — three false positives caught: 1. Composite types: xs:string followed by d4p1:CatalogRef.X with a single trailing is a legitimate pattern. Rewritten check to collect all and qualifier blocks per , then verify each qualifier has a matching scalar type anywhere in the block — not necessarily right before it. 2. System types: AccumulationRecordType (and similar enum-like system types) use the http://v8.1c.ru/8.1/data/enterprise namespace (without /current-config) and a plain TypeName local name with no dot. Whitelisted as a second valid namespace for ref-like types. 3. v8: scalar types extended: v8:Null, v8:Type, v8:ValueStorage — present in real configs as type-less placeholders. Also reverted SKILL.md change from previous commit (validator details don't belong in user-facing docs). Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/skd-validate/SKILL.md | 2 +- .../skd-validate/scripts/skd-validate.ps1 | 158 ++++++++++-------- .../skd-validate/scripts/skd-validate.py | 136 ++++++++------- 3 files changed, 166 insertions(+), 130 deletions(-) diff --git a/.claude/skills/skd-validate/SKILL.md b/.claude/skills/skd-validate/SKILL.md index 48d6b622..7bfea969 100644 --- a/.claude/skills/skd-validate/SKILL.md +++ b/.claude/skills/skd-validate/SKILL.md @@ -10,7 +10,7 @@ allowed-tools: # /skd-validate — валидация СКД (DataCompositionSchema) -Проверяет структурную корректность Template.xml схемы компоновки данных. Выявляет ошибки формата, битые ссылки, дубликаты имён, невалидный XDTO в `` / `` (отсутствие префикса `xs:`, несовпадение типа и квалификаторов, литералы вроде `_` в `DesignTimeValue`). +Проверяет структурную корректность Template.xml схемы компоновки данных. Выявляет ошибки формата, битые ссылки, дубликаты имён. ## Параметры diff --git a/.claude/skills/skd-validate/scripts/skd-validate.ps1 b/.claude/skills/skd-validate/scripts/skd-validate.ps1 index 781c0405..a13d88a5 100644 --- a/.claude/skills/skd-validate/scripts/skd-validate.ps1 +++ b/.claude/skills/skd-validate/scripts/skd-validate.ps1 @@ -745,111 +745,127 @@ $validTypeQualifier = @{ 'xs:boolean' = '' 'v8:StandardPeriod' = '' 'v8:UUID' = '' + 'v8:Null' = '' + 'v8:Type' = '' + 'v8:ValueStorage' = '' } $validSign = @('Any', 'Nonnegative', 'Negative') $validLength = @('Variable', 'Fixed') $validFractions = @('Date', 'DateTime', 'Time') +# DCS supports composite types: multiple blocks may share a single +# trailing qualifier block (e.g. xs:string + CatalogRef.X + StringQualifiers). +# So we collect all types and qualifiers per valueType, then check consistency. +$qualifierProducers = @{ + 'v8:NumberQualifiers' = 'xs:decimal' + 'v8:StringQualifiers' = 'xs:string' + 'v8:DateQualifiers' = 'xs:dateTime' +} + $valueTypeNodes = $root.SelectNodes("//s:valueType", $ns) $vtChecked = 0 $vtOk = $true foreach ($vt in $valueTypeNodes) { $vtChecked++ - # Walk children in document order - $children = @($vt.ChildNodes | Where-Object { $_.NodeType -eq 'Element' }) - $lastType = $null # short form like 'xs:decimal' or '' (ref types resolved to '') + $types = @() # list of short type strings; '' marks a ref type + $qualifiers = @() # list of @{ name = 'v8:XQualifiers'; node = $child } - foreach ($child in $children) { - $qName = "$($child.Prefix):$($child.LocalName)" - if ($child.LocalName -eq 'Type' -and $child.NamespaceURI -eq 'http://v8.1c.ru/8.1/data/core') { - $t = $child.InnerText.Trim() + foreach ($child in $vt.ChildNodes) { + if ($child.NodeType -ne 'Element') { continue } + if ($child.NamespaceURI -ne 'http://v8.1c.ru/8.1/data/core') { continue } + $localName = $child.LocalName + + if ($localName -eq 'Type') { + $t = "$($child.InnerText)".Trim() if (-not $t) { Report-Error "valueType: is empty" $vtOk = $false - $lastType = $null continue } - # Must have a known prefix — xs:, v8:, or any prefix bound to current-config namespace if ($t -match '^([A-Za-z][A-Za-z0-9]*):(.+)$') { $prefix = $Matches[1] - $localName = $Matches[2] - $lastType = $t + $localT = $Matches[2] if ($prefix -eq 'xs' -or $prefix -eq 'v8') { if (-not $validTypeQualifier.ContainsKey($t)) { Report-Error "valueType: unknown type '$t' (allowed: xs:decimal/xs:string/xs:dateTime/xs:boolean/v8:StandardPeriod or :*Ref.X)" $vtOk = $false - $lastType = $null + } else { + $types += $t } } else { - # Inline-declared prefix — should resolve to current-config namespace $prefixNs = $child.GetNamespaceOfPrefix($prefix) - if ($prefixNs -ne 'http://v8.1c.ru/8.1/data/enterprise/current-config') { - Report-Error "valueType: type '$t' uses prefix '$prefix' which is not bound to enterprise/current-config namespace" - $vtOk = $false - $lastType = $null - } elseif (-not ($localName -match '^[A-Za-z]+(Ref)?\.')) { - Report-Error "valueType: ref type '$t' must look like ':.' (e.g. d5p1:CatalogRef.X)" - $vtOk = $false - $lastType = '' + if ($prefixNs -eq 'http://v8.1c.ru/8.1/data/enterprise/current-config') { + if (-not ($localT -match '^[A-Za-z]+(Ref)?\.')) { + Report-Error "valueType: ref type '$t' must look like ':.' (e.g. d5p1:CatalogRef.X)" + $vtOk = $false + } else { + $types += '' # ref — no qualifier needed + } + } elseif ($prefixNs -eq 'http://v8.1c.ru/8.1/data/enterprise') { + # System types: AccumulationRecordType etc. — no qualifiers + if (-not ($localT -match '^[A-Za-z][A-Za-z0-9]*$')) { + Report-Error "valueType: system type '$t' has unexpected local-name shape" + $vtOk = $false + } else { + $types += '' + } } else { - $lastType = '' # ref type — no qualifier expected + Report-Error "valueType: type '$t' uses prefix '$prefix' bound to unexpected namespace '$prefixNs'" + $vtOk = $false } } } else { Report-Error "valueType: type '$t' has no namespace prefix (expected xs:/v8:/d5p1: — e.g. xs:decimal not decimal)" $vtOk = $false - $lastType = $null } - } elseif ($child.LocalName -match 'Qualifiers$' -and $child.NamespaceURI -eq 'http://v8.1c.ru/8.1/data/core') { - # Qualifier block — must match preceding Type - $expected = if ($lastType -and $validTypeQualifier.ContainsKey($lastType)) { - $validTypeQualifier[$lastType] - } else { $null } - - if ($null -eq $expected -or $expected -eq '') { - Report-Error "valueType: <$qName> after $lastType — this type has no qualifiers" - $vtOk = $false - } elseif ($qName -ne $expected) { - Report-Error "valueType: <$qName> doesn't match $lastType (expected <$expected>)" - $vtOk = $false - } else { - # Validate qualifier internals - if ($qName -eq 'v8:NumberQualifiers') { - $digits = $child.SelectSingleNode("v8:Digits", $ns) - $frac = $child.SelectSingleNode("v8:FractionDigits", $ns) - $sign = $child.SelectSingleNode("v8:AllowedSign", $ns) - if (-not $digits -or -not ($digits.InnerText -match '^\d+$')) { - Report-Error "v8:NumberQualifiers: missing or not a non-negative integer" - $vtOk = $false - } - if (-not $frac -or -not ($frac.InnerText -match '^\d+$')) { - Report-Error "v8:NumberQualifiers: missing or not a non-negative integer" - $vtOk = $false - } - if ($sign -and $sign.InnerText -and $sign.InnerText -notin $validSign) { - Report-Error "v8:NumberQualifiers: $($sign.InnerText) — must be one of: $($validSign -join ', ')" - $vtOk = $false - } - } elseif ($qName -eq 'v8:StringQualifiers') { - $len = $child.SelectSingleNode("v8:Length", $ns) - $al = $child.SelectSingleNode("v8:AllowedLength", $ns) - if (-not $len -or -not ($len.InnerText -match '^\d+$')) { - Report-Error "v8:StringQualifiers: missing or not a non-negative integer" - $vtOk = $false - } - if ($al -and $al.InnerText -and $al.InnerText -notin $validLength) { - Report-Error "v8:StringQualifiers: $($al.InnerText) — must be one of: $($validLength -join ', ')" - $vtOk = $false - } - } elseif ($qName -eq 'v8:DateQualifiers') { - $df = $child.SelectSingleNode("v8:DateFractions", $ns) - if ($df -and $df.InnerText -and $df.InnerText -notin $validFractions) { - Report-Error "v8:DateQualifiers: $($df.InnerText) — must be one of: $($validFractions -join ', ')" - $vtOk = $false - } + } elseif ($localName -match 'Qualifiers$') { + $qName = "v8:$localName" + $qualifiers += @{ name = $qName; node = $child } + # Validate qualifier internals + if ($qName -eq 'v8:NumberQualifiers') { + $digits = $child.SelectSingleNode("v8:Digits", $ns) + $frac = $child.SelectSingleNode("v8:FractionDigits", $ns) + $sign = $child.SelectSingleNode("v8:AllowedSign", $ns) + if (-not $digits -or -not ($digits.InnerText -match '^\d+$')) { + Report-Error "v8:NumberQualifiers: missing or not a non-negative integer" + $vtOk = $false + } + if (-not $frac -or -not ($frac.InnerText -match '^\d+$')) { + Report-Error "v8:NumberQualifiers: missing or not a non-negative integer" + $vtOk = $false + } + if ($sign -and $sign.InnerText -and $sign.InnerText -notin $validSign) { + Report-Error "v8:NumberQualifiers: $($sign.InnerText) — must be one of: $($validSign -join ', ')" + $vtOk = $false + } + } elseif ($qName -eq 'v8:StringQualifiers') { + $len = $child.SelectSingleNode("v8:Length", $ns) + $al = $child.SelectSingleNode("v8:AllowedLength", $ns) + if (-not $len -or -not ($len.InnerText -match '^\d+$')) { + Report-Error "v8:StringQualifiers: missing or not a non-negative integer" + $vtOk = $false + } + if ($al -and $al.InnerText -and $al.InnerText -notin $validLength) { + Report-Error "v8:StringQualifiers: $($al.InnerText) — must be one of: $($validLength -join ', ')" + $vtOk = $false + } + } elseif ($qName -eq 'v8:DateQualifiers') { + $df = $child.SelectSingleNode("v8:DateFractions", $ns) + if ($df -and $df.InnerText -and $df.InnerText -notin $validFractions) { + Report-Error "v8:DateQualifiers: $($df.InnerText) — must be one of: $($validFractions -join ', ')" + $vtOk = $false } } - $lastType = $null # qualifier consumed; next must be another Type or end + } + } + + # Cross-check: every qualifier must have a matching scalar type in this valueType + foreach ($q in $qualifiers) { + $producer = $qualifierProducers[$q.name] + if (-not $producer) { continue } + if ($types -notcontains $producer) { + Report-Error "valueType: <$($q.name)> has no matching $producer in this valueType" + $vtOk = $false } } } diff --git a/.claude/skills/skd-validate/scripts/skd-validate.py b/.claude/skills/skd-validate/scripts/skd-validate.py index 409ad590..7454c214 100644 --- a/.claude/skills/skd-validate/scripts/skd-validate.py +++ b/.claude/skills/skd-validate/scripts/skd-validate.py @@ -698,6 +698,9 @@ _VALID_TYPE_QUALIFIER = { 'xs:boolean': '', 'v8:StandardPeriod': '', 'v8:UUID': '', + 'v8:Null': '', + 'v8:Type': '', + 'v8:ValueStorage': '', } _VALID_SIGN = ('Any', 'Nonnegative', 'Negative') _VALID_LENGTH = ('Variable', 'Fixed') @@ -705,90 +708,107 @@ _VALID_FRACTIONS = ('Date', 'DateTime', 'Time') _V8_NS_URI = 'http://v8.1c.ru/8.1/data/core' _CONFIG_NS_URI = 'http://v8.1c.ru/8.1/data/enterprise/current-config' +# DCS supports composite types: multiple blocks may share a single +# trailing qualifier block (e.g. xs:string + CatalogRef.X + StringQualifiers). +# So we collect all types and qualifiers per valueType, then check consistency. +_QUALIFIER_PRODUCERS = { + 'v8:NumberQualifiers': 'xs:decimal', + 'v8:StringQualifiers': 'xs:string', + 'v8:DateQualifiers': 'xs:dateTime', +} + vt_nodes = find_all(root, "//s:valueType") vt_checked = 0 vt_ok = True for vt in vt_nodes: vt_checked += 1 - last_type = None # short form 'xs:decimal' or '' (ref — no qualifiers) + types = [] # short type strings; '' marks a ref type + qualifiers = [] # list of (qName, node) + for child in vt: if not isinstance(child.tag, str): - continue # comments etc. - qname_local = etree.QName(child.tag).localname - qname_ns = etree.QName(child.tag).namespace + continue + qn = etree.QName(child.tag) + if qn.namespace != _V8_NS_URI: + continue + local = qn.localname - if qname_local == 'Type' and qname_ns == _V8_NS_URI: + if local == 'Type': t = (child.text or '').strip() if not t: report_error("valueType: is empty") vt_ok = False - last_type = None continue m = _re_vt.match(r'^([A-Za-z][A-Za-z0-9]*):(.+)$', t) if not m: report_error(f"valueType: type '{t}' has no namespace prefix (expected xs:/v8:/d5p1: — e.g. xs:decimal not decimal)") vt_ok = False - last_type = None continue - prefix, local = m.group(1), m.group(2) - last_type = t + prefix, local_t = m.group(1), m.group(2) if prefix in ('xs', 'v8'): if t not in _VALID_TYPE_QUALIFIER: report_error(f"valueType: unknown type '{t}' (allowed: xs:decimal/xs:string/xs:dateTime/xs:boolean/v8:StandardPeriod or :*Ref.X)") vt_ok = False - last_type = None - else: - # Inline-declared prefix — must resolve to current-config namespace - prefix_ns = child.nsmap.get(prefix) - if prefix_ns != _CONFIG_NS_URI: - report_error(f"valueType: type '{t}' uses prefix '{prefix}' which is not bound to enterprise/current-config namespace") - vt_ok = False - last_type = None - elif not _re_vt.match(r'^[A-Za-z]+(Ref)?\.', local): - report_error(f"valueType: ref type '{t}' must look like ':.' (e.g. d5p1:CatalogRef.X)") - vt_ok = False - last_type = '' else: - last_type = '' # ref — no qualifier expected - - elif qname_local.endswith('Qualifiers') and qname_ns == _V8_NS_URI: - q_name = f"v8:{qname_local}" - expected = _VALID_TYPE_QUALIFIER.get(last_type) if last_type else None - if expected is None or expected == '': - report_error(f"valueType: <{q_name}> after {last_type} — this type has no qualifiers") - vt_ok = False - elif q_name != expected: - report_error(f"valueType: <{q_name}> doesn't match {last_type} (expected <{expected}>)") - vt_ok = False + types.append(t) else: - if q_name == 'v8:NumberQualifiers': - digits = find(child, "v8:Digits") - frac = find(child, "v8:FractionDigits") - sign = find(child, "v8:AllowedSign") - if digits is None or not _re_vt.match(r'^\d+$', text_of(digits)): - report_error("v8:NumberQualifiers: missing or not a non-negative integer") + prefix_ns = child.nsmap.get(prefix) + if prefix_ns == _CONFIG_NS_URI: + if not _re_vt.match(r'^[A-Za-z]+(Ref)?\.', local_t): + report_error(f"valueType: ref type '{t}' must look like ':.' (e.g. d5p1:CatalogRef.X)") vt_ok = False - if frac is None or not _re_vt.match(r'^\d+$', text_of(frac)): - report_error("v8:NumberQualifiers: missing or not a non-negative integer") + else: + types.append('') # ref — no qualifier needed + elif prefix_ns == 'http://v8.1c.ru/8.1/data/enterprise': + # System types: AccumulationRecordType etc. — no qualifiers + if not _re_vt.match(r'^[A-Za-z][A-Za-z0-9]*$', local_t): + report_error(f"valueType: system type '{t}' has unexpected local-name shape") vt_ok = False - if sign is not None and text_of(sign) and text_of(sign) not in _VALID_SIGN: - report_error(f"v8:NumberQualifiers: {text_of(sign)} — must be one of: {', '.join(_VALID_SIGN)}") - vt_ok = False - elif q_name == 'v8:StringQualifiers': - length = find(child, "v8:Length") - al = find(child, "v8:AllowedLength") - if length is None or not _re_vt.match(r'^\d+$', text_of(length)): - report_error("v8:StringQualifiers: missing or not a non-negative integer") - vt_ok = False - if al is not None and text_of(al) and text_of(al) not in _VALID_LENGTH: - report_error(f"v8:StringQualifiers: {text_of(al)} — must be one of: {', '.join(_VALID_LENGTH)}") - vt_ok = False - elif q_name == 'v8:DateQualifiers': - df = find(child, "v8:DateFractions") - if df is not None and text_of(df) and text_of(df) not in _VALID_FRACTIONS: - report_error(f"v8:DateQualifiers: {text_of(df)} — must be one of: {', '.join(_VALID_FRACTIONS)}") - vt_ok = False - last_type = None # consumed + else: + types.append('') + else: + report_error(f"valueType: type '{t}' uses prefix '{prefix}' bound to unexpected namespace '{prefix_ns}'") + vt_ok = False + + elif local.endswith('Qualifiers'): + q_name = f"v8:{local}" + qualifiers.append((q_name, child)) + if q_name == 'v8:NumberQualifiers': + digits = find(child, "v8:Digits") + frac = find(child, "v8:FractionDigits") + sign = find(child, "v8:AllowedSign") + if digits is None or not _re_vt.match(r'^\d+$', text_of(digits)): + report_error("v8:NumberQualifiers: missing or not a non-negative integer") + vt_ok = False + if frac is None or not _re_vt.match(r'^\d+$', text_of(frac)): + report_error("v8:NumberQualifiers: missing or not a non-negative integer") + vt_ok = False + if sign is not None and text_of(sign) and text_of(sign) not in _VALID_SIGN: + report_error(f"v8:NumberQualifiers: {text_of(sign)} — must be one of: {', '.join(_VALID_SIGN)}") + vt_ok = False + elif q_name == 'v8:StringQualifiers': + length = find(child, "v8:Length") + al = find(child, "v8:AllowedLength") + if length is None or not _re_vt.match(r'^\d+$', text_of(length)): + report_error("v8:StringQualifiers: missing or not a non-negative integer") + vt_ok = False + if al is not None and text_of(al) and text_of(al) not in _VALID_LENGTH: + report_error(f"v8:StringQualifiers: {text_of(al)} — must be one of: {', '.join(_VALID_LENGTH)}") + vt_ok = False + elif q_name == 'v8:DateQualifiers': + df = find(child, "v8:DateFractions") + if df is not None and text_of(df) and text_of(df) not in _VALID_FRACTIONS: + report_error(f"v8:DateQualifiers: {text_of(df)} — must be one of: {', '.join(_VALID_FRACTIONS)}") + vt_ok = False + + # Cross-check: every qualifier must have a matching scalar type in this valueType + for q_name, _ in qualifiers: + producer = _QUALIFIER_PRODUCERS.get(q_name) + if not producer: + continue + if producer not in types: + report_error(f"valueType: <{q_name}> has no matching {producer} in this valueType") + vt_ok = False if vt_checked > 0 and vt_ok: report_ok(f"{vt_checked} valueType block(s): structure and qualifiers OK")