[DTP-Worker] Sprint 8 · buffer L60 · Domaine SEO (2e surface) : la COMPOSITION du graphe schema.org (« graphe JSON-LD Organization + une Residence par projet ») était décrite EN PROSE dans le README du module seo sans AUCUN gate d'IDENTITÉ. Le bloc SEO existant ne gate que le COMPTE de mots-clés (total + fr/en/es via MANIFEST.counts) — surface distincte du MÊME README. Ces @type sont DATA-DERIVED : l'ensemble des @type du @graph de seo/out/seo_schema_org.json (byte-gaté par check_artifacts) = un nœud racine Organization (@id …#organization) + une Residence par projet, listings uniformes. Aucun gate ne comparait ces TYPES à l'artefact : RENOMMER le @type des listings (Residence→Apartment, un @type voisin du rango schema.org) · AJOUTER un 3e type de nœud (Offer/Product) · CASSER la bijection (2 nœuds pour un projet) ferait dériver la prose en silence pendant que le graphe byte-gaté dit autre chose — « vert trompeur » qu'aucune suite tests/ (qui teste des FONCTIONS de génération, pas la prose du README) n'attrape → nouveau bloc recomputant l'ENSEMBLE des @type depuis SON artefact byte-gaté (zéro duplication du contrat du générateur #6) et exigeant que la parenthèse README les nomme EXACTEMENT. Contrôle par ENSEMBLE (absent ET en trop mordus via set-diff) + le type « par projet » nommé == @type des listings. Cohérences croisées en bonus (mordent un artefact INTERNEMENT incohérent) : nœud racine Organization SINGLETON · listings UNIFORMES (un seul @type) et en BIJECTION avec les projets (listings == projects == schema_org_nodes−1 du MANIFEST) · aucune offers (statuts sans prix · USD #10). Un claim absent échoue AUSSI. 6 morsures vérifiées : prose listing Residence→Apartment captée (absents=[Residence] en trop=[Apartment] + « par projet » désaligné) · prose racine Organization→Business captée · artefact +nœud Offer (prose absente + listings non uniformes) capté · artefact 1 listing Residence→Apartment (non uniforme) capté · MANIFEST listings 9→8 (bijection cassée) capté · composition prose supprimée (INTROUVABLE) captée ; restauré = green : racine Organization singleton · 9 × Residence · total 10 nœuds · aucune offers == seo_schema_org.@graph. État courant : aucun type périmé (anti-invention #6, rien à réécrire) — le défaut est la surface ungated. ci/README.md (table + détail « 2e surface SEO ») mis à jour · 7 gates re-verts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Code DTP Worker
2026-07-31 17:37:50 +00:00
parent 733c73ae3f
commit e52519dab1
3 changed files with 138 additions and 2 deletions
+95
View File
@@ -1805,6 +1805,101 @@ if fx_cdt is not None:
bad(f"RBAC fixtures · {FX_RD} — nomme « {mr.group(1).strip()} » MAIS "
f"set_user_permissions porté par {fx_sup[0]!r} → régénérer le README")
# ============================================================================
# SEO · la COMPOSITION du graphe schema.org (JSON-LD) — un nœud racine
# `Organization` + une `Residence` PAR PROJET — décrite EN PROSE dans le README
# sans AUCUN gate d'IDENTITÉ.
# ----------------------------------------------------------------------------
# Le générateur SEO émet out/seo_schema_org.json (byte-gaté par check_artifacts ⇒
# faisant autorité). Son `@graph` est l'ENSEMBLE des nœuds JSON-LD : exactement UN
# nœud racine `Organization` (@id …#organization) + un listing PAR PROJET, tous du
# même @type. Le README:16 décrit cette structure À LA MAIN : « graphe JSON-LD
# (`Organization` + une `Residence` par projet) ». Le bloc SEO existant ne gate que
# le COMPTE de mots-clés (keywords_total + ventilation par langue) — jamais les
# @type du graphe ni la bijection un-listing-par-projet. Aucun gate ne comparait
# ces TYPES à l'artefact : RENOMMER le @type des listings (Residence → Apartment,
# rango schema.org) OU AJOUTER un 3ᵉ type de nœud (Offer/Product) OU casser la
# bijection (2 nœuds pour un projet) ferait dériver la prose en silence pendant que
# le graphe byte-gaté dit autre chose — « vert trompeur » qu'aucune suite tests/
# (qui teste des FONCTIONS de génération, pas la prose du README) n'attrape. Même
# patron que l'énumération des onglets Mobile ou du catalogue custom RBAC. On
# RECOMPUTE l'ENSEMBLE des @type depuis SON artefact byte-gaté (zéro duplication du
# contrat du générateur #6) et on exige que la prose les nomme EXACTEMENT (absent ET
# en trop via set-diff). Cohérences croisées en bonus : le nœud racine Organization
# est un SINGLETON ; les listings sont uniformes (un seul @type) et en BIJECTION avec
# les projets (listings == projects == schema_org_nodes1 du MANIFEST byte-gaté) ;
# aucune `offers` (statuts sans prix · USD #10). Un claim absent échoue AUSSI.
# ============================================================================
SEO_SG_RD = f"{D}/seo/README.md"
try:
sg = load("seo/out/seo_schema_org.json")["@graph"]
seo_cnt = load("seo/out/MANIFEST.json")["counts"]
sg_org = [n for n in sg if str(n.get("@id", "")).endswith("#organization")]
sg_list = [n for n in sg if not str(n.get("@id", "")).endswith("#organization")]
sg_types = {n["@type"] for n in sg} # ensemble des @type du graphe
org_types = {n["@type"] for n in sg_org}
list_types = {n["@type"] for n in sg_list}
sg_offers = sum(1 for n in sg if "offers" in n)
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"SEO schema.org · artefact illisible/inattendu : {e}"); sg = None
if sg is not None:
n_proj = seo_cnt.get("projects")
# bonus 1 : racine Organization = SINGLETON, d'un seul @type
if len(sg_org) != 1 or len(org_types) != 1:
bad(f"SEO schema.org · nœud racine #organization non-singleton "
f"({len(sg_org)} nœud(s), types={sorted(org_types)}) → graphe suspect")
else:
good(f"SEO schema.org · racine {sorted(org_types)[0]!r} — singleton (1 nœud)")
# bonus 2 : listings uniformes + bijection 1 par projet + total du MANIFEST
if not sg_list or len(list_types) != 1:
bad(f"SEO schema.org · listings non uniformes (types={sorted(list_types)}) "
f"→ un @type unique attendu par projet")
elif not (len(sg_list) == n_proj == seo_cnt.get("listings")
== seo_cnt.get("schema_org_nodes", 0) - 1):
bad(f"SEO schema.org · bijection cassée : {len(sg_list)} listing(s) MAIS "
f"projects={n_proj} listings={seo_cnt.get('listings')} "
f"schema_org_nodes={seo_cnt.get('schema_org_nodes')} (attendu 1+{n_proj})")
else:
good(f"SEO schema.org · {len(sg_list)} × {sorted(list_types)[0]!r} — bijection "
f"un listing par projet ({n_proj}) · total {seo_cnt.get('schema_org_nodes')} nœuds")
# bonus 3 : aucune offers/prix (statuts sans prix · #10 · cohérent MANIFEST.offers)
if sg_offers != 0 or seo_cnt.get("offers") != 0:
bad(f"SEO schema.org · {sg_offers} nœud(s) `offers` (MANIFEST.offers="
f"{seo_cnt.get('offers')}) → prix non attendu pour statuts sans prix (#10)")
else:
good("SEO schema.org · aucune `offers`/prix (statuts sans prix · USD #10)")
seo_sg_rd = read_norm(SEO_SG_RD)
if seo_sg_rd is not None:
# README:16 — la parenthèse décrit la composition du graphe : ENSEMBLE des @type
msg = re.search(r"graphe JSON-LD \(([^)]*)\)", seo_sg_rd)
if msg is None:
bad(f"SEO schema.org · {SEO_SG_RD} — composition « graphe JSON-LD (…) » "
f"INTROUVABLE (attendu {sorted(sg_types)})")
else:
prose_types = set(re.findall(r"`([A-Za-z]+)`", msg.group(1)))
if prose_types == sg_types:
good(f"SEO schema.org · {SEO_SG_RD} — @type {sorted(prose_types)} == "
f"seo_schema_org.@graph")
else:
miss = sorted(sg_types - prose_types); extra = sorted(prose_types - sg_types)
detail = (f" · absents={miss}" if miss else "") + \
(f" · en trop={extra}" if extra else "")
bad(f"SEO schema.org · {SEO_SG_RD} — nomme {sorted(prose_types)} MAIS "
f"graphe {sorted(sg_types)}{detail} → régénérer le README")
# README:16 — le type PAR PROJET nommé == le @type des listings (racine distincte)
if len(list_types) == 1:
mpp = re.search(r"une `([A-Za-z]+)` par projet", seo_sg_rd)
lt = sorted(list_types)[0]
if mpp is None:
bad(f"SEO schema.org · {SEO_SG_RD} — « une `<type>` par projet » "
f"INTROUVABLE (attendu {lt!r})")
elif mpp.group(1) == lt:
good(f"SEO schema.org · {SEO_SG_RD} — « une `{lt}` par projet » == "
f"listings du graphe")
else:
bad(f"SEO schema.org · {SEO_SG_RD} — dit « une `{mpp.group(1)}` par projet » "
f"MAIS listings sont `{lt}` → régénérer le README")
sys.exit(1 if FAIL else 0)
PY
rc=$?