Files
erp/fleet/profile/scripts/validate.py
T
arcodangeandClaude Fable 5 a482bb18c4 feat(fleet): fiscal profile + compliance calendar + ADC register (erp#54, T11 data)
fleet/profile/ goes from stub to the machine-readable business-rules surface
the fleet reads (PRD agent-catalog document surface + compliance ADC framework):

- fiscal.yaml — entity, VAT position, 8 rules (regime reel simplifie until
  2026-12-31 -> quarterly CA3 from 2027-01-01 per LF 2025 art. 38; KM export
  autoliquidation 259-1 CGI box E2; FR 20% deductible; intra-EU reverse
  charge; FX 766/666; SaaS expensed; CCA 455 lane). Every rule carries
  effective_from/effective_until AND decision: adc-NNN; every date cites its
  PRD anchor as an inline comment (verified against factory origin/main).
- calendar.yaml — 15 entries: acomptes TVA (2026-07 month-window, 2026-12-15),
  last CA12 FY-2026 (2027-05-04), CA3 quarterly windows, CFE (December),
  AG comptes annuels (2027-06-30), e-invoicing milestones (2026-09-01
  reception, 2027-09-01 emission/e-reporting), URSSAF echeancier with the
  in-file NOTE that a real direct debit exists since May 2026 (erp#57 revisit
  of the payroll-dormant assumption), KM deferred due dates + renewal stub.
- JSON Schemas for both + scripts/validate.py (stdlib-only: strict YAML-subset
  parser, JSON-Schema-subset checker, rule->ADC resolution, calendar checks).
- decisions/ — ADC register: template + adc-001..005 Accepted formalizations
  (autoliquidation KM, FX->766/666, SaaS expensed, reel simplifie until
  abolition, CCA personal-card lane) + adc-006/007 Proposed stubs (retainer
  currency -> erp#53; capital path -> erp#51). Agents draft, the operator
  Accepts — never the reverse; immutable once merged, supersede never edit.
- Mutation policy in-file: PRs only (T12 proposes, human merges).
- Same-change: profile README stub -> real doc; fleet/README.md layout line
  and AGENTS.md fleet row updated (profile no longer a stub).

Validation: PASS — 8 rules, 15 entries, 7 ADCs, 0 errors, 7 warnings (the
warnings list exactly what awaits operator verification). Human gate left
open on purpose: operator sanity-read of the calendar + Acceptance of
adc-001..005.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
2026-07-18 23:45:00 +02:00

436 lines
17 KiB
Python

#!/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="<yaml>"):
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-<slug>.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, "<missing>", "<missing>"))
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())