| name | check-vtt-compliance |
| description | Generates EXHAUSTIVE WebVTT compliance report checking all 76 rules individually + tag/setting/entity coverage with deep validation analysis to identify ALL issues in pycaption code. |
check-vtt-compliance
What this skill does
Exhaustive WebVTT compliance checker - 5 phases:
- Deep validation (critical rules with function-level detection)
- Systematic checking (all 76 rules individually verified)
- Tag/Setting/Entity coverage (8+6+7)
- Test coverage
- Report generation
Usage: /check-vtt-compliance
Implementation
Run this Python script (context-optimized):
import os, re, glob
from datetime import datetime
print("WebVTT Exhaustive Compliance Check\n" + "=" * 60)
# ===== INIT =====
webvtt_files = [
'pycaption/webvtt/reader.py',
'pycaption/webvtt/writer.py',
'pycaption/webvtt/constants.py',
'pycaption/webvtt/__init__.py',
]
for wf in webvtt_files[:3]:
if not os.path.exists(wf):
print(f"ERROR: {wf} not found")
raise SystemExit(1)
def _read(p):
with open(p) as _fh: return _fh.read()
content = "\n".join(_read(f) for f in webvtt_files if os.path.exists(f))
reader_content = _read('pycaption/webvtt/reader.py')
writer_content = _read('pycaption/webvtt/writer.py')
constants_content = _read('pycaption/webvtt/constants.py')
webvtt_file = 'pycaption/webvtt/ (package)'
# Also read geometry.py and base.py for Layout/CaptionNode handling
support_files = ['pycaption/geometry.py', 'pycaption/base.py']
support_content = "\n".join(_read(f) for f in support_files if os.path.exists(f))
spec_file = 'ai_artifacts/specs/vtt/vtt_specs_summary.md'
if not os.path.exists(spec_file):
print(f"ERROR: {spec_file} not found. Run analyze-vtt-docs first.")
raise SystemExit(1)
spec = _read(spec_file)
# Extract all rules from spec
all_rules = {}
for match in re.finditer(r'\*\*\[(RULE-[A-Z]+-\d{3}|IMPL-(?:[A-Z]+-)?\d{3})\]\*\*\s*(.+?)(?:\n|$)', spec):
rule_id = match.group(1)
rule_name = match.group(2).strip()
rule_start = match.start()
next_rule = re.search(r'\*\*\[(?:RULE-[A-Z]+-\d{3}|IMPL-(?:[A-Z]+-)?\d{3})\]\*\*', spec[rule_start + 1:])
rule_block = spec[rule_start:rule_start + 1 + next_rule.start()] if next_rule else spec[rule_start:]
level_match = re.search(r'\*\*Level:\*\*\s*(MUST NOT|MUST|SHOULD|MAY)', rule_block)
level = level_match.group(1) if level_match else 'UNKNOWN'
all_rules[rule_id] = {'name': rule_name, 'level': level}
print(f"[INIT] Spec: {len(all_rules)} rules, Code: {len(content)} chars")
# ===== SANITY CHECK: Verify expected code landmarks exist =====
landmarks = {
'class WebVTTReader': ('pycaption/webvtt/reader.py', r'class\s+WebVTTReader\b'),
'class WebVTTWriter': ('pycaption/webvtt/writer.py', r'class\s+WebVTTWriter\b'),
'def detect (WebVTTReader)': ('pycaption/webvtt/reader.py', r'def\s+detect\b'),
'def read (WebVTTReader)': ('pycaption/webvtt/reader.py', r'def\s+read\b'),
'def write (WebVTTWriter)': ('pycaption/webvtt/writer.py', r'def\s+write\b'),
'class Layout': ('pycaption/geometry.py', r'class\s+Layout\b'),
'def _parse_cue_settings': ('pycaption/webvtt/reader.py', r'def\s+_parse_cue_settings\b'),
'def _parse_style_blocks': ('pycaption/webvtt/reader.py', r'def\s+_parse_style_blocks\b'),
'CUE_SETTING_PATTERN': ('pycaption/webvtt/constants.py', r'CUE_SETTING_PATTERN\s*=\s*re\.compile'),
'STYLE_SELECTOR_PATTERN': ('pycaption/webvtt/constants.py', r'STYLE_SELECTOR_PATTERN\s*=\s*re\.compile'),
'TAG_SPLIT_PATTERN': ('pycaption/webvtt/constants.py', r'TAG_SPLIT_PATTERN\s*=\s*re\.compile'),
'def _classify_tag': ('pycaption/webvtt/reader.py', r'def\s+_classify_tag\b'),
'WritingDirectionEnum': ('pycaption/geometry.py', r'class\s+WritingDirectionEnum\b'),
}
stale_warnings = []
for name, (expected_file, pattern) in landmarks.items():
try:
with open(expected_file) as _fh:
if not re.search(pattern, _fh.read()):
stale_warnings.append(f"{name} not found in {expected_file}")
except FileNotFoundError:
stale_warnings.append(f"{expected_file} does not exist")
if stale_warnings:
print(f"[SANITY] WARNING: {len(stale_warnings)} landmark(s) not found — patterns may be stale:")
for w in stale_warnings:
print(f" - {w}")
else:
print("[SANITY] All code landmarks found")
# ===== PHASE 1: DEEP VALIDATION =====
# Check critical rules at function level, not keyword level
print("\n[1/5] Deep Validation Analysis")
deep_results = {}
# RULE-FMT-001: WEBVTT header detection
has_header_validate = bool(re.search(r'def _validate_header|startswith.*"WEBVTT ', content))
has_detect_first_line = bool(re.search(r'first_line\s*==\s*"WEBVTT"|first_line\.startswith\("WEBVTT', content))
deep_results['RULE-FMT-001'] = {
'name': 'WEBVTT header',
'detected': has_header_validate or has_detect_first_line,
'validated': has_header_validate and has_detect_first_line,
'note': '' if (has_header_validate and has_detect_first_line) else 'Header validation incomplete',
}
# RULE-FMT-002: UTF-8 encoding (base class _decode_content handles via utf-8-sig)
has_utf8_check = bool(re.search(r'isinstance.*str|encoding.*utf|_decode_content', content, re.I))
has_utf8_validate = bool(re.search(r'UnicodeDecodeError|decode\("utf-8"|decode\("utf-8-sig"|_decode_content', content + "\n" + support_content, re.I))
deep_results['RULE-FMT-002'] = {
'name': 'UTF-8 encoding',
'detected': has_utf8_check,
'validated': has_utf8_validate,
'note': '' if has_utf8_validate else 'Checks isinstance(content, str) but no explicit UTF-8 decode validation',
}
# RULE-TIME-001: Timestamp format [HH:]MM:SS.mmm
has_timestamp_parse = bool(re.search(r'TIMESTAMP_PATTERN.*compile.*\d.*:.*\d', content, re.DOTALL))
has_timestamp_func = bool(re.search(r'def _parse_timestamp', content))
deep_results['RULE-TIME-001'] = {
'name': 'Timestamp format parsing',
'detected': has_timestamp_parse and has_timestamp_func,
'validated': has_timestamp_func,
'note': '',
}
# RULE-TIME-003: Exactly 3 millisecond digits
has_3_digits = bool(re.search(r'\\d\{3\}', content))
deep_results['RULE-TIME-003'] = {
'name': 'Milliseconds exactly 3 digits',
'detected': has_3_digits,
'validated': has_3_digits,
'note': '',
}
# RULE-TIME-005: Start <= end
has_start_end_check = bool(re.search(r'start\s*>\s*end', content))
has_start_end_error = bool(re.search(r'raise.*End timestamp.*not greater|raise.*start.*end', content, re.I))
disabled_by_default = bool(re.search(r'ignore_timing_errors.*=\s*True', content))
deep_results['RULE-TIME-005'] = {
'name': 'Start time <= end time',
'detected': has_start_end_check,
'validated': has_start_end_error,
'note': 'DISABLED BY DEFAULT (ignore_timing_errors=True)' if disabled_by_default else '',
}
# RULE-TIME-006: Monotonic timestamps
has_monotonic_check = bool(re.search(r'start\s*<\s*last_start_time', content))
has_monotonic_error = bool(re.search(r'raise.*not greater than or equal.*previous', content, re.I))
deep_results['RULE-TIME-006'] = {
'name': 'Monotonic timestamps',
'detected': has_monotonic_check,
'validated': has_monotonic_error,
'note': 'DISABLED BY DEFAULT (ignore_timing_errors=True)' if disabled_by_default else '',
}
# RULE-CUE-001: Timing separator ' --> '
# Only match the TIMING_LINE_PATTERN definition, not general '-->' usage in style/note handling
has_timing_pattern = bool(re.search(r'TIMING_LINE_PATTERN\s*=\s*re\.compile', content))
has_timing_parse = bool(re.search(r'TIMING_LINE_PATTERN\.match|TIMING_LINE_PATTERN\.search', content))
deep_results['RULE-CUE-001'] = {
'name': 'Timing separator -->',
'detected': has_timing_pattern,
'validated': has_timing_pattern and has_timing_parse,
'note': '',
}
# RULE-SET-002: Zero-value positions silently dropped on write
writer_section = writer_content
zero_pos_bug = bool(re.search(r'if left_offset:', writer_section)) and not bool(re.search(r'if left_offset is not None', writer_section))
zero_line_bug = bool(re.search(r'if top_offset:', writer_section)) and not bool(re.search(r'if top_offset is not None', writer_section))
zero_size_bug = bool(re.search(r'if cue_width:', writer_section)) and not bool(re.search(r'if cue_width is not None', writer_section))
deep_results['RULE-SET-002'] = {
'name': 'Zero-value position/line/size dropped on write',
'detected': True,
'validated': not (zero_pos_bug or zero_line_bug or zero_size_bug),
'note': f'Writer uses truthiness check instead of `is not None`: position={zero_pos_bug}, line={zero_line_bug}, size={zero_size_bug}' if (zero_pos_bug or zero_line_bug or zero_size_bug) else '',
}
if zero_pos_bug or zero_line_bug or zero_size_bug:
dropped = [x for x, v in [('position', zero_pos_bug), ('line', zero_line_bug), ('size', zero_size_bug)] if v]
validation_gaps_extra = {
'rule_id': 'RULE-SET-002', 'name': 'Zero-value cue settings silently dropped',
'status': 'TRUTHINESS_BUG', 'severity': 'MUST',
'note': f'`if {dropped[0]}:` is falsy for 0. Cues at position:0/line:0/size:0 lose positioning. '
f'Affected: {", ".join(dropped)}. Fix: use `is not None` checks.',
}
print(f" RULE-SET-002: {'PASS' if not (zero_pos_bug or zero_line_bug or zero_size_bug) else 'TRUTHINESS BUG — zero values dropped'}")
# RULE-SET-005: Center alignment silently dropped on write
center_dropped = bool(re.search(r'alignment.*!=.*CENTER|alignment.*!=.*WEBVTT_VERSION_OF\[HorizontalAlignmentEnum\.CENTER\]', writer_section))
deep_results['RULE-SET-005'] = {
'name': 'Center alignment silently dropped on write',
'detected': True,
'validated': not center_dropped,
'note': 'Writer skips align:center assuming it is the default. Explicit center alignment lost on round-trip.' if center_dropped else '',
}
print(f" RULE-SET-005: {'PASS' if not center_dropped else 'CENTER ALIGNMENT DROPPED'}")
# RULE-VAL-007: Timing validation disabled by default
timing_disabled = bool(re.search(r'ignore_timing_errors\s*=\s*True', content))
deep_results['RULE-VAL-007'] = {
'name': 'Timing validation disabled by default',
'detected': True,
'validated': not timing_disabled,
'note': 'ignore_timing_errors defaults to True. Invalid timing (start>end, non-monotonic) silently accepted.' if timing_disabled else '',
}
print(f" RULE-VAL-007: {'PASS' if not timing_disabled else 'DISABLED BY DEFAULT'}")
# IMPL-PARSE-006 deep: Tag parsing via TAG_SPLIT_PATTERN + _classify_tag
# Tags are now preserved as CaptionNode.STYLE open/close pairs, not stripped
has_tag_split = bool(re.search(r'TAG_SPLIT_PATTERN\.split', content))
has_classify_tag = bool(re.search(r'def _classify_tag', content))
has_known_tags = bool(re.search(r'KNOWN_TAGS\s*=\s*frozenset', content))
deep_results['IMPL-PARSE-006'] = {
'name': 'Inline tag parsing and preservation',
'detected': has_tag_split and has_classify_tag,
'validated': has_tag_split and has_classify_tag and has_known_tags,
'note': '' if (has_tag_split and has_classify_tag and has_known_tags) else 'Tag parsing infrastructure incomplete',
}
print(f" IMPL-PARSE-006: {'TAG PARSING IMPLEMENTED' if (has_tag_split and has_classify_tag) else 'MISSING TAG PARSING'}")
# IMPL-WRITE-003 deep: Writer always emits HH:MM:SS.mmm (hours never dropped)
has_hours_truthiness = bool(re.search(r'if hh:', writer_section))
has_always_hours = bool(re.search(r'hh:02.*mm:02.*ss:02', writer_section))
deep_results['IMPL-WRITE-003'] = {
'name': 'Writer timestamp format',
'detected': True,
'validated': has_always_hours and not has_hours_truthiness,
'note': '`if hh:` omits hours when 0. Produces MM:SS.mmm.' if has_hours_truthiness else '',
}
print(f" IMPL-WRITE-003: {'DROPS ZERO-HOURS' if has_hours_truthiness else 'KEEPS HOURS'}")
# IMPL-WRITE-002 deep: Entity encoding partially commented out
has_encode_commented = bool(re.search(r'#.*replace.*‎|#.*replace.*‏|#.*replace.* ', content))
deep_results['IMPL-WRITE-002'] = {
'name': 'Entity encoding partially commented out',
'detected': True,
'validated': not has_encode_commented,
'note': '‎, ‏, >, encoding explicitly commented out in _encode_illegal_characters.' if has_encode_commented else '',
}
print(f" IMPL-WRITE-002: {'PARTIAL — entities commented out' if has_encode_commented else 'FULL ENCODING'}")
# Center alignment logic bug: writer drops center but DEFAULT_ALIGN is "start"
has_default_start = bool(re.search(r'DEFAULT_ALIGN.*=.*"start"|DEFAULT_ALIGN.*=.*start', content))
if center_dropped and has_default_start:
deep_results['RULE-SET-005']['note'] = (
deep_results['RULE-SET-005'].get('note', '') +
' Logic bug: DEFAULT_ALIGN is "start" but center is dropped as if it were the default. '
'Explicit center alignment is valid and should be preserved.'
).strip()
# IMPL-PARSE-009: REGION block state-machine handling in _parse_line
has_region_block_state = bool(re.search(r'in_region_block', reader_content))
has_region_block_check = bool(re.search(r'state\.in_region_block', reader_content))
has_region_block_start = bool(re.search(r'line\.strip\(\)\s*==\s*"REGION"|==\s*"REGION"', reader_content))
deep_results['IMPL-PARSE-009'] = {
'name': 'REGION block state-machine skip in parse loop',
'detected': has_region_block_state,
'validated': has_region_block_state and has_region_block_check and has_region_block_start,
'note': '' if (has_region_block_state and has_region_block_check) else 'REGION blocks encountered during cue parsing may be misinterpreted as cue IDs',
}
print(f" IMPL-PARSE-009: {'PASS' if has_region_block_state and has_region_block_check else 'MISSING — REGION blocks not skipped in parse loop'}")
# IMPL-STYLE-001: _VISUAL_KEYS covers color/background-color/font properties for base wrapping
has_visual_keys = bool(re.search(r'_VISUAL_KEYS\s*=\s*\{', reader_content))
has_color_in_visual = bool(re.search(r'_VISUAL_KEYS\s*=\s*\{[^}]*"color"', reader_content, re.DOTALL))
has_bg_in_visual = bool(re.search(r'_VISUAL_KEYS\s*=\s*\{[^}]*"background-color"', reader_content, re.DOTALL))
has_font_in_visual = bool(re.search(r'_VISUAL_KEYS\s*=\s*\{[^}]*"font-family"', reader_content, re.DOTALL))
visual_keys_complete = has_visual_keys and has_color_in_visual and has_bg_in_visual and has_font_in_visual
deep_results['IMPL-STYLE-001'] = {
'name': '_VISUAL_KEYS covers all visual CSS properties for base wrapping',
'detected': has_visual_keys,
'validated': visual_keys_complete,
'note': '' if visual_keys_complete else 'Base-style wrapping only covers bold/italic/underline — color/background-color/font properties not wrapped',
}
print(f" IMPL-STYLE-001: {'COMPLETE (7 keys)' if visual_keys_complete else 'INCOMPLETE — missing visual properties'}")
# IMPL-STYLE-002: _covers_base uses key-presence (not value-equality) for style coverage
has_covers_base = bool(re.search(r'def _covers_base', reader_content))
uses_key_presence = bool(re.search(r'all\(k\s+in\s+content\s+for\s+k\s+in', reader_content))
uses_value_equality = bool(re.search(r'content\.get\(k\)\s*==\s*v|content\[k\]\s*==', reader_content))
deep_results['IMPL-STYLE-002'] = {
'name': '_covers_base uses key-presence semantics',
'detected': has_covers_base,
'validated': has_covers_base and uses_key_presence and not uses_value_equality,
'note': '' if (uses_key_presence and not uses_value_equality) else 'Value-equality check causes double-wrapping when class overrides base value (e.g. color:red vs base color:white)',
}
print(f" IMPL-STYLE-002: {'KEY-PRESENCE (correct)' if uses_key_presence and not uses_value_equality else 'VALUE-EQUALITY (double-wrap bug)'}")
validation_gaps = []
partial_validation = []
# Add the zero-value bug if detected
if zero_pos_bug or zero_line_bug or zero_size_bug:
validation_gaps.append(validation_gaps_extra)
for rid, info in deep_results.items():
_rule_level = all_rules.get(rid, {}).get('level', 'UNKNOWN')
if not info['detected']:
validation_gaps.append({
'rule_id': rid, 'name': info['name'],
'status': 'NOT_DETECTED', 'severity': _rule_level,
})
elif not info['validated']:
validation_gaps.append({
'rule_id': rid, 'name': info['name'],
'status': 'DETECTED_NOT_VALIDATED', 'severity': _rule_level,
'note': info.get('note', ''),
})
elif info.get('note'):
partial_validation.append({
'rule_id': rid, 'name': info['name'],
'status': 'IMPLEMENTED_WITH_CAVEATS', 'severity': 'SHOULD',
'note': info['note'],
})
# ===== PHASE 1.5: IMPLEMENTATION QUALITY GAPS =====
# These check for things the W3C reference parser handles but pycaption doesn't.
# They go beyond "does the function exist" to "does it actually cover all cases."
print("\n[1.5/5] Implementation Quality Gaps (vs W3C reference parser)")
quality_gaps = []
# Q1: Cue identifiers not preserved on Caption object or written on output
has_caption_id_field = bool(re.search(r'self\.id\s*=|self\.cue_id\s*=', support_content))
writes_cue_id = bool(re.search(r'cue_id|identifier|caption\.id', writer_content))
if not has_caption_id_field or not writes_cue_id:
quality_gaps.append({
'id': 'QUAL-001', 'name': 'Cue identifiers silently discarded',
'severity': 'SHOULD',
'note': 'Reader parses and validates cue IDs (seen_ids) but Caption has no id field. '
'IDs are lost on output. W3C reference parser preserves them on the cue object.',
})
print(f" QUAL-001: CUE IDs NOT PRESERVED (parsed then discarded)")
else:
print(f" QUAL-001: PASS — cue IDs preserved")
# Q2: Voice spans destructively flattened to text
voice_is_text_prefix = bool(re.search(r'VOICE_SPAN_PATTERN\.sub\(.*\\\\2.*:', reader_content))
voice_preserves_semantic = bool(re.search(r'"voice"|"speaker"', reader_content))
if voice_is_text_prefix and not voice_preserves_semantic:
quality_gaps.append({
'id': 'QUAL-002', 'name': 'Voice <v> spans destructively flattened to text',
'severity': 'SHOULD',
'note': '<v Speaker>text</v> becomes "Speaker: text" (baked into TEXT node). '
'Semantic voice annotation is lost. W3C parser preserves voice as tree node attribute.',
})
print(f" QUAL-002: VOICE FLATTENED to text prefix")
else:
print(f" QUAL-002: PASS — voice annotations preserved semantically")
# Q3: No tag nesting validation (spec requires proper nesting)
has_nesting_error = bool(re.search(r'nesting.*error|invalid.*nest|improper.*nest|overlap.*tag', reader_content, re.I))
if not has_nesting_error:
quality_gaps.append({
'id': 'QUAL-003', 'name': 'No tag nesting validation',
'severity': 'SHOULD',
'note': 'Malformed nesting like <b><i></b></i> passes silently. '
'W3C reference parser reports nesting violations with line/column info. '
'pycaption closes unclosed tags at cue end but never warns about bad nesting.',
})
print(f" QUAL-003: NO NESTING VALIDATION")
else:
print(f" QUAL-003: PASS — nesting errors detected")
# Q4: No duplicate cue settings detection/warning
has_dup_setting_warn = bool(re.search(r'duplicate.*setting|setting.*already|seen_settings', reader_content, re.I))
if not has_dup_setting_warn:
quality_gaps.append({
'id': 'QUAL-004', 'name': 'Duplicate cue settings not detected',
'severity': 'MAY',
'note': 'Spec says each setting should appear at most once (first-wins). '
'pycaption silently takes one value without warning. '
'W3C reference parser flags duplicates as errors.',
})
print(f" QUAL-004: DUPLICATE SETTINGS NOT WARNED")
else:
print(f" QUAL-004: PASS — duplicate settings detected")
# Q5: STYLE block — only ::cue and ::cue(.class) supported, not tag/combined selectors
has_tag_selector = bool(re.search(r'::cue\(b\)|::cue\(i\)|::cue\(u\)|::cue\(v\)|::cue\(ruby\)', reader_content))
has_combined_selector = bool(re.search(r'::cue\([^)]+\)\s*,\s*::cue', reader_content))
tag_selector_skipped = bool(re.search(r'tag.*selector.*skip|Skip tag|skip.*::cue\(\w+\)', reader_content, re.I))
if not has_tag_selector or tag_selector_skipped:
quality_gaps.append({
'id': 'QUAL-005', 'name': 'STYLE block: tag selectors and combined selectors unsupported',
'severity': 'MAY',
'note': '::cue(b), ::cue(i), ::cue(v) tag selectors are explicitly skipped. '
'Combined selectors like ::cue(.a), ::cue(.b) { } not parsed. '
'Only ::cue (global) and ::cue(.className) are handled.',
})
print(f" QUAL-005: TAG/COMBINED SELECTORS SKIPPED")
else:
print(f" QUAL-005: PASS — all selector types handled")
# Q6: Limited CSS property support (only 5 properties mapped)
css_props_mapped = 0
for prop in ['font-style', 'font-weight', 'text-decoration', 'color', 'background-color',
'font-family', 'font-size', 'text-shadow', 'opacity', 'white-space']:
if re.search(rf'"{prop}"', reader_content):
css_props_mapped += 1
if css_props_mapped < 7:
quality_gaps.append({
'id': 'QUAL-006', 'name': f'Limited CSS property support ({css_props_mapped}/10 properties)',
'severity': 'MAY',
'note': f'Only {css_props_mapped} CSS properties are mapped from STYLE blocks. '
'font-family, font-size, text-shadow, opacity, white-space are silently ignored. '
'This affects cross-format fidelity when styled VTT is converted.',
})
print(f" QUAL-006: LIMITED CSS ({css_props_mapped}/10 mapped)")
else:
print(f" QUAL-006: PASS — {css_props_mapped}/10 CSS properties mapped")
# Q7: Class annotations on style tags discarded (<i.highlight> loses class)
# _tag_content for 'i' returns {"italics": True} — class_suffix is only used for 'c' tag
tag_content_uses_class_for_ibu = bool(re.search(
r'tag_name\s*==\s*"[ibu]".*class_suffix|'
r'if tag_name in.*ibu.*class_suffix|'
r'"i".*class_suffix|"b".*class_suffix|"u".*class_suffix',
reader_content, re.DOTALL))
if not tag_content_uses_class_for_ibu:
quality_gaps.append({
'id': 'QUAL-007', 'name': 'Class annotations on <i>/<b>/<u> tags discarded',
'severity': 'MAY',
'note': '<i.highlight>text</i> is parsed but class_suffix is discarded — '
'only {"italics": True} stored. The class is only preserved for <c> tags. '
'Spec allows classes on all tags.',
})
print(f" QUAL-007: CLASS ON <i>/<b>/<u> DISCARDED")
else:
print(f" QUAL-007: PASS — classes preserved on all tags")
# Q8: No error diagnostics with line/column info (only exceptions or silence)
has_line_col_errors = bool(re.search(r'line.*col.*error|column.*\d+|diagnostic.*line', reader_content, re.I))
has_structured_warnings = bool(re.search(r'warnings\.warn.*line|CaptionReadWarning.*line', reader_content))
if not has_line_col_errors:
quality_gaps.append({
'id': 'QUAL-008', 'name': 'No structured error diagnostics (line/column)',
'severity': 'SHOULD',
'note': 'pycaption either raises an exception (halting parse) or silently accepts malformed input. '
'No middle ground for "this is malformed but I parsed what I could, here are the issues." '
'W3C reference parser collects all errors with line/col info in an array.',
})
print(f" QUAL-008: NO LINE/COL DIAGNOSTICS")
else:
print(f" QUAL-008: PASS — structured error diagnostics present")
# Q9: Invalid entity references not validated on read
has_entity_validation = bool(re.search(r'invalid.*entity|unknown.*entity|unrecognized.*&', reader_content, re.I))
if not has_entity_validation:
quality_gaps.append({
'id': 'QUAL-009', 'name': 'Invalid/unknown entity references not flagged',
'severity': 'MAY',
'note': 'WebVTT only allows 6 named entities (& < > ‎ ‏ ) plus numeric refs. '
'html.unescape() decodes ALL HTML entities silently (e.g. © —). '
'W3C parser flags unrecognized entities as errors.',
})
print(f" QUAL-009: INVALID ENTITIES NOT FLAGGED")
else:
print(f" QUAL-009: PASS — invalid entities flagged")
# Q10: position-only without line creates no origin
position_requires_line = bool(re.search(r'origin_y.*is.*None|if.*origin_y.*None.*origin_x', reader_content))
pos_without_line = not bool(re.search(r'Point\(.*origin_x.*Size\(0', reader_content))
if position_requires_line or pos_without_line:
# Check if position:50% without line: creates an origin
has_pos_only_handling = bool(re.search(r'if\s+origin_x\s+is\s+not\s+None\s+and\s+origin_y\s+is\s+None', reader_content))
if not has_pos_only_handling:
quality_gaps.append({
'id': 'QUAL-010', 'name': 'position-only cue setting produces no Layout origin',
'severity': 'SHOULD',
'note': 'If only position:50% is set without line:, origin remains None because '
'reader requires origin_y (line value) to create a Point. '
'Position information is silently lost.',
})
print(f" QUAL-010: POSITION-ONLY LOST (needs line: too)")
else:
print(f" QUAL-010: PASS — position-only handled")
else:
print(f" QUAL-010: PASS")
# Q11: line alignment qualifier discarded
line_align_used = bool(re.search(r'line_align|lineAlign', reader_content))
if not line_align_used:
quality_gaps.append({
'id': 'QUAL-011', 'name': 'Line alignment qualifier discarded',
'severity': 'MAY',
'note': 'line:80%,center is parsed but the alignment qualifier (center/start/end) '
'is extracted then assigned to _ (unused). Only the numeric part is stored.',
})
print(f" QUAL-011: LINE ALIGN QUALIFIER DISCARDED")
else:
print(f" QUAL-011: PASS — line alignment preserved")
print(f" Quality gaps found: {len(quality_gaps)}")
# Add quality gaps to partial_validation (they're real limitations, not missing features)
for qg in quality_gaps:
partial_validation.append({
'rule_id': qg['id'], 'name': qg['name'],
'status': 'QUALITY_GAP', 'severity': qg['severity'],
'note': qg['note'],
})
# Accepted (Won't Fix) — intentional design decisions, not real gaps
ACCEPTED_WONT_FIX = {
'RULE-TIME-006': 'Intentional: timing validation disabled by default because real-world VTT files have overlapping captions. Opt-in via ignore_timing_errors=False.',
'RULE-VAL-007': 'Intentional: same as RULE-TIME-006 — disabled-by-default is the correct design for production tolerance.',
'RULE-BLK-004': 'MAY-level. Spec says parser "may" reject --> in STYLE blocks. We silently accept — harmless since style parsing stops at blank line.',
'RULE-CUE-006': 'MUST NOT in file, but writer already encodes --> as -->. On read, rejecting payloads with stray --> would break real-world files.',
'RULE-REG-008': 'MAY-level. Last-wins is implicit behavior for duplicate region settings. Explicit dedup tracking adds no value.',
'RULE-SET-007': 'MAY-level. Last-wins is implicit behavior for duplicate cue settings. Explicit dedup tracking adds no value.',
'RULE-SET-008': 'MAY-level. Region/vertical/line/size mutual exclusion — not enforced, no production impact.',
}
# Remove accepted items from validation_gaps so they don't inflate issue count
validation_gaps = [g for g in validation_gaps if g['rule_id'] not in ACCEPTED_WONT_FIX]
partial_validation = [p for p in partial_validation if p['rule_id'] not in ACCEPTED_WONT_FIX]
print(f" Gaps: {len(validation_gaps)}, Caveats: {len(partial_validation)}, Accepted: {len(ACCEPTED_WONT_FIX)}")
# ===== PHASE 2: SYSTEMATIC RULE CHECK =====
print("\n[2/5] Systematic Rule Check ({} rules)".format(len(all_rules)))
# Per-rule patterns: match actual function names, variable names, and logic
# NOT broad keywords that could match comments
specific_patterns = {
# File Format
'RULE-FMT-001': [r'"WEBVTT"', r'def detect', r'def _validate_header'],
'RULE-FMT-002': [r'isinstance.*str|InvalidInputError'],
'RULE-FMT-003': [r'BOM|\\ufeff|\xef\xbb\xbf|startswith.*"\xef\xbb\xbf"'],
'RULE-FMT-004': [r'_validate_header.*blank|lines\[1\]\s*!=\s*""|Missing blank line after.*header'],
'RULE-FMT-005': [r'splitlines|\\r\\n|\\r|\\n'],
# Timestamps
'RULE-TIME-001': [r'TIMESTAMP_PATTERN', r'def _parse_timestamp'],
'RULE-TIME-002': [r'hours.*optional|m\[2\].*m\[0\].*m\[1\]|if m\[2\]'],
'RULE-TIME-003': [r'\\d\{3\}'],
'RULE-TIME-004': [r'\\d\{2\}'],
'RULE-TIME-005': [r'start\s*>\s*end'],
'RULE-TIME-006': [r'start\s*<\s*last_start_time'],
'RULE-TIME-007': [r'timestamp.*tag|internal.*timestamp|\d+:\d+.*\.\d+.*>'],
# Cue Structure
'RULE-CUE-001': [r'TIMING_LINE_PATTERN\s*=\s*re\.compile'],
'RULE-CUE-002': [r'identifier.*-->|"-->" in line|pending_id'],
'RULE-CUE-003': [r'identifier.*line.*terminator|splitlines|pending_id'],
'RULE-CUE-004': [r'cue.*id.*unique|identifier.*unique|seen_ids|Duplicate cue identifier'],
'RULE-CUE-005': [r'"".*==.*line|blank.*line.*terminat|line\s*==\s*""'],
'RULE-CUE-006': [r'payload.*-->'],
# Cue Settings - check for ACTUAL parsing via _parse_cue_settings
'RULE-SET-001': [r'vertical.*rl|vertical.*lr|WritingDirectionEnum|"vertical".*CUE_SETTING_PATTERN'],
'RULE-SET-002': [r'name\s*==\s*"line"|_line_number_to_percent|_parse_percent_value.*line'],
'RULE-SET-003': [r'name\s*==\s*"position"|_parse_percent_value.*position'],
'RULE-SET-004': [r'name\s*==\s*"size"|extent_horizontal'],
'RULE-SET-005': [r'ALIGN_SETTING_MAP|name\s*==\s*"align"'],
'RULE-SET-006': [r'_extract_region_id|region.*=.*settings'],
'RULE-SET-007': [r'setting.*once|duplicate.*setting'],
'RULE-SET-008': [r'region.*exclud|region.*vertical|region.*line|region.*size'],
# Tags - check via TAG_SPLIT_PATTERN + _classify_tag + _tag_content
'RULE-TAG-001': [r'<c[\\.> ]|"c".*KNOWN_TAGS|_tag_content.*class|_convert_structural_tag'],
'RULE-TAG-002': [r'"<i>"|"i".*KNOWN_TAGS|italics|_classify_tag'],
'RULE-TAG-003': [r'"<b>"|"b".*KNOWN_TAGS|bold|_classify_tag'],
'RULE-TAG-004': [r'"<u>"|"u".*KNOWN_TAGS|underline|_classify_tag'],
'RULE-TAG-005': [r'VOICE_SPAN_PATTERN|<v[\\.> ]|"v".*KNOWN_TAGS'],
'RULE-TAG-006': [r'"lang".*KNOWN_TAGS|_classify_tag.*lang|_tag_content'],
'RULE-TAG-007': [r'"ruby".*KNOWN_TAGS|"rt".*KNOWN_TAGS|_classify_tag.*ruby|_SIMPLE_STRUCTURAL_TAGS.*ruby|<ruby'],
'RULE-TAG-008': [r'timestamp.*tag|_classify_tag.*\d+:\d+'],
'RULE-TAG-009': [r'VOICE_SPAN_PATTERN.*\\\\\\.\\\\w|class.*annot.*pars|class_suffix'],
'RULE-TAG-010': [r'html\.unescape|&|<|>|_decode_entities'],
'RULE-TAG-011': [r'_close_unclosed_tags|open_tags|tag.*clos|</\w+>'],
# Entities
'RULE-ENT-001': [r'html\.unescape|&'],
'RULE-ENT-002': [r'html\.unescape|<'],
'RULE-ENT-003': [r'html\.unescape|>'],
'RULE-ENT-004': [r'html\.unescape| |\\u00a0'],
'RULE-ENT-005': [r'html\.unescape|‎|\\u200e'],
'RULE-ENT-006': [r'html\.unescape|‏|\\u200f'],
'RULE-ENT-007': [r'html\.unescape|&#\d+;|&#x[0-9a-fA-F]+;|numeric.*ref'],
# Regions
'RULE-REG-001': [r'def _parse_regions|REGION.*block|region.*block.*pars'],
'RULE-REG-002': [r'region.*id.*=|"id".*settings|region.*identifier'],
'RULE-REG-003': [r'region.*width|"width".*REGION_SETTING'],
'RULE-REG-004': [r'region.*lines?\b|"lines".*REGION_SETTING'],
'RULE-REG-005': [r'regionanchor'],
'RULE-REG-006': [r'viewportanchor'],
'RULE-REG-007': [r'scroll.*up|scroll.*=|"scroll"'],
'RULE-REG-008': [r'region.*setting.*once|seen_keys'],
'RULE-REG-009': [r'region_id\s+not\s+in\s+regions|region.*unique'],
# Special Blocks
'RULE-BLK-001': [r'_is_note_start|in_note_block|NOTE.*block'],
'RULE-BLK-002': [r'def _parse_style_blocks|STYLE_SELECTOR_PATTERN'],
'RULE-BLK-003': [r'"-->"\s*in\s*line.*break|STYLE.*before.*cue|valid before cues|Stops at the first timing'],
'RULE-BLK-004': [r'in_style_block.*-->|STYLE.*-->'],
# Validation
'RULE-VAL-001': [r'case.*sensitiv|==\s*"WEBVTT"|==\s*"STYLE"|==\s*"REGION"|startswith\("WEBVTT'],
'RULE-VAL-002': [r'seen_ids|Duplicate cue identifier|duplicate.*id'],
'RULE-VAL-003': [r'region_id\s+not\s+in\s+regions|region.*id.*unique'],
'RULE-VAL-004': [r'timestamp.*order|monotonic|start.*<.*last'],
'RULE-VAL-005': [r'unicode.*normali|html\.unescape|splitlines'],
'RULE-VAL-006': [r'class WebVTTWriter|def write.*caption_set'],
'RULE-VAL-007': [r'ignore_timing_errors'],
# Implementation
'IMPL-PARSE-001': [r'isinstance.*str|utf.?8|decode'],
'IMPL-PARSE-002': [r'def detect|"WEBVTT"|_validate_header'],
'IMPL-PARSE-003': [r'def _parse_timestamp'],
'IMPL-PARSE-004': [r'def _validate_timings'],
'IMPL-PARSE-005': [r'_parse_cue_settings|CUE_SETTING_PATTERN|Layout\('],
'IMPL-PARSE-006': [r'TAG_SPLIT_PATTERN|_classify_tag|KNOWN_TAGS'],
'IMPL-PARSE-007': [r'html\.unescape|_decode_entities'],
'IMPL-PARSE-008': [r'def _parse_regions|REGION_SETTING_PATTERN'],
'IMPL-WRITE-001': [r'class WebVTTWriter|def write'],
'IMPL-WRITE-002': [r'def _encode_illegal_characters|replace.*&'],
'IMPL-WRITE-003': [r'def _timestamp'],
'IMPL-WRITE-004': [r'-->\s|f".*-->.*"'],
}
missing_rules = []
found_rules = []
for rule_id, meta in sorted(all_rules.items()):
# Skip rules covered in Phase 1
if rule_id in deep_results:
if deep_results[rule_id]['detected']:
found_rules.append(rule_id)
else:
missing_rules.append({
'rule_id': rule_id, 'name': meta['name'],
'level': meta['level'], 'status': 'MISSING',
})
continue
patterns = specific_patterns.get(rule_id, [])
if not patterns:
# No specific pattern defined — mark as unchecked
missing_rules.append({
'rule_id': rule_id, 'name': meta['name'],
'level': meta['level'], 'status': 'NO_PATTERN',
})
continue
# Search in main file + support files
all_content = content + "\n" + support_content
found = any(re.search(p, all_content, re.I) for p in patterns)
if found:
found_rules.append(rule_id)
else:
missing_rules.append({
'rule_id': rule_id, 'name': meta['name'],
'level': meta['level'], 'status': 'MISSING',
})
# Remove accepted rules from missing list — they're intentional
missing_rules = [r for r in missing_rules if r['rule_id'] not in ACCEPTED_WONT_FIX]
# Also count accepted rules that were found as still "found"
for rid in ACCEPTED_WONT_FIX:
if rid not in found_rules:
found_rules.append(rid)
must_missing = [r for r in missing_rules if r['level'] == 'MUST']
print(f" Found: {len(found_rules)}/{len(all_rules)}, Missing: {len(missing_rules)} (MUST: {len(must_missing)})")
# ===== PHASE 3: TAG/SETTING/ENTITY COVERAGE =====
print("\n[3/5] Tag/Setting/Entity Coverage")
# Tags: check if the code can READ or WRITE each tag
# Reader now parses tags via TAG_SPLIT_PATTERN + _classify_tag into CaptionNode.STYLE pairs
# Writer generates tags from style nodes via _convert_structural_tag
tag_coverage = {
'<c>': {'read': bool(re.search(r'_classify_tag|_tag_content', content)),
'write': bool(re.search(r'_convert_structural_tag', content)),
'note': 'Reader parses via _classify_tag + _tag_content, writer via _convert_structural_tag'},
'<i>': {'read': bool(re.search(r'TAG_SPLIT_PATTERN|_classify_tag', content)),
'write': bool(re.search(r'"<i>"', content)),
'note': 'Reader preserves as STYLE node (italics), writer generates from style nodes'},
'<b>': {'read': bool(re.search(r'TAG_SPLIT_PATTERN|_classify_tag', content)),
'write': bool(re.search(r'"<b>"', content)),
'note': 'Reader preserves as STYLE node (bold), writer generates from style nodes'},
'<u>': {'read': bool(re.search(r'TAG_SPLIT_PATTERN|_classify_tag', content)),
'write': bool(re.search(r'"<u>"', content)),
'note': 'Reader preserves as STYLE node (underline), writer generates from style nodes'},
'<v>': {'read': bool(re.search(r'VOICE_SPAN_PATTERN|"v"', content)),
'write': False,
'note': 'Accepted: extracts speaker as text (correct for HLS). Not round-trippable by design.'},
'<lang>': {'read': bool(re.search(r'"lang".*KNOWN_TAGS|_tag_content', content)),
'write': bool(re.search(r'_convert_structural_tag.*lang|"<lang', content)),
'note': 'Reader parses via _classify_tag into STYLE node with lang key'},
'<ruby>/<rt>': {'read': bool(re.search(r'"ruby".*KNOWN_TAGS|"rt".*KNOWN_TAGS|_tag_content', content)),
'write': bool(re.search(r'_convert_structural_tag.*ruby|"<ruby', content)),
'note': 'Reader parses via _classify_tag into STYLE node'},
'<timestamp>': {'read': bool(re.search(r'_classify_tag.*timestamp|timestamp.*microseconds', content, re.DOTALL)),
'write': bool(re.search(r'"timestamp" in content', writer_content)),
'note': 'Reader parses timestamp tags, writer re-emits via _convert_structural_tag'},
}
tags_with_read = sum(1 for t in tag_coverage.values() if t['read'])
tags_with_write = sum(1 for t in tag_coverage.values() if t['write'])
tags_roundtrip = sum(1 for t in tag_coverage.values() if t['read'] and t['write'])
print(f" Tags: {tags_with_read}/8 read, {tags_with_write}/8 write, {tags_roundtrip}/8 round-trip")
# Settings: check if the code PARSES individual settings
# Reader now parses all settings via _parse_cue_settings + CUE_SETTING_PATTERN
setting_coverage = {
'vertical': {'parsed': bool(re.search(r'WritingDirectionEnum|"vertical".*CUE_SETTING_PATTERN|name\s*==\s*"vertical"', content)),
'written': bool(re.search(r'f" vertical:|f"vertical:|writing_direction\.value', writer_section)),
'note': 'Parsed into Layout.writing_direction via WritingDirectionEnum'},
'line': {'parsed': bool(re.search(r'_line_number_to_percent|name\s*==\s*"line"', content)),
'written': bool(re.search(r'f" line:|f"line:', writer_section)),
'note': 'Parsed into origin.y, writer generates from layout'},
'position': {'parsed': bool(re.search(r'_parse_percent_value|name\s*==\s*"position"', content)),
'written': bool(re.search(r'f" position:|f"position:', writer_section)),
'note': 'Parsed into origin.x, writer generates from layout'},
'size': {'parsed': bool(re.search(r'extent_horizontal|name\s*==\s*"size"', content)),
'written': bool(re.search(r'f" size:|f"size:', writer_section)),
'note': 'Parsed into extent.horizontal, writer generates from layout'},
'align': {'parsed': bool(re.search(r'ALIGN_SETTING_MAP|name\s*==\s*"align"', content)),
'written': bool(re.search(r'f" align:|f"align:', writer_section)),
'note': 'Parsed into Layout.alignment via ALIGN_SETTING_MAP'},
'region': {'parsed': bool(re.search(r'_extract_region_id|region.*inherit_from', content)),
'written': bool(re.search(r'f" region:|f"region:|webvtt_positioning', writer_section)),
'note': 'Parsed via _extract_region_id, region layout inherited via inherit_from'},
}
settings_parsed = sum(1 for s in setting_coverage.values() if s['parsed'])
settings_written = sum(1 for s in setting_coverage.values() if s['written'])
print(f" Settings: {settings_parsed}/6 parsed, {settings_written}/6 written")
# Entities: check read (decode) and write (encode) separately
# Reader now uses html.unescape() which handles all named + numeric entities
has_html_unescape = bool(re.search(r'html\.unescape', content))
entity_coverage = {
'&': {'read': has_html_unescape,
'write': bool(re.search(r'replace.*"&".*"&"', content))},
'<': {'read': has_html_unescape,
'write': bool(re.search(r'replace.*"<".*"<"', content))},
'>': {'read': has_html_unescape,
'write': bool(re.search(r'replace.*">".*">"|-->', content))},
' ': {'read': has_html_unescape,
'write': bool(re.search(r'" "', content))},
'‎': {'read': has_html_unescape,
'write': bool(re.search(r'^\s*[^#\s].*replace.*"‎"', content, re.MULTILINE))},
'‏': {'read': has_html_unescape,
'write': bool(re.search(r'^\s*[^#\s].*replace.*"‏"', content, re.MULTILINE))},
'&#ref': {'read': has_html_unescape, 'write': 'accepted',
'note': 'Accepted: decoded to Unicode on read; raw Unicode is valid VTT, no need to re-encode as &#ref.'},
}
entities_read = sum(1 for e in entity_coverage.values() if e['read'])
entities_write = sum(1 for e in entity_coverage.values() if e['write'] and e['write'] != 'accepted')
print(f" Entities: {entities_read}/7 read, {entities_write}/7 write")
# ===== PHASE 4: TEST COVERAGE =====
print("\n[4/5] Test Coverage")
test_files = glob.glob('tests/**/test*webvtt*.py', recursive=True) + glob.glob('tests/**/test*vtt*.py', recursive=True)
tests = "\n".join(_read(f) for f in test_files if os.path.exists(f))
print(f" Test files: {len(test_files)} ({len(tests)} chars)")
test_checks = {
'RULE-FMT-001': [r'def test.*header|def test.*detect|def test.*webvtt'],
'RULE-TIME-001': [r'def test.*timestamp|def test.*time.*pars'],
'RULE-TIME-005': [r'def test.*start.*end|def test.*timing.*error|def test.*invalid.*time'],
'RULE-TIME-006': [r'def test.*monotonic|def test.*order|def test.*previous'],
'RULE-CUE-001': [r'def test.*arrow|def test.*-->|def test.*timing.*line'],
'IMPL-WRITE-002': [r'def test.*encod|def test.*escap|def test.*illegal'],
'IMPL-WRITE-003': [r'def test.*timestamp.*format|def test.*write.*time|def test.*timestamp.*hours'],
}
test_gaps = []
for rid, patterns in test_checks.items():
if not any(re.search(p, tests, re.I) for p in patterns):
name = all_rules.get(rid, {}).get('name', rid)
test_gaps.append({'rule_id': rid, 'name': name})
print(f" Test gaps: {len(test_gaps)}/{len(test_checks)}")
# ===== PHASE 5: GENERATE REPORT =====
print("\n[5/5] Generating Report")
os.makedirs("ai_artifacts/compliance_checks/vtt", exist_ok=True)
date = datetime.now().strftime("%Y-%m-%d")
path = f"ai_artifacts/compliance_checks/vtt/compliance_report_{date}.md"
# Totals — accepted (won't fix) items excluded from issue count
tags_missing = 8 - tags_roundtrip
settings_missing = 6 - settings_parsed
entities_missing = 7 - entities_read
total = (len(validation_gaps) + len(partial_validation) + len(missing_rules) +
tags_missing + settings_missing + entities_missing + len(test_gaps))
must_count = (len([g for g in validation_gaps if g.get('severity') == 'MUST']) +
len([p for p in partial_validation if p.get('severity') == 'MUST']) +
len(must_missing))
sanity_section = ""
if stale_warnings:
sanity_section = "\n**STALE PATTERN WARNING**: The following expected code landmarks were not found. Some findings below may report features as 'missing' when they have actually been renamed or moved:\n"
for w in stale_warnings:
sanity_section += f"- {w}\n"
sanity_section += "\n"
report = f"""# WebVTT EXHAUSTIVE Compliance Report
**Generated**: {date}
**Spec**: {spec_file} ({len(all_rules)} rules)
**Implementation**: {webvtt_file}
**Analysis**: Deep Validation + Systematic Rules + Coverage + Tests
{sanity_section}
---
## Executive Summary
**Rules checked**: {len(all_rules)}/{len(all_rules)} (100%)
**Total issues**: {total}
**MUST violations**: {must_count}
| Category | Count |
|----------|-------|
| Validation gaps | {len(validation_gaps)} |
| Implementation quality gaps | {len(quality_gaps)} |
| Implementation caveats | {len(partial_validation) - len(quality_gaps)} |
| Missing rules | {len(missing_rules)} (MUST: {len(must_missing)}) |
| Tag round-trip gaps | {tags_missing}/8 |
| Setting parse gaps | {settings_missing}/6 |
| Entity gaps | {entities_missing}/7 |
| Test gaps | {len(test_gaps)} |
| Accepted (won't fix) | {len(ACCEPTED_WONT_FIX)} |
---
## 1. Validation Gaps ({len(validation_gaps)})
"""
for g in validation_gaps:
report += f"### {g['rule_id']}: {g['name']}\n"
report += f"- **Status**: {g['status']}\n"
report += f"- **Severity**: {g.get('severity', 'UNKNOWN')}\n"
if g.get('note'):
report += f"- **Note**: {g['note']}\n"
report += "\n"
report += f"""---
## 2. Implementation Quality Gaps ({len(quality_gaps)})
Features that exist but are incomplete compared to the W3C reference parser (webvtt.js).
These represent real data loss or missing validation that affects interoperability.
"""
for qg in quality_gaps:
report += f"### {qg['id']}: {qg['name']}\n"
report += f"- **Severity**: {qg['severity']}\n"
report += f"- **Note**: {qg['note']}\n\n"
# Filter out quality gaps from partial_validation for section 3
non_quality_caveats = [p for p in partial_validation if not p['rule_id'].startswith('QUAL-')]
report += f"""---
## 3. Implementation Caveats ({len(non_quality_caveats)})
Rules implemented but with significant limitations.
"""
for p in non_quality_caveats:
report += f"### {p['rule_id']}: {p['name']}\n"
report += f"- **Status**: {p['status']}\n"
report += f"- **Note**: {p['note']}\n\n"
report += f"""---
## 4. Missing Rules ({len(missing_rules)})
### MUST Rules ({len(must_missing)})
"""
for r in must_missing:
report += f"- **{r['rule_id']}**: {r['name']} ({r['status']})\n"
should_missing = [r for r in missing_rules if r['level'] == 'SHOULD']
may_missing = [r for r in missing_rules if r['level'] in ('MAY', 'MUST NOT')]
report += f"\n### SHOULD Rules ({len(should_missing)})\n\n"
for r in should_missing:
report += f"- **{r['rule_id']}**: {r['name']} ({r['status']})\n"
report += f"\n### MAY/MUST NOT Rules ({len(may_missing)})\n\n"
for r in may_missing:
report += f"- **{r['rule_id']}**: {r['name']} ({r['status']})\n"
report += f"""
---
## 5. Accepted — Won't Fix ({len(ACCEPTED_WONT_FIX)})
Intentional design decisions — not counted as issues.
"""
for rid, reason in sorted(ACCEPTED_WONT_FIX.items()):
rule_name = all_rules.get(rid, {}).get('name', rid)
rule_level = all_rules.get(rid, {}).get('level', 'UNKNOWN')
report += f"- **{rid}** ({rule_level}): {rule_name} — {reason}\n"
report += f"""
---
## 6. Coverage Analysis
### Tags ({tags_roundtrip}/8 round-trip)
| Tag | Read | Write | Round-trip | Note |
|-----|------|-------|------------|------|
"""
for tag, info in tag_coverage.items():
r = "Yes" if info['read'] else "No"
w = "Yes" if info['write'] else "No"
rt = "Yes" if info['read'] and info['write'] else "No"
report += f"| `{tag}` | {r} | {w} | {rt} | {info['note']} |\n"
report += f"""
### Cue Settings ({settings_parsed}/6 parsed, {settings_written}/6 written)
| Setting | Parsed | Written | Note |
|---------|--------|---------|------|
"""
for setting, info in setting_coverage.items():
p = "Yes" if info['parsed'] else "No"
w = "Yes" if info['written'] else "No"
report += f"| `{setting}` | {p} | {w} | {info['note']} |\n"
report += f"""
### Entities ({entities_read}/7 read, {entities_write}/7 write)
| Entity | Read (decode) | Write (encode) | Note |
|--------|---------------|----------------|------|
"""
for entity, info in entity_coverage.items():
r = "Yes" if info['read'] else "No"
w = "Accepted" if info['write'] == 'accepted' else ("Yes" if info['write'] else "No")
note = info.get('note', '')
note_col = f" {note}" if note else ""
report += f"| `{entity}` | {r} | {w} |{note_col}\n"
report += f"""
---
## 7. Test Gaps ({len(test_gaps)})
"""
for t in test_gaps:
report += f"- **{t['rule_id']}**: {t['name']}\n"
report += """
---
## 8. Key Findings
"""
# Generate findings dynamically from detection results
findings = []
# Check tag state
if tags_with_read >= 7:
findings.append(f"1. **Reader preserves inline tags**: Tags `<i>`, `<b>`, `<u>`, `<c>`, `<lang>`, `<ruby>`, `<rt>`, and timestamp are parsed by `TAG_SPLIT_PATTERN.split()` + `_classify_tag()` into CaptionNode.STYLE open/close pairs. Voice `<v>` annotation extracted into text.")
else:
findings.append(f"1. **Tag parsing incomplete**: Only {tags_with_read}/8 tags parsed by reader.")
if tags_with_write >= 4:
findings.append(f"2. **Writer generates tags from style nodes**: `<i>`, `<b>`, `<u>` from text styles; `<c>`, `<lang>`, `<ruby>` via `_convert_structural_tag()`. VTT-to-VTT round-trip preserves formatting.")
else:
findings.append(f"2. **Writer tag output limited**: Only {tags_with_write}/8 tags written.")
if settings_parsed >= 5:
findings.append(f"3. **All cue settings individually parsed**: `_parse_cue_settings()` uses `CUE_SETTING_PATTERN` to parse position, line, size, align, vertical. Region handled via `_extract_region_id()` + `inherit_from`.")
else:
findings.append(f"3. **Cue settings partially parsed**: Only {settings_parsed}/6 settings individually parsed.")
findings.append(f"4. **STYLE blocks fully implemented**: `_parse_style_blocks()` extracts `::cue` CSS rules via `STYLE_SELECTOR_PATTERN`. `_resolve_cue_styles()` merges resolved properties into nodes. Class-based styling (`::cue(.class)`) supported.")
findings.append(f"5. **Timing validation exists but is DISABLED by default** (`ignore_timing_errors=True`). Start<=end and monotonic checks are opt-in.")
if has_html_unescape and not has_encode_commented:
findings.append(f"6. **Full entity support**: Decode via `html.unescape()` (all named + numeric references). Encode covers &, <, -->, , ‎, ‏. Only &#ref numeric encoding not round-tripped.")
elif has_html_unescape:
findings.append(f"6. **Entity decode uses `html.unescape()`**: Handles all named entities + numeric character references. **Entity encode is partial** — some entities commented out in `_encode_illegal_characters`.")
else:
findings.append(f"6. **Entity decode limited**: Manual replacement for spec entities only.")
if bool(re.search(r'def _parse_regions', content)):
findings.append(f"7. **REGION blocks fully implemented**: `_parse_regions()` parses id, width, lines, regionanchor, viewportanchor, scroll settings. First-definition-wins for duplicate IDs. Region layout inherited by cues via `inherit_from`.")
else:
findings.append(f"7. **REGION blocks not implemented**.")
if has_header_validate and has_detect_first_line:
findings.append(f"8. **Header detection is strict**: `detect()` checks first line only (not substring). `_validate_header()` enforces WEBVTT on first line + blank line separator. BOM stripped before parsing.")
else:
findings.append(f"8. **Header detection needs review**.")
for f in findings:
report += f + "\n"
report += f"""
---
**Generated**: {datetime.now().strftime('%Y-%m-%d %H:%M')}
**Rules**: {len(all_rules)} | **Found**: {len(found_rules)} | **Missing**: {len(missing_rules)}
**Tags**: {tags_roundtrip}/8 round-trip | **Settings**: {settings_parsed}/6 parsed | **Entities**: {entities_read}/7 read, {entities_write}/7 write
"""
with open(path, 'w') as _f: _f.write(report)
print(f"\n Report: {path}")
print(f" Total issues: {total} ({must_count} MUST)")
Execute the above Python script directly.
Key improvements over previous version
- No category key bug -- per-rule patterns instead of category-based lookup
- Function-level detection -- matches
def _parse_timestamp, def _parse_cue_settings, _classify_tag, not keywords
- Read vs Write distinction -- tags, settings, entities tracked separately for read/write/round-trip
- Disabled-by-default detection -- timing validation flagged as caveat when
ignore_timing_errors=True
- Parsed settings detection -- detects
_parse_cue_settings, CUE_SETTING_PATTERN, individual setting names
- Tag preservation detection -- detects
TAG_SPLIT_PATTERN, _classify_tag, KNOWN_TAGS, _tag_content
- STYLE block detection -- detects
_parse_style_blocks, STYLE_SELECTOR_PATTERN, _extract_cue_styles
- Dynamic key findings -- generated from detection results, not hardcoded stale text
- Tighter RULE-CUE-001 -- matches
TIMING_LINE_PATTERN definition, not general --> string checks
- Expanded landmarks -- includes
_parse_cue_settings, _parse_style_blocks, CUE_SETTING_PATTERN, STYLE_SELECTOR_PATTERN, TAG_SPLIT_PATTERN, _classify_tag, WritingDirectionEnum
Success Criteria
- All 76 spec rules individually checked with per-rule patterns
- Deep validation for critical rules at function level
- Tags tracked as read/write/round-trip (not just keyword match)
- Settings tracked as parsed vs raw-string
- Entities tracked as read (decode) vs write (encode)
- Disabled-by-default validations flagged
- Dynamic key findings generated from detection results