# FV-CMS-VENDORED v1.1.1 — DO NOT EDIT IN PLACE. Source: github.com/avidtech6/freshvibe-cms. To update, run: fvcms-update
#!/usr/bin/env python3
"""
freshvibe-cms drift detector
Runs the 8 protection tests + the shadow freeze check.
Per plan a452 Phase 7.

Usage:
  python3 scripts/drift-detector.py --check-shadow     # just the shadow
  python3 scripts/drift-detector.py --check-atlas      # just the atlas
  python3 scripts/drift-detector.py --check-dna        # just the dna
  python3 scripts/drift-detector.py --check-pact       # just the pact
  python3 scripts/drift-detector.py --check-codex      # just the codex
  python3 scripts/drift-detector.py --check-overlays   # just the overlays
  python3 scripts/drift-detector.py --check-recipes     # just the recipes
  python3 scripts/drift-detector.py --check-vendored   # just the vendored
  python3 scripts/drift-detector.py --full             # all checks
"""
import sys, os, json, argparse
from pathlib import Path

ROOT = Path(__file__).parent.parent  # freshvibe-cms/

class Result:
    def __init__(self, name, status, evidence=""):
        self.name = name
        self.status = status  # 'PASS', 'FAIL', 'SKIP'
        self.evidence = evidence

def check_shadow():
    """Check 1: shadow frozen and EXTRACTION-MANIFEST.md present"""
    shadow_dir = ROOT / 'app-pact' / 'shadow' / 'v0.8.0-pre-fwv-v8-alignment'
    manifest = ROOT / 'app-pact' / 'shadow' / 'EXTRACTION-MANIFEST.md'
    if not shadow_dir.exists():
        return Result('shadow-exists', 'FAIL', f'shadow dir not found: {shadow_dir}')
    if not manifest.exists():
        return Result('manifest-exists', 'FAIL', f'manifest not found: {manifest}')
    file_count = sum(1 for _ in shadow_dir.rglob('*') if _.is_file())
    if file_count < 500:
        return Result('shadow-file-count', 'FAIL', f'only {file_count} files (expected >= 500)')
    return Result('shadow-frozen', 'PASS', f'{file_count} files in shadow, manifest present')

def check_atlas():
    """Check 2: atlas has surfaces + behaviours + modules with reasonable counts"""
    atlas_path = ROOT / 'app-trace-atlas' / 'atlas.json'
    if not atlas_path.exists():
        return Result('atlas-exists', 'FAIL', f'atlas.json not found')
    try:
        d = json.loads(atlas_path.read_text())
    except json.JSONDecodeError as e:
        return Result('atlas-valid-json', 'FAIL', f'JSON parse error: {e}')
    surfaces = len(d.get('surfaces', {}))
    behaviours = len(d.get('behaviours', {}))
    modules = len(d.get('modules', {}))
    if surfaces < 10 or behaviours < 10 or modules < 10:
        return Result('atlas-completeness', 'FAIL', f'surfaces={surfaces}, behaviours={behaviours}, modules={modules}')
    return Result('atlas-completeness', 'PASS', f'{surfaces} surfaces, {behaviours} behaviours, {modules} modules')

def check_dna():
    """Check 3: DNA has correct schema, version, tier"""
    dna_path = ROOT / 'app-pact' / 'dna' / 'app.dna.json'
    if not dna_path.exists():
        return Result('dna-exists', 'FAIL', 'app.dna.json not found')
    try:
        d = json.loads(dna_path.read_text())
    except json.JSONDecodeError as e:
        return Result('dna-valid-json', 'FAIL', f'JSON parse error: {e}')
    if d.get('schema') != 'freshvibe-way-v8.app-dna':
        return Result('dna-schema', 'FAIL', f'schema is {d.get("schema")}, expected freshvibe-way-v8.app-dna')
    if d.get('freshvibe_way_version') != 'v8.3.0':
        return Result('dna-fwv-version', 'FAIL', f'freshvibe_way_version is {d.get("freshvibe_way_version")}, expected v8.3.0')
    if not d.get('freshvibe_compliance', {}).get('v8_anti_drift_compliant'):
        return Result('dna-compliance', 'FAIL', 'v8_anti_drift_compliant flag not set')
    return Result('dna-validity', 'PASS', f'schema ok, fvw={d["freshvibe_way_version"]}, compliance flags set')

def check_pact():
    """Check 4: pact has app-pact.md + invariants.md + anti-drift.md + rules/"""
    required = [
        'app-pact/app-pact.md',
        'app-pact/invariants.md',
        'app-pact/anti-drift.md',
        'app-pact/rules/',
    ]
    missing = [r for r in required if not (ROOT / r).exists()]
    if missing:
        return Result('pact-completeness', 'FAIL', f'missing: {missing}')
    # Check rules/ has at least 1 file
    rules_dir = ROOT / 'app-pact' / 'rules'
    rule_files = list(rules_dir.glob('*.md'))
    if len(rule_files) < 1:
        return Result('pact-rules', 'FAIL', 'no rule files in app-pact/rules/')
    return Result('pact-completeness', 'PASS', f'all 4 required present, {len(rule_files)} rule file(s)')

def check_codex():
    """Check 5: codex has C1-C8 sections, >= 200 lines"""
    codex_path = ROOT / 'app-codex' / 'codex.md'
    if not codex_path.exists():
        return Result('codex-exists', 'FAIL', 'app-codex/codex.md not found')
    content = codex_path.read_text()
    for c in ['C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'C7', 'C8']:
        if f'## {c}' not in content:
            return Result(f'codex-{c}', 'FAIL', f'## {c} section missing')
    line_count = len(content.splitlines())
    if line_count < 200:
        return Result('codex-lines', 'FAIL', f'only {line_count} lines (need >= 200)')
    return Result('codex-c1-c8', 'PASS', f'C1-C8 all present, {line_count} lines')

def check_overlays():
    """Check 6: app-overlays/ has at least 2 overlays, each with overlay.json + index.js"""
    overlays_dir = ROOT / 'app-overlays'
    if not overlays_dir.exists():
        return Result('overlays-exists', 'FAIL', 'app-overlays/ not found')
    overlays = [d for d in overlays_dir.iterdir() if d.is_dir() and d.name != '__pycache__']
    if len(overlays) < 2:
        return Result('overlays-count', 'FAIL', f'only {len(overlays)} overlays (need >= 2)')
    for ov in overlays:
        if not (ov / 'overlay.json').exists():
            return Result(f'overlay-{ov.name}-manifest', 'FAIL', f'no overlay.json in {ov.name}')
    return Result('overlays-well-formed', 'PASS', f'{len(overlays)} overlays, all have manifest')

def check_recipes():
    """Check 7: per-block recipe books + module-meta.json with tier"""
    modules_dir = ROOT / 'modules'
    if not modules_dir.exists():
        return Result('modules-exists', 'FAIL', 'modules/ not found')
    metas = list(modules_dir.glob('*/module-meta.json'))
    recipes = list(modules_dir.glob('*/recipe.md'))
    if len(metas) + len(recipes) < 20:
        return Result('recipe-count', 'FAIL', f'only {len(metas)} metas + {len(recipes)} recipes (need >= 20)')
    tier_metas = [m for m in metas if '"tier"' in m.read_text()]
    if len(tier_metas) < 5:
        return Result('tier-declarations', 'FAIL', f'only {len(tier_metas)} module-meta.json declare tier (need >= 5)')
    return Result('recipe-books-present', 'PASS', f'{len(metas)} module-meta + {len(recipes)} recipe.md, {len(tier_metas)} declare tier')

def check_vendored():
    """Check 8: vendored FWV v8 has 55 files + VERSION=8.3.0"""
    vendored = ROOT / 'app-pact' / 'vendored' / 'fvw-v8'
    if not vendored.exists():
        return Result('vendored-exists', 'FAIL', 'app-pact/vendored/fvw-v8/ not found')
    files = [f for f in vendored.rglob('*') if f.is_file()]
    if len(files) < 50:
        return Result('vendored-file-count', 'FAIL', f'only {len(files)} files (need >= 50)')
    version_file = vendored / 'VERSION'
    if not version_file.exists():
        return Result('vendored-version-file', 'FAIL', 'VERSION file missing')
    version = version_file.read_text().strip()
    if not version.startswith('8.3'):
        return Result('vendored-version-value', 'FAIL', f'VERSION is {version}, expected 8.3.x')
    return Result('vendored-fwv-v8', 'PASS', f'{len(files)} files, VERSION={version}')

def check_source_paths_resolve():
    """Check 9: every module-meta.json source_path actually exists.

    Per FVW v8 §17.5: source_path in module-meta.json is a CONTRACT.
    The path must point at a real file (in the gallery, in the package,
    or anywhere else). If the path doesn't resolve, the FES worker
    will fail to load that widget.

    Caught in 2026-08-04 post-v1.1.0: the 17 vendored modules/*.js files
    were deleted but module-meta.json still pointed at them. 16/16
    module-meta.json files had broken source_path references.
    """
    import json
    modules_dir = ROOT / 'modules'
    if not modules_dir.exists():
        return Result('modules-exists', 'FAIL', 'modules/ not found')
    metas = list(modules_dir.glob('*/module-meta.json'))
    broken = []
    for m in metas:
        try:
            d = json.loads(m.read_text())
        except json.JSONDecodeError:
            continue
        sp = d.get('source_path', '')
        if not sp:
            continue
        # Accept both relative (in fvcms) and absolute-style paths
        if sp.startswith('/') or sp.startswith('avidtech6/') or sp.startswith('https://'):
            # External reference (gallery) — skip file existence check
            # but verify the kind is set to gallery-original
            if d.get('_module_kind') not in ('gallery-original', 'gallery-mirror', 'borrowed'):
                broken.append((m.parent.name, f'external path but kind={d.get("_module_kind")} (need gallery-original/mirror/borrowed)'))
            continue
        # Local relative path
        full = ROOT / sp
        if not full.exists():
            broken.append((m.parent.name, sp))
    if broken:
        names = ', '.join(f'{n}({p})' for n, p in broken[:3])
        return Result('source-paths-resolve', 'FAIL', f'{len(broken)} broken: {names}...')
    return Result('source-paths-resolve', 'PASS', f'{len(metas)} module-meta.json, all source_paths resolve')

CHECKS = {
    'check-shadow': check_shadow,
    'check-atlas': check_atlas,
    'check-dna': check_dna,
    'check-pact': check_pact,
    'check-codex': check_codex,
    'check-overlays': check_overlays,
    'check-recipes': check_recipes,
    'check-vendored': check_vendored,
    'check-source-paths': check_source_paths_resolve,
}

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('--check-shadow', action='store_true')
    parser.add_argument('--check-atlas', action='store_true')
    parser.add_argument('--check-dna', action='store_true')
    parser.add_argument('--check-pact', action='store_true')
    parser.add_argument('--check-codex', action='store_true')
    parser.add_argument('--check-overlays', action='store_true')
    parser.add_argument('--check-recipes', action='store_true')
    parser.add_argument('--check-vendored', action='store_true')
    parser.add_argument('--check-source-paths', action='store_true')
    parser.add_argument('--full', action='store_true')
    args = parser.parse_args()
    
    selected = [name for name in CHECKS if getattr(args, name.replace('-', '_'), False)]
    if args.full or not selected:
        selected = list(CHECKS.keys())
    
    results = []
    for name in selected:
        check_fn = CHECKS[name]
        try:
            r = check_fn()
            results.append(r)
            icon = '✓' if r.status == 'PASS' else '✗' if r.status == 'FAIL' else '~'
            print(f"  [{icon}] {r.name}: {r.status} — {r.evidence}")
        except Exception as e:
            r = Result(name, 'FAIL', f'exception: {type(e).__name__}: {e}')
            results.append(r)
            print(f"  [✗] {name}: FAIL — {e}")
    
    passed = sum(1 for r in results if r.status == 'PASS')
    total = len(results)
    print(f"\n{passed}/{total} checks passed")
    
    if passed < total:
        sys.exit(1)
    else:
        sys.exit(0)

if __name__ == '__main__':
    main()
