#!/usr/bin/env python3 """Validate the fleet/profile document surface (erp#54). Checks, in order: 1. fiscal.yaml parses (strict YAML subset) and validates against fiscal.schema.json 2. calendar.yaml parses and validates against calendar.schema.json 3. Referential integrity: - every fiscal rule's `decision: adc-NNN` resolves to exactly one decisions/adc-NNN-*.md whose frontmatter status is Accepted (agents draft, the operator Accepts - a rule may only rely on an Accepted decision) - rule/entry ids unique; effective_from <= effective_until - calendar entries: exactly one of due|recurrence, unless status is pending-definition/conditional; recurrence months are 1..12 4. ADC register hygiene: filename <-> frontmatter id match, valid status (Proposed | Accepted | Superseded-by-NNN), Accepted records carry decided + effective_from, Proposed records carry decided: null, required body sections present, no duplicate adc numbers. Stdlib only, by design (PRD agent-catalog "document surface" + erp#54): the profile YAML is written in a strict subset - block maps, block sequences, single-line scalars, one-line [flow] lists of scalars, comments; no anchors, no multi-line scalars, no nested flow - and the schemas use the JSON-Schema subset {type, properties, required, additionalProperties, items, enum, pattern}. Usage: python3 fleet/profile/scripts/validate.py Exit 0 = all green (warnings allowed). Exit 1 = at least one error. """ import json import re import sys from pathlib import Path PROFILE = Path(__file__).resolve().parent.parent DECISIONS = PROFILE / "decisions" DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") ADC_FILE_RE = re.compile(r"^adc-(\d{3})-[a-z0-9-]+\.md$") ADC_STATUS_RE = re.compile(r"^(Proposed|Accepted|Superseded-by-\d{3})$") ADC_REQUIRED_SECTIONS = [ "## Context", "## Decision", "## Base légale & doctrine", "## Alternatives rejected", "## Consequences", "## QA & validation", "## References", ] # -------------------------------------------------------------------------- # Strict YAML-subset parser # -------------------------------------------------------------------------- class YamlSubsetError(Exception): def __init__(self, msg, line=None): super().__init__(f"line {line}: {msg}" if line else msg) def _strip_comment(raw, line): """Return the value token of a raw value string, comment stripped.""" s = raw.strip() if not s or s.startswith("#"): return "" if s[0] in "\"'": q = s[0] end = s.find(q, 1) if end == -1: raise YamlSubsetError("unterminated quoted scalar", line) rest = s[end + 1:].strip() if rest and not rest.startswith("#"): raise YamlSubsetError(f"trailing content after quoted scalar: {rest!r}", line) return s[: end + 1] idx = s.find(" #") if idx != -1: s = s[:idx] return s.strip() def _scalar(tok, line): if tok == "" or tok in ("null", "~"): return None if tok == "true": return True if tok == "false": return False if len(tok) >= 2 and tok[0] in "\"'" and tok[-1] == tok[0]: return tok[1:-1] if tok.startswith("["): if not tok.endswith("]"): raise YamlSubsetError("unterminated flow list", line) inner = tok[1:-1].strip() if not inner: return [] return [_scalar(p.strip(), line) for p in inner.split(",")] if re.fullmatch(r"-?\d+", tok): return int(tok) if re.fullmatch(r"-?\d+\.\d+", tok): return float(tok) return tok # plain string (ISO dates stay strings) def parse_yaml(text, name=""): items = [] for n, raw in enumerate(text.splitlines(), 1): if not raw.strip() or raw.strip().startswith("#"): continue if "\t" in raw: raise YamlSubsetError(f"{name}: tab character (use spaces)", n) indent = len(raw) - len(raw.lstrip(" ")) items.append((indent, raw.strip(), n)) if not items: return {} value, nxt = _parse_block(items, 0, items[0][0]) if nxt != len(items): raise YamlSubsetError(f"{name}: trailing content", items[nxt][2]) return value def _parse_block(items, i, indent): _, content, line = items[i] if items[i][0] != indent: raise YamlSubsetError("unexpected indent", line) if content == "-" or content.startswith("- "): return _parse_seq(items, i, indent) return _parse_map(items, i, indent) def _parse_map(items, i, indent): result = {} while i < len(items): ind, content, line = items[i] if ind < indent: break if ind > indent: raise YamlSubsetError("unexpected deeper indent", line) if content == "-" or content.startswith("- "): raise YamlSubsetError("sequence item at mapping level", line) m = re.match(r"^([A-Za-z_][A-Za-z0-9_-]*):(?:\s+(.*))?$", content) if not m: raise YamlSubsetError(f"not a 'key: value' line: {content!r}", line) key, rest = m.group(1), m.group(2) if key in result: raise YamlSubsetError(f"duplicate key {key!r}", line) tok = _strip_comment(rest, line) if rest else "" if tok: result[key] = _scalar(tok, line) i += 1 else: i += 1 if i < len(items) and items[i][0] > indent: value, i = _parse_block(items, i, items[i][0]) result[key] = value else: result[key] = None return result, i def _parse_seq(items, i, indent): result = [] while i < len(items): ind, content, line = items[i] if ind < indent: break if ind > indent: raise YamlSubsetError("unexpected deeper indent in sequence", line) if not (content == "-" or content.startswith("- ")): break rest = content[1:].strip() if not rest or rest.startswith("#"): i += 1 if i < len(items) and items[i][0] > indent: value, i = _parse_block(items, i, items[i][0]) result.append(value) else: result.append(None) elif re.match(r"^[A-Za-z_][A-Za-z0-9_-]*:(\s|$)", rest): # inline first key of a mapping item: reparse at indent+2 sub = [(indent + 2, rest, line)] i += 1 while i < len(items) and items[i][0] > indent: s_ind, s_content, s_line = items[i] if s_ind < indent + 2: raise YamlSubsetError("bad indent inside sequence item", s_line) sub.append((s_ind, s_content, s_line)) i += 1 value, used = _parse_map(sub, 0, indent + 2) if used != len(sub): raise YamlSubsetError("trailing content in sequence item", sub[used][2]) result.append(value) else: tok = _strip_comment(rest, line) result.append(_scalar(tok, line)) i += 1 return result, i # -------------------------------------------------------------------------- # JSON-Schema subset checker # -------------------------------------------------------------------------- def _is_type(v, t): return { "object": lambda: isinstance(v, dict), "array": lambda: isinstance(v, list), "string": lambda: isinstance(v, str), "integer": lambda: isinstance(v, int) and not isinstance(v, bool), "number": lambda: isinstance(v, (int, float)) and not isinstance(v, bool), "boolean": lambda: isinstance(v, bool), "null": lambda: v is None, }[t]() def schema_check(value, schema, path, errors): types = schema.get("type") if types: if isinstance(types, str): types = [types] if not any(_is_type(value, t) for t in types): errors.append(f"{path}: expected {'/'.join(types)}, got {type(value).__name__}") return if value is None: return if "enum" in schema and value not in schema["enum"]: errors.append(f"{path}: {value!r} not in {schema['enum']}") if isinstance(value, str) and "pattern" in schema: if not re.search(schema["pattern"], value): errors.append(f"{path}: {value!r} does not match {schema['pattern']!r}") if isinstance(value, dict): props = schema.get("properties", {}) for req in schema.get("required", []): if req not in value: errors.append(f"{path}: missing required key '{req}'") if schema.get("additionalProperties") is False: for k in value: if k not in props: errors.append(f"{path}: unexpected key '{k}'") for k, v in value.items(): if k in props: schema_check(v, props[k], f"{path}.{k}", errors) if isinstance(value, list) and "items" in schema: for idx, item in enumerate(value): schema_check(item, schema["items"], f"{path}[{idx}]", errors) # -------------------------------------------------------------------------- # ADC register # -------------------------------------------------------------------------- def parse_frontmatter(path): text = path.read_text(encoding="utf-8") if not text.startswith("---\n"): raise YamlSubsetError(f"{path.name}: missing frontmatter") end = text.find("\n---", 4) if end == -1: raise YamlSubsetError(f"{path.name}: unterminated frontmatter") return parse_yaml(text[4:end], path.name), text[end + 4:] def load_adc_register(errors): """Return {'adc-NNN': {'file': name, 'status': str, ...}}.""" register = {} if not DECISIONS.is_dir(): errors.append(f"decisions/ directory missing at {DECISIONS}") return register for path in sorted(DECISIONS.glob("*.md")): if path.name == "adc-template.md": continue m = ADC_FILE_RE.match(path.name) if not m: errors.append(f"decisions/{path.name}: name must match adc-NNN-.md") continue adc_id = f"adc-{m.group(1)}" if adc_id in register: errors.append(f"decisions/{path.name}: duplicate id {adc_id} " f"(also {register[adc_id]['file']})") continue try: fm, body = parse_frontmatter(path) except YamlSubsetError as e: errors.append(f"decisions/{path.name}: {e}") continue if fm.get("id") != adc_id: errors.append(f"decisions/{path.name}: frontmatter id {fm.get('id')!r} != {adc_id}") status = fm.get("status") if not (isinstance(status, str) and ADC_STATUS_RE.match(status)): errors.append(f"decisions/{path.name}: invalid status {status!r} " "(Proposed | Accepted | Superseded-by-NNN)") if status == "Accepted": if not (isinstance(fm.get("decided"), str) and DATE_RE.match(fm["decided"])): errors.append(f"decisions/{path.name}: Accepted requires a 'decided' date") if not (isinstance(fm.get("effective_from"), str) and DATE_RE.match(fm["effective_from"])): errors.append(f"decisions/{path.name}: Accepted requires 'effective_from'") elif status == "Proposed": if fm.get("decided") is not None: errors.append(f"decisions/{path.name}: Proposed must keep decided: null " "(acceptance is a human act)") for section in ADC_REQUIRED_SECTIONS: if section not in body: errors.append(f"decisions/{path.name}: missing section '{section}'") register[adc_id] = dict(fm, file=path.name) return register # -------------------------------------------------------------------------- # Custom (referential) checks # -------------------------------------------------------------------------- def check_dates_ordered(obj, path, errors): frm, until = obj.get("effective_from"), obj.get("effective_until") if isinstance(frm, str) and isinstance(until, str) and frm > until: errors.append(f"{path}: effective_from {frm} > effective_until {until}") def check_fiscal(fiscal, register, errors, warnings, resolution): seen = set() for idx, rule in enumerate(fiscal.get("rules") or []): rid = rule.get("id", f"[{idx}]") path = f"fiscal.rules.{rid}" if rid in seen: errors.append(f"{path}: duplicate rule id") seen.add(rid) check_dates_ordered(rule, path, errors) decision = rule.get("decision") adc = register.get(decision) if adc is None: errors.append(f"{path}: decision {decision!r} does not resolve to any " f"decisions/adc-NNN-*.md record") resolution.append((rid, decision, "", "")) continue resolution.append((rid, decision, adc["file"], adc.get("status"))) if adc.get("status") != "Accepted": errors.append(f"{path}: decision {decision} has status " f"{adc.get('status')!r} - a rule in force may only cite an " "Accepted decision (agents draft, the operator Accepts)") def check_calendar(calendar, errors, warnings): seen = set() for idx, entry in enumerate(calendar.get("entries") or []): eid = entry.get("id", f"[{idx}]") path = f"calendar.entries.{eid}" if eid in seen: errors.append(f"{path}: duplicate entry id") seen.add(eid) check_dates_ordered(entry, path, errors) status = entry.get("status", "confirmed") has_due = entry.get("due") is not None has_rec = entry.get("recurrence") is not None if has_due and has_rec: errors.append(f"{path}: carries both due and recurrence") elif not has_due and not has_rec and status not in ("pending-definition", "conditional"): errors.append(f"{path}: needs due or recurrence (status {status!r})") rec = entry.get("recurrence") months = entry.get("months") if rec in ("yearly", "quarterly"): if not months: errors.append(f"{path}: recurrence {rec!r} requires months") elif months is not None: errors.append(f"{path}: months only allowed with yearly/quarterly recurrence") for mth in months or []: if not (isinstance(mth, int) and 1 <= mth <= 12): errors.append(f"{path}: month {mth!r} not in 1..12") if has_due and re.fullmatch(r"\d{4}-\d{2}", entry["due"]): warnings.append(f"{path}: month-precision due ({entry['due']}) - the source " "gives no day; verify on the authority's notice") if status in ("estimated", "conditional", "pending-definition"): warnings.append(f"{path}: status {status} - operator verification pending") # -------------------------------------------------------------------------- # Main # -------------------------------------------------------------------------- def load_and_validate(name, errors): yaml_path = PROFILE / f"{name}.yaml" schema_path = PROFILE / f"{name}.schema.json" try: data = parse_yaml(yaml_path.read_text(encoding="utf-8"), yaml_path.name) except (OSError, YamlSubsetError) as e: errors.append(f"{yaml_path.name}: {e}") return None try: schema = json.loads(schema_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as e: errors.append(f"{schema_path.name}: {e}") return None before = len(errors) schema_check(data, schema, name, errors) print(f"== {yaml_path.name} ==") print(f" parse: OK") print(f" schema ({schema_path.name}): {'OK' if len(errors) == before else 'FAIL'}") return data def main(): errors, warnings, resolution = [], [], [] fiscal = load_and_validate("fiscal", errors) calendar = load_and_validate("calendar", errors) print("== decisions/ (ADC register) ==") register = load_adc_register(errors) for adc_id in sorted(register): adc = register[adc_id] print(f" {adc['file']:<44} [{adc.get('status')}]") if fiscal is not None: check_fiscal(fiscal, register, errors, warnings, resolution) print("== rule -> ADC resolution ==") for rid, decision, fname, status in resolution: print(f" {rid:<32} -> {decision} -> {fname} [{status}]") if calendar is not None: check_calendar(calendar, errors, warnings) if warnings: print(f"== warnings ({len(warnings)}) ==") for w in warnings: print(f" WARN {w}") if errors: print(f"== errors ({len(errors)}) ==") for e in errors: print(f" ERROR {e}") n_rules = len((fiscal or {}).get("rules") or []) n_entries = len((calendar or {}).get("entries") or []) verdict = "FAIL" if errors else "PASS" print(f"== RESULT: {verdict} - {n_rules} rules, {n_entries} calendar entries, " f"{len(register)} ADC records, {len(errors)} errors, {len(warnings)} warnings ==") return 1 if errors else 0 if __name__ == "__main__": sys.exit(main())