From 2836e6bdfcbdce33157699bac685f9f535e990f7 Mon Sep 17 00:00:00 2001 From: jsboige Date: Sat, 19 Sep 2026 04:28:05 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(audit,#16776):=20organ-duplication=20d?= =?UTF-8?q?etector=20=E2=80=94=20organ=20of=20the=20organ-first=20rule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Index of characteristic public API symbols for 8 series (measured on the tree, .py packages + .ipynb code cells; generic names excluded by curation). Detector scans a diff (--patch or --base merge-base) for added def/class colliding with another series organ; a declared pedagogical copy in the PR body whitens (canonical + retroactive Greffe2 forms). Advisory CI wiring: always exit 0, sticky comment payload, anti-fork guard, self-covering paths. Controls verified on REAL patches: #13802 (Greffe2) 5 COLLISION without body / 5 EXEMPTED with; #13664 (ICT-12e consumer) CLEAN. 10/10 hermetic tests. Closes #16776 Co-Authored-By: Claude Sonnet 5 --- .../workflows/organ-duplication-advisory.yml | 118 ++++++++++ scripts/audit/detect_organ_duplication.py | 175 +++++++++++++++ scripts/audit/organ_api_index.yaml | 189 ++++++++++++++++ .../tests/test_detect_organ_duplication.py | 208 ++++++++++++++++++ 4 files changed, 690 insertions(+) create mode 100644 .github/workflows/organ-duplication-advisory.yml create mode 100644 scripts/audit/detect_organ_duplication.py create mode 100644 scripts/audit/organ_api_index.yaml create mode 100644 scripts/tests/test_detect_organ_duplication.py diff --git a/.github/workflows/organ-duplication-advisory.yml b/.github/workflows/organ-duplication-advisory.yml new file mode 100644 index 0000000000..e4051474bb --- /dev/null +++ b/.github/workflows/organ-duplication-advisory.yml @@ -0,0 +1,118 @@ +name: Organ-duplication advisory + +# Organe de la regle organ-first implementation (.claude/rules/ +# organ-first-implementation.md, merge #16778 ; spec #16776, fille #13564). +# +# Ce que la regle demande : avant de reimplementer une semantique qu'une +# autre serie possede deja, la PR nomme l'organe (5 questions dans le body) +# OU declare une copie pedagogique (« copie pedagogique declaree, motif : ... »). +# Jusqu'ici la regle s'applique a la main -- ce detector rend la duplication +# VISIBLE : symboles def/class ajoutes qui collisionnent avec l'index des +# organes (scripts/audit/organ_api_index.yaml), dans une serie != proprietaire. +# +# Controle positif retroactif (acceptance #16776) : Greffe2 -- le patch reel +# de #13802 produit 5 COLLISION (Operateur, successeurs, prop, ops_de_base, +# ops_avec_radeau -> Planners) SANS body, et 5 EXEMPTED AVEC son body +# (« copie fidele »). Controle negatif : #13664 (ICT-12e, consommateur +# Infer.NET/PyMC pur) = 0 collision. +# +# ADVISORY, never blocking (acceptance #16776 : « advisory en CI dans un +# premier temps ») : le job sort TOUJOURS 0. Le payload actionnable est le +# commentaire sticky (collisions + comment declarer), jamais la conclusion +# verte du job. +# +# Hors scope (student-pr-reviews.md) : les PRs de fork (soumissions +# etudiantes) sautent le job -- review bienveillante, pas de critere +# interne de contenu. + +on: + pull_request: + types: [opened, synchronize, edited, reopened] + branches: [main] + paths: + - '**/*.py' + - '**/*.ipynb' + # Self-cover (#8822) : l'organe se liste lui-meme, sinon il ne peut + # plus se re-evaluer une fois ses paths sortis du diff. + - 'scripts/audit/detect_organ_duplication.py' + - 'scripts/audit/organ_api_index.yaml' + - '.github/workflows/organ-duplication-advisory.yml' + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +concurrency: + group: organ-duplication-advisory-${{ github.ref }} + cancel-in-progress: true + +jobs: + organ-duplication-advisory: + name: "Organ-duplication advisory (non-blocking)" + # Routage #14283 tranche 3 : jambe Linux auto-hebergee, runs-on STATIQUE + # (garde auditable de check_self_hosted_runner_policy.py). Les PRs de + # fork sautent le job -- pr_gate.py compte skipped comme OK. + # Retour arriere = remettre runs-on: ubuntu-latest + retrait allowlist. + runs-on: [self-hosted, coursia-ephemeral, coursia-linux] + # Same-repo PRs only (agents/staff). Fork = etudiants ( benevolent review). + if: github.event.pull_request.head.repo.full_name == github.repository + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + # fetch-depth 0 + blob:none : merge-base doit resoudre sur une + # branche en retard (#11646/#11658). + fetch-depth: 0 + filter: blob:none + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Run detector (advisory, do not gate) + id: run + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: | + python -m pip install --quiet pyyaml + set +e + # Base = merge-base origin/main HEAD ; si introuvable : ABSTENTION + # EXPLICITE, jamais de fallback silencieux (garde #8655/#8662 : + # « pas regarde » et « rien trouve » doivent rester distinguables). + BASE=$(git merge-base origin/main HEAD 2>/dev/null) + if [ -z "$BASE" ]; then + echo "status=abstain" >> "$GITHUB_OUTPUT" + exit 0 + fi + printf '%s' "$PR_BODY" > /tmp/pr_body.txt + python scripts/audit/detect_organ_duplication.py \ + --base "$BASE" --head HEAD \ + --body-file /tmp/pr_body.txt \ + > /tmp/organ_dup.out 2>/tmp/organ_dup.err + rc=$? + # --check omis : advisory. rc n'est lu que pour le statut affiche. + if grep -q '^BLOCKED' /tmp/organ_dup.out; then + echo "status=collision" >> "$GITHUB_OUTPUT" + elif grep -q '^EXEMPTED' /tmp/organ_dup.out; then + echo "status=exempted" >> "$GITHUB_OUTPUT" + elif grep -q '^CLEAN' /tmp/organ_dup.out; then + echo "status=clean" >> "$GITHUB_OUTPUT" + else + echo "status=abstain" >> "$GITHUB_OUTPUT" + cat /tmp/organ_dup.err >&2 + exit 0 + fi + cat /tmp/organ_dup.out + exit 0 + + - name: Post advisory comment + if: github.event_name == 'pull_request' + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: organ-duplication-advisory + message: | + ${{ steps.run.outputs.status == 'clean' && 'No organ-duplication: no added def/class collides with another series organ API (scripts/audit/organ_api_index.yaml).' || steps.run.outputs.status == 'exempted' && 'Organ duplication DETECTED but EXEMPTED: the PR body declares a pedagogical copy. The organ-first rule (5 questions) is satisfied by construction. If this copy grows (several consumer notebooks), ask the source series for an extraction instead.' || steps.run.outputs.status == 'abstain' && 'Organ-duplication detector ABSTAINS: merge-base unresolved or structural error -- no verdict. See workflow log.' || format('WARNING: added symbol(s) collide with another series organ API (organ-first rule, .claude/rules/organ-first-implementation.md). Answer the 5 questions in the PR body, or declare the pedagogical copy (« copie pedagogique declaree, motif : ... ») which whitens it. See the workflow log for the full collision list (demanding series -> bypassed organ -> symbol). Advisory, NOT a merge gate.', '') }} + + Detector: `python scripts/audit/detect_organ_duplication.py --base --body-file ` + Rationale: #16776 / #13564 (rule merged in #16778). diff --git a/scripts/audit/detect_organ_duplication.py b/scripts/audit/detect_organ_duplication.py new file mode 100644 index 0000000000..4a8f1233f9 --- /dev/null +++ b/scripts/audit/detect_organ_duplication.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Detecteur de duplication d'organe (#16776, fille de #13564). + +Sert la regle .claude/rules/organ-first-implementation.md (merge #16778) : +avant de reimplementer une semantique qu'une autre serie possede deja, +une PR doit nommer l'organe (5 questions) ou declarer une copie +pedagogique dans son body. + +Scan un diff (mode --patch ou --base) et repere les symboles def/class +AJOUTES qui collisionnent avec l'index des organes +(scripts/audit/organ_api_index.yaml), dans un fichier hors de la serie +proprietaire de l'organe. Une phrase de copie declaree dans le body de +la PR blanchit les collisions (la porte de sortie vit dans le body). + +Sortie : une ligne par collision au format + COLLISION () -> () +--check : exit 1 si au moins une collision non declaree. +""" + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +try: + import yaml +except ImportError: # pragma: no cover + print("ERROR: PyYAML requis (pip install pyyaml)", file=sys.stderr) + sys.exit(2) + +DEFAULT_INDEX = Path(__file__).resolve().parent / "organ_api_index.yaml" + +DEF_RE = re.compile(r"(?:^|[\s\"'(])(?:def|class)\s+([A-Za-z_][A-Za-z0-9_]*)") +DIFF_FILE_RE = re.compile(r"^diff --git a/(.+?) b/(.+?)(?:\s|$)") + +# Formes acceptees pour la declaration : « copie pedagogique declarée » +# (forme canonique de la regle) et « copie fidèle » (forme retroactive du +# cas Greffe2, PR #13802). Les accents sont normalises avant matching. +DECLARATION_RE = re.compile( + r"\bcopie\s+(?:pedagogique\s+)?(?:declaree|fidele)\b", re.IGNORECASE +) + + +def strip_accents(text: str) -> str: + import unicodedata + nfd = unicodedata.normalize("NFD", text) + return "".join(c for c in nfd if not unicodedata.combining(c)) + + +def is_declared(body: str) -> bool: + if not body: + return False + return bool(DECLARATION_RE.search(strip_accents(body))) + + +def load_index(path: Path): + data = yaml.safe_load(path.read_text(encoding="utf-8")) + series = data.get("series") or {} + if not isinstance(series, dict) or not series: + raise SystemExit(f"ERROR: index {path} sans section 'series' exploitable") + symbol_owners = {} + owners = {} + for name, entry in series.items(): + owner_path = str(entry.get("owner_path", "")).rstrip("/") + owners[name] = owner_path + for sym in entry.get("symbols") or []: + symbol_owners.setdefault(str(sym), name) + return owners, symbol_owners + + +def resolve_series(path: str, owners: dict): + """Serie propriétaire du fichier = owner_path le plus long prefix du chemin. + + Fallback hors index : composant racine sous MyIA.AI.Notebooks (label + informatif ~, pas une serie indexee). + """ + best, best_len = None, -1 + for name, owner_path in owners.items(): + if owner_path and (path + "/").startswith(owner_path + "/") and len(owner_path) > best_len: + best, best_len = name, len(owner_path) + if best is None: + parts = path.split("/") + if len(parts) >= 2 and parts[0] == "MyIA.AI.Notebooks": + return "~" + parts[1] + return best + + +def parse_patch(patch_text: str, owners: dict, symbol_owners: dict): + """Retourne [(file, series, symbol)] pour chaque def/class ajoute en collision.""" + collisions = [] + current_file = None + for line in patch_text.splitlines(): + m = DIFF_FILE_RE.match(line) + if m: + current_file = m.group(2) + continue + if current_file is None or not line.startswith("+") or line.startswith("+++"): + continue + content = line[1:].lstrip() + if content.startswith("#"): + continue + for sym_m in DEF_RE.finditer(line[1:]): + sym = sym_m.group(1) + owner = symbol_owners.get(sym) + if owner is None: + continue + file_series = resolve_series(current_file, owners) + if file_series == owner: + continue + collisions.append((current_file, file_series, owner, sym)) + return collisions + + +def git_diff(base: str, head: str, cwd: Path) -> str: + result = subprocess.run( + ["git", "diff", f"{base}...{head}", "--unified=0"], + capture_output=True, text=True, cwd=str(cwd), check=True, + ) + return result.stdout + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--patch", help="fichier diff unifie a scanner (mode hors git)") + parser.add_argument("--base", help="ref de base pour git diff (defaut origin/main)") + parser.add_argument("--head", default="HEAD", help="ref de tete (defaut HEAD)") + parser.add_argument("--body-file", help="fichier contenant le body de la PR") + parser.add_argument("--body", help="body de la PR en ligne") + parser.add_argument("--index", type=Path, default=DEFAULT_INDEX) + parser.add_argument("--check", action="store_true", + help="exit 1 si collision non declaree") + parser.add_argument("--cwd", type=Path, default=None) + args = parser.parse_args(argv) + + if not args.patch and not args.base: + args.base = "origin/main" + + owners, symbol_owners = load_index(args.index) + + if args.patch: + patch_text = Path(args.patch).read_text(encoding="utf-8", errors="replace") + else: + patch_text = git_diff(args.base, args.head, args.cwd or Path.cwd()) + + body = args.body if args.body is not None else ( + Path(args.body_file).read_text(encoding="utf-8", errors="replace") + if args.body_file else "" + ) + declared = is_declared(body) + + collisions = parse_patch(patch_text, owners, symbol_owners) + + undeclared = [] + for file, file_series, owner, sym in collisions: + origin = file_series if file_series else "?" + if declared: + print(f"EXEMPTED {origin} ({file}) -> {owner} ({sym}) [copie declaree]") + else: + print(f"COLLISION {origin} ({file}) -> {owner} ({sym})") + undeclared.append((file, owner, sym)) + + status = "EXEMPTED" if declared and collisions else ( + "BLOCKED" if undeclared else "CLEAN" + ) + print(f"{status} {len(collisions)} collision(s), {len(undeclared)} non declaree(s), " + f"{len(owners)} series dans l'index") + + if args.check and undeclared: + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/audit/organ_api_index.yaml b/scripts/audit/organ_api_index.yaml new file mode 100644 index 0000000000..f5173b7fea --- /dev/null +++ b/scripts/audit/organ_api_index.yaml @@ -0,0 +1,189 @@ +version: 1 +# Index des API publiques caracteristiques des organes de series (#16776, fille de #13564). +# +# Regle servie : .claude/rules/organ-first-implementation.md (merge #16778) -- +# avant de reimplementer une semantique qu'une autre serie possede deja, la PR +# doit nommer l'organe (5 questions) ou declarer une copie pedagogique. +# +# Source des symboles : MESURE sur l'arbre au cycle 41 (def/class publics des +# .py des packages + cellules code des .ipynb par serie). Aucun symbole de +# memoire. Les exemples illustratifs de la spec d'origine (`grounded_extension`, +# `sparql_update`, `assert_expr`, `plan`...) ont ete mesures a 0 hit sur l'arbre +# -- ils ne sont PAS dans l'index. +# +# Curation : symboles GENERIQUES exclus volontairement (solve, install, main, +# Search, Marginal, download, benchmark, plot_dist, memoize...) : une collision +# ne compte que sur un nom distinctif de l'organe. Une re-curation doit +# re-mesurer (grep def/class par serie) avant d'ajouter un symbole. + +series: + Planners: + owner_path: MyIA.AI.Notebooks/SymbolicAI/Planners + organ: "moteur STRIPS + recherche classique planification (notebooks, lake planning_lean)" + symbols: + - Operateur + - ops_de_base + - ops_avec_radeau + - prop + - successeurs + - successeurs_relaxes + - h_max + - distances_relaxees + - astar_hmax + - iddfs + - reconstruire + - Action + - GridState + - GridWorld + - build_state_graph + - run_fast_downward_docker + - pull_fast_downward + - start_fd_container + - estimate_states + # Mesure : 37 notebooks, 66 symboles .ipynb (cellules code) + 0 .py. + # Controle positif Greffe2 : Operateur/ops_de_base/ops_avec_radeau/prop/ + # successeurs copies dans ICT-Greffe2 (#13802, declare). + + ArgumentAnalysis: + owner_path: MyIA.AI.Notebooks/SymbolicAI/Argument_Analysis + organ: "pont Tweety/JVM (argumentation_lib : AF, ADF, modal, FOL, dialogue)" + symbols: + - AFHandler + - ADFHandler + - TweetyBridge + - TweetyInitializer + - ModalHandler + - FOLHandler + - PLHandler + - ProbabilisticHandler + - BeliefRevisionHandler + - DialogueHandler + - RankingHandler + - RhetoricalAnalysisState + - TaxonomySophismDetector + - AnalysisRunner + - initialize_jvm + - is_jvm_started + - shutdown_jvm + # Mesure : argumentation_lib, 60 symboles publics .py. + + CooperativeGames: + owner_path: MyIA.AI.Notebooks/GameTheory/cooperative_games + organ: "jeux cooperatifs -- Shapley, coeur, vote pondere" + symbols: + - VotingGame + - WeightedVotingGame + - ShapleyCalculator + - CoalitionGame + - AssistanceGame + - compute_core + - is_in_core + - core_constraints + - core_vertices_2d + - core_vertices_3d + - bondareva_shapley_condition + - marginal_contribution + - majority_game + # Mesure : cooperative_games, 44 symboles publics .py. + + TrustSimulation: + owner_path: MyIA.AI.Notebooks/GameTheory/trust_simulation + organ: "dilemme du prisonnier itere + dynamique de replicateurs (Axelrod)" + symbols: + - TitForTat + - TitForTwoTats + - Grudger + - Copykitten + - Detective + - Pavlov + - AlwaysCooperate + - AlwaysDefect + - Tournament + - TournamentResults + - MatchResult + - play_match + - play_round + - replicator_dynamics + - run_axelrod_tournament + - ecological_simulation + # Mesure : trust_simulation, 24 symboles publics .py. + + Sudoku: + owner_path: MyIA.AI.Notebooks/Sudoku + organ: "solveurs Sudoku (backtracking, MRV, annealing, graph coloring) + dataset ML" + symbols: + - SudokuGrid + - BacktrackingSolver + - MRVBacktrackingSolver + - SimulatedAnnealingSolver + - SimpleBacktracking + - SudokuDataset + - SmallCNNModel + - SmallDenseModel + - count_solutions + - load_puzzles + - puzzle_to_grid + - solve_graph_coloring + - find_hidden_pairs + - generate_sudoku_data + - train_one_epoch + # Mesure : 15 symboles publics .py (hors Test*) + 68 notebooks, 225 symboles + # .ipynb (top: load_puzzles, SudokuGrid, count_solutions, BacktrackingSolver...). + + Probas: + owner_path: MyIA.AI.Notebooks/Probas + organ: "Infer.NET/PyMC -- RSA pragmatique, VaR/CVaR, causalite DoWhy" + symbols: + - HashingMarginal + - literal_listener + - pragmatic_listener + - speaker + - meaning + - state_prior + - utterance_prior + - compute_var + - compute_cvar + - analyse_portfolio + - bornes_rosenbaum + - biais_iv_vs_ols + - ResultatRR + - ResultatEValue + - DowhyBackdoorResult + - DowhyIvResult + # Mesure : 187 symboles publics .py (hors Test*) + 63 notebooks, 120 symboles + # .ipynb (top: HashingMarginal, literal_listener, pragmatic_listener...). + # Controle negatif : ICT-12e (consommateur Infer.NET+PyMC, 0 def/class) = 0 collision. + + SMT: + owner_path: MyIA.AI.Notebooks/SymbolicAI/SMT + organ: "solveur Z3 -- pipelines de contraintes (taches, crypto, sudoku-SMT)" + symbols: + - compter_solutions + - trouver_triplet_pythagoricien + - ordonnancer_taches + - allocation_optimale + - resoudre_sudoku + - resoudre_sudoku_anti_diagonale + - pipeline_tactiques + - trouver_clef_xor + - reverse_bits_8 + - verifier_commutativite_store + - generer_mot_de_passe_valide + - compter_indices + # Mesure : 43 notebooks, 50 symboles .ipynb (top: compter_solutions, + # trouver_triplet_pythagoricien, ordonnancer_taches...). Les 5 symboles .py + # (download*, main) sont generiques : exclus. + + SemanticWeb: + owner_path: MyIA.AI.Notebooks/SymbolicAI/SemanticWeb + organ: "RDF/SPARQL + GraphRAG (reification, sous-graphes, requetes LLM)" + symbols: + - reify + - to_uri_id + - extract_subgraph + - graphrag_query + - extract_entities_llm + - safe_llm_call + - add_score_claim + - slugify + # Mesure : 36 notebooks, 19 symboles .ipynb. diff --git a/scripts/tests/test_detect_organ_duplication.py b/scripts/tests/test_detect_organ_duplication.py new file mode 100644 index 0000000000..94c50971e7 --- /dev/null +++ b/scripts/tests/test_detect_organ_duplication.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Tests hermetiques du detecteur de duplication d'organe (#16776, fille #13564). + +Controles d'acceptance encodes : +- Positif retroactif Greffe2 : la forme du patch reel de #13802 (fichier IIT + ajoutant les 5 symboles STRIPS de Planners) produit 5 COLLISION sans body + (exit 1 en --check) et 5 EXEMPTED avec le body declaratif (« copie fidele »). +- Negatif : un consommateur pur (ICT-12e, 0 def/class ; PR reelle #13664 = + CLEAN verifie firsthand) n'importe pas de collision. + +Executable deux fois facons : + py scripts/tests/test_detect_organ_duplication.py + npx pytest scripts/tests/test_detect_organ_duplication.py +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +DETECTOR = REPO / "scripts" / "audit" / "detect_organ_duplication.py" +INDEX = REPO / "scripts" / "audit" / "organ_api_index.yaml" + + +def _load_detector(): + spec = importlib.util.spec_from_file_location("detect_organ_duplication", DETECTOR) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def _write(tmp_path: Path, name: str, text: str) -> Path: + p = tmp_path / name + p.write_text(text, encoding="utf-8", newline="\n") + return p + + +GREFFE2_PATCH = """diff --git a/MyIA.AI.Notebooks/IIT/ICT-Series/ICT-Greffe2-EspaceAtteignable.ipynb b/MyIA.AI.Notebooks/IIT/ICT-Series/ICT-Greffe2-EspaceAtteignable.ipynb +new file mode 100644 +--- /dev/null ++++ b/MyIA.AI.Notebooks/IIT/ICT-Series/ICT-Greffe2-EspaceAtteignable.ipynb +@@ -0,0 +1,9 @@ ++{ ++ "cells": [ ++ { ++ "cell_type": "code", ++ "source": [ ++ "class Operateur:\\n", ++ "def ops_de_base():\\n", ++ "def ops_avec_radeau():\\n", ++ "def prop(etat):\\n", ++ "def successeurs(etat, ops):\\n" ++ ] ++ } ++ ] ++} +""" + +BODY_DECLARED_CANONIQUE = ( + "## Copie\n\nCopie pédagogique déclarée, motif : montrer comment marche " + "le moteur STRIPS de Planners-5c en 30 lignes lisibles." +) +BODY_DECLARED_FIDELE = ( + "Interdit respecté : le moteur STRIPS est la **copie fidèle** de " + "l'instrument de mesure de Planners-5c." +) +BODY_UNRELATED = "Ajout d'un notebook ICT sur l'atteignabilité. See #13568." + + +def test_index_loads_measured_series(): + mod = _load_detector() + owners, symbol_owners = mod.load_index(INDEX) + assert len(owners) >= 6, "acceptance: >= 6 series mesurees" + for sym in ("Operateur", "ops_de_base", "ops_avec_radeau", "prop", "successeurs"): + assert symbol_owners[sym] == "Planners" + + +def test_greffe2_flagged_without_body(tmp_path, capsys): + """Controle positif retroactif : sans declaration, les 5 symboles STRIPS collisionnent.""" + mod = _load_detector() + patch = _write(tmp_path, "greffe2.diff", GREFFE2_PATCH) + rc = mod.main(["--patch", str(patch), "--index", str(INDEX), "--check"]) + out = capsys.readouterr().out + assert rc == 1 + assert out.count("COLLISION") == 5 + for sym in ("Operateur", "ops_de_base", "ops_avec_radeau", "prop", "successeurs"): + assert f"({sym})" in out + assert "-> Planners" in out + assert "~IIT" in out, "label de serie demandeuse hors index" + assert "BLOCKED" in out + + +def test_greffe2_whitened_by_canonical_declaration(tmp_path, capsys): + mod = _load_detector() + patch = _write(tmp_path, "greffe2.diff", GREFFE2_PATCH) + body = _write(tmp_path, "body.md", BODY_DECLARED_CANONIQUE) + rc = mod.main(["--patch", str(patch), "--body-file", str(body), + "--index", str(INDEX), "--check"]) + out = capsys.readouterr().out + assert rc == 0 + assert out.count("EXEMPTED") == 6 # 5 lignes + le resume + assert "non declaree(s), 0" in out or "0 non declaree" in out + + +def test_greffe2_whitened_by_retroactive_fidele(tmp_path, capsys): + """Le body reel de #13802 (« copie fidèle ») blanchit aussi.""" + mod = _load_detector() + patch = _write(tmp_path, "greffe2.diff", GREFFE2_PATCH) + body = _write(tmp_path, "body.md", BODY_DECLARED_FIDELE) + rc = mod.main(["--patch", str(patch), "--body-file", str(body), + "--index", str(INDEX), "--check"]) + assert rc == 0 + assert "EXEMPTED" in capsys.readouterr().out + + +def test_unrelated_body_still_blocks(tmp_path, capsys): + mod = _load_detector() + patch = _write(tmp_path, "greffe2.diff", GREFFE2_PATCH) + body = _write(tmp_path, "body.md", BODY_UNRELATED) + rc = mod.main(["--patch", str(patch), "--body-file", str(body), + "--index", str(INDEX), "--check"]) + assert rc == 1 + + +def test_negative_consumer_no_collision(tmp_path, capsys): + """Controle negatif : un consommateur (ICT-12e, PR #13664) ajoute des + definitions qui ne collisionnent avec aucun organe de l'index.""" + mod = _load_detector() + patch = _write(tmp_path, "consumer.diff", """diff --git a/MyIA.AI.Notebooks/IIT/ICT-Series/ICT-12e-Value-of-Information-Animat.ipynb b/MyIA.AI.Notebooks/IIT/ICT-Series/ICT-12e-Value-of-Information-Animat.ipynb +--- a/MyIA.AI.Notebooks/IIT/ICT-Series/ICT-12e-Value-of-Information-Animat.ipynb ++++ b/MyIA.AI.Notebooks/IIT/ICT-Series/ICT-12e-Value-of-Information-Animat.ipynb +@@ -1,3 +1,5 @@ ++import pymc as pm ++import numpy as np ++def animat_policy(evpi, evsi): ++ return evsi / evpi +""") + rc = mod.main(["--patch", str(patch), "--index", str(INDEX), "--check"]) + out = capsys.readouterr().out + assert rc == 0 + assert "CLEAN" in out and "0 collision" in out + + +def test_owner_series_does_not_collide_with_itself(tmp_path, capsys): + """La serie proprietaire qui enrichit son propre organe ne collisionne pas.""" + mod = _load_detector() + patch = _write(tmp_path, "owner.diff", """diff --git a/MyIA.AI.Notebooks/SymbolicAI/Planners/02-Classical/planners_core.py b/MyIA.AI.Notebooks/SymbolicAI/Planners/02-Classical/planners_core.py +--- a/MyIA.AI.Notebooks/SymbolicAI/Planners/02-Classical/planners_core.py ++++ b/MyIA.AI.Notebooks/SymbolicAI/Planners/02-Classical/planners_core.py +@@ -1,2 +1,4 @@ ++def successeurs(etat, ops): ++ pass ++class Operateur: ++ pass +""") + rc = mod.main(["--patch", str(patch), "--index", str(INDEX), "--check"]) + out = capsys.readouterr().out + assert rc == 0 + assert "CLEAN" in out and "0 collision" in out + + +def test_generic_symbol_outside_index_is_ignored(tmp_path, capsys): + """Un nom generique absent de l'index (curation) ne compte pas.""" + mod = _load_detector() + patch = _write(tmp_path, "generic.diff", """diff --git a/MyIA.AI.Notebooks/GenAI/Texte/essai.py b/MyIA.AI.Notebooks/GenAI/Texte/essai.py +--- a/MyIA.AI.Notebooks/GenAI/Texte/essai.py ++++ b/MyIA.AI.Notebooks/GenAI/Texte/essai.py +@@ -1,1 +1,2 @@ ++def solve(problem): ++ pass +""") + rc = mod.main(["--patch", str(patch), "--index", str(INDEX), "--check"]) + out = capsys.readouterr().out + assert rc == 0 + assert "CLEAN" in out + + +def test_py_file_and_notebook_json_both_scanned(tmp_path, capsys): + """Une reimplementation .py (pas notebook) est aussi attrapee.""" + mod = _load_detector() + patch = _write(tmp_path, "py.diff", """diff --git a/MyIA.AI.Notebooks/GameTheory/local_strips.py b/MyIA.AI.Notebooks/GameTheory/local_strips.py +--- a/MyIA.AI.Notebooks/GameTheory/local_strips.py ++++ b/MyIA.AI.Notebooks/GameTheory/local_strips.py +@@ -0,0 +1,2 @@ ++def h_max(etat, buts): ++ pass +""") + rc = mod.main(["--patch", str(patch), "--index", str(INDEX), "--check"]) + out = capsys.readouterr().out + assert rc == 1 + assert "COLLISION ~GameTheory" in out and "(h_max)" in out + + +def test_accent_tolerant_declaration_forms(): + mod = _load_detector() + assert mod.is_declared("copie pédagogique déclarée, motif : X") + assert mod.is_declared("copie pedagogique declaree") + assert mod.is_declared("la copie fidèle de Planners-5c") + assert mod.is_declared("copie fidele") + assert not mod.is_declared("on a copié le style") + assert not mod.is_declared("") + + +if __name__ == "__main__": + import pytest + sys.exit(pytest.main([__file__, "-v"])) From ad5fcb260f841167d50fcd8324aa69fbde9b158d Mon Sep 17 00:00:00 2001 From: jsboige Date: Mon, 21 Sep 2026 03:02:03 +0200 Subject: [PATCH 2/2] fix(ci,#16776): allowlist organ-duplication-advisory.yml in self-hosted policy Repair du rouge Scripts Tests (CPU) signale par Hermes (CONCERNS 19/09) : la PR routait le job sur runs-on [self-hosted, coursia-ephemeral, coursia-linux] (routage #14283 tranche 3) sans ajouter l'entree allowlist du garde fail-closed -- Violation(WORKFLOW_NOT_ALLOWED) sur test_current_repository_self_hosted_jobs_satisfy_isolation_policy. Verifie localement : pytest -k isolation -> 1 passed avec le workflow present dans l'arbre de la PR. Co-Authored-By: Claude Sonnet 5 --- scripts/ci/check_self_hosted_runner_policy.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/scripts/ci/check_self_hosted_runner_policy.py b/scripts/ci/check_self_hosted_runner_policy.py index 6de4cd33b5..cf8b21c14a 100644 --- a/scripts/ci/check_self_hosted_runner_policy.py +++ b/scripts/ci/check_self_hosted_runner_policy.py @@ -451,6 +451,18 @@ # peut etre rejoue sans frais). Pas de label pose. Rollback = revert # de la PR (l'entree disparait de l'allowlist). "notebook-kernel-drift-guard.yml", + # #16776 (owner myia-po-2023:CoursIA, PR #16801) : organ-duplication + # advisory -- signale les symboles ajoutes qui collident avec l'organ + # API d'une autre serie (scripts/audit/organ_api_index.yaml, regle + # organ-first). Pure-Python stdlib, garde same-repo au niveau job + # (#13874) -- fork PRs skipped, pull_request filtre par paths en + # auto-couverture des fichiers de l'organe (#8822), workflow_dispatch + # pour re-run manuel. runs-on STATIQUE jambe Linux containerisee + # (routage #14283 tranche 3). Advisory non-bloquant (sticky comment). + # Entree ajoutee par le repair du rouge WORKFLOW_NOT_ALLOWED signale + # par Hermes (CONCERNS 19/09) : la PR routait le job self-hosted sans + # toucher cette allowlist fail-closed. Rollback = revert de la PR. + "organ-duplication-advisory.yml", } GITHUB_HOSTED_LABELS = { "ubuntu-latest",