Darstellung

Contao Summit 2026

Ein Workflow, der die Arbeit übernimmt

Tool-assisted Refactoring in Contao und Symfony
Sebastian Zoglowek

Über mich

  • Contao Core Entwickler
  • Digitale Dinge GmbH
  • The Dark-Mode guy

github.com/zoglo

Kapitel 0 · Ausgangslage

contao-kiss (digitaledinge/contao-kiss)

  • Framework bei Digitale Dinge GmbH, Basis für alle Websites
  • Twig Globals, Value objects (enums), virtuelle Felder (>=5.7)
  • KI-assistierte PRs: viel zusätzliche Arbeit im Review

PRs und Review-Schleifen

  • Änderungen im PR, die mit Bugfix oder Feature nichts zu tun hatten
  • .editorconfig in VS Code nicht berücksichtigt: 4, 3 oder 2 Leerzeichen
  • Twig-Vorgaben vergessen, unter anderem |raw
  • Im Review immer wieder: „Mach es doch wie in den anderen Templates. Schau dir das doch ab.“
  • Changes requested

Neuer Versuch

  • SKILL.md mit unseren Vorgaben.
  • Die KI erkannte nun vorhandenen Code.
  • Änderte Code für das Feature gleich mit, ganz ohne Auftrag.
  • CSS-Klassen und Enums geändert: Migrationen wären nötig gewesen.
  • Changes requested

Letztes Jahr

Contao, wie geht es heute?

Meldet Fehler Fixed Fehler
PHP-Stil PHP_CodeSniffer PHP-CS-Fixer, ECS
Logik PHPStan, Psalm Rector
Twig-Stil Twig-CS-Fixer: meldet und fixt

Dieses Jahr

Tool-assisted Refactoring in Contao und Symfony

  • Easy Coding Standard
  • Rector
  • Twig-CS-Fixer
  • PHPStan

Das kann man doch verbinden, oder?

Agenda

  1. ECS und Rector
  2. Eine eigene Rector-Regel
  3. Twig-CS-Fixer
  1. PHPStan und GitHub Actions
  2. KI: Feedback und Projektkontext
  3. Tools miteinbeziehen

Drei Arten von Feedback

Mechanisch

Eine bekannte Änderung automatisch ausführen.

ECS · Rector · Twig-CS-Fixer

Überprüfbar

Eine mögliche Fehlerstelle im Code finden.

PHPStan · Twig-CS-Fixer

Kontextuell

Verstehen, was im Projekt gewollt ist.

Mensch · AGENTS.md · Skills

Kapitel 1 · Stil und Zielversion

1. Problem: wechselnder Coding Style

  • Die .editorconfig wurde nicht angewendet; Einrückungen wechselten.
  • ECS setzt den PHP-Stil automatisch. Es läuft vor dem Commit oder als Check im Pull Request.
  • Basis: PSR-12 plus Projektregeln. Der Diff zeigt wieder die inhaltliche Änderung.

          composer ecs
          

https://www.php-fig.org/psr/psr-12/

Wie sieht PSR-12 aus?


          class Foo{
            function bar($a,$b){
              if($a){return $b;}
            }
          }
            

          class Foo
          {
              public function bar($a, $b)
              {
                  if ($a) {
                      return $b;
                  }
              }
          }
            

Links ohne Standard, rechts mit.

2. Problem: Code für die falsche Version

  • KI-Code trifft nicht immer den Zielstand von Contao, Symfony oder PHP.
  • Rector modernisiert bekannte Muster mit festgelegten Regeln.

Rector - Abstract Syntax Tree

Quelltext


          $GLOBALS['TL_DCA']['tl_foo']['config']['dataContainer'] = 'Table';
              

← AST →


          Assign                          (der ganze Ausdruck: =)
          ├── var: ArrayDimFetch          ['dataContainer']
          │   └── var: ArrayDimFetch      ['config']
          │       └── var: ArrayDimFetch  ['tl_foo']
          │           └── var: ArrayDimFetch  ['TL_DCA']
          │               └── var: Variable   $GLOBALS
          └── expr: String_               'Table'
              

Rector-Regeln gibt es schon

  • PHP und Symfony: Regeln passend zu den Projekt-Abhängigkeiten
  • Contao: contao/contao-rector bis zum gewünschten Zielstand
  • getrector.com/find-rule

Kapitel 2 · Eigene Rector-Regel

Wenn ein Rector-Set nicht reicht

Comic: Buni nimmt eine glückliche Blume mit nach Hause, zu Hause stehen vertrocknete Pflanzen

© 2018 Ryan Pagelow, bunicomic.com/comic/buni-1154

Warum 20 Minuten Regex-Replace, wenn ich 30–40 Stunden in eine Rector-Regel stecken kann?
  • Einmal geschrieben → für alle Projekte und alle Leute
  • Heute kann z. B. eine KI die Regel schreiben. Der Refactor mit Rector bleibt deterministisch.

Input und Regel

Input: eine DCA, zwei Schreibweisen


          $GLOBALS['TL_DCA']['tl_foo']['fields']['bar']['flag'] = 13;

          $GLOBALS['TL_DCA']['tl_foo'] = [
              'fields' => ['bar' => ['flag' => 13]],
          ];
          

Die Regel: ein Pfad mit Wildcards, der alte Wert, der neue Wert


              new ReplaceNestedArrayItemValue(
                  'TL_DCA.*.fields.*.flag',
                  13,
                  new ClassConstFetch(new FullyQualified(DataContainer::class), 'SORT_INITIAL_LETTER_BOTH'),
              ),
              

* passt auf jede Tabelle und jedes Feld: TL_DCA.*.fields.*.flag trifft tl_content genauso wie tl_news.

Ergebnis

Dieselbe Datei nach dem Lauf


          $GLOBALS['TL_DCA']['tl_foo']['fields']['bar']['flag'] = \Contao\DataContainer::SORT_INITIAL_LETTER_BOTH;

          $GLOBALS['TL_DCA']['tl_foo'] = [
              'fields' => ['bar' => ['flag' => \Contao\DataContainer::SORT_INITIAL_LETTER_BOTH]],
          ];
          

Beide Schreibweisen, eine Regel. Kein Regex.

Die Rector-Regel ist eine Klasse

Gekürzter Ausschnitt


          final class ReplaceNestedArrayItemRector extends AbstractRector
          {
              public function getNodeTypes(): array
              {
                  return [Assign::class];
              }

              public function refactor(Node $node): ?Node
              {
                  if (!$node instanceof Assign) {
                      return null;
                  }

                  // Verarbeitung auf der nächsten Folie
              }
          }
          

getNodeTypes() wählt Assign. instanceof prüft den Typ in refactor().

Regel suchen: https://getrector.com/documentation/custom-rule

Der gemeinsame Punkt: Assign


          public function refactor(Node $node): ?Node
          {
              if (!$node instanceof Assign)
              {
                  return null;
              }

              if ($node->var instanceof ArrayDimFetch)
              {
                  $arrParentKeyPath = $this->findParentKeys($node->var);
                  $childTraversal = $this->createChildTraversalPath($node->expr);

                  foreach ($this->configuration as $configuration)
                  {
                      $targetPath = explode('.', $configuration->getTargetPath());

                      $childrenKeyPaths = $this->matchPaths($targetPath, $arrParentKeyPath, $childTraversal);

                      if ([] !== $childrenKeyPaths)
                      {
                          $oldValue = $configuration->getOldValue();
                          $newValue = $configuration->getNewValue();

                          foreach ($childrenKeyPaths as $childrenKeyPath)
                          {
                              $this->replaceTargetNodeValue($node, $childrenKeyPath, $configuration, $oldValue, $newValue);
                          }
                      }
                  }
              }

              return null;
          }
          
  1. instanceof Assign: nur Zuweisungen interessieren, alles andere → null (nichts ändern)
  2. Links vom = ein Array-Zugriff? Dann Pfad links (findParentKeys) und Baum rechts bestimmen
  3. Für jede konfigurierte Regel: Pfad mit Wildcards abgleichen (matchPaths)
  4. Treffer → alten Wert gegen den neuen tauschen (replaceTargetNodeValue)

Vor 0.17 kein Problem, danach: rector-017-brings-more-robust-and-lighter-node-tree

Links vom =: findParentKeys()


          private function findParentKeys(ArrayDimFetch $arrayDimFetch): array
          {
              $keys = [];

              while ($arrayDimFetch instanceof ArrayDimFetch)
              {
                  if ($arrayDimFetch->dim instanceof String_)
                  {
                      $keys[] = $arrayDimFetch->dim->value;
                  }

                  $arrayDimFetch = $arrayDimFetch->var;
              }

              return array_reverse($keys);
          }
          
  1. Schleife: solange der Ausdruck ein Array-Zugriff ist, den String-Schlüssel merken und eine Ebene nach innen gehen
  2. Reihenfolge drehen: von außen nach innen wird innen nach außen

$GLOBALS['TL_DCA']['tl_foo']['fields'] → ['TL_DCA', 'tl_foo', 'fields']

Kapitel 3 · Twig-Vorgaben

3. Problem: Twig-Vorgaben gehen unter

  • Im Framework nutzen wir es konsequent für Templates.
  • Die ähnliche Syntax von Jinja2, der Template-Engine für Python, liefert oft unpassende Muster.
  • Der einfache Ausweg bei HTML: |raw. Damit fällt das Escaping weg.

Unsere Regeln im Twig-CS-Fixer

Melden oder korrigieren, bevor der nächste Review-Kommentar entsteht.

VariableNameRule → Variablennamen in snake_case
HtmlAttributesVariableNameRule → attrs() heißt immer …_attributes
ForbiddenRawFilterRule → kein |raw (XSS), Ausnahmen nur für Contao Legacy
ForbiddenBlockRule → kein {% embed %} (Ausnahmen konfigurierbar)
ComponentIncludeRule → Komponenten mit {% use %} statt include() mit Variablen
SetUnderscoreRule → {% do … %} statt {% set _ = … %}
ImportSelfRule → _self.macro() statt {% import _self %}
AttrsHookMergeRule → Attribute ergänzen, nicht ersetzen
AddClassConditionRule → card- ohne Wert gibt es nicht: Klasse nur mit Bedingung

Jede Regel ist ein Review-Kommentar, den man nie wieder schreiben muss.

So arbeitet Twig-CS-Fixer

CS = Coding Standard, wie bei ECS

Datenstruktur Eigenschaft
Rector AST: Baum aus Knoten Leerzeichen und Formatierung weg
Twig-CS-Fixer Tokens: flache Liste jedes Zeichen bleibt, auch Leerzeichen

Linter und Fixer zugleich.

Ein Template als Tokens

{{ data.text|raw }}, vereinfacht


          {{          Ausgabe beginnt
          data.text   Ausdruck
          |           Filter beginnt
          raw         FILTER_NAME_TYPE
          }}          Ausgabe endet
          
  1. Eine Ausgabe beginnt
  2. data.text wird ausgegeben
  3. Nach | steht der Filtername raw
  4. Die Ausgabe endet

Gleiches Prinzip, andere Schnittstelle

Rector getNodeTypes() wählt Knoten; refactor() verarbeitet sie
Twig-CS-Fixer process() prüft Tokens; addWarning() meldet |raw

Beides sind Regeln als Klassen. Die konkrete Twig-Regel folgt jetzt.

SetUnderscoreRule: erkennen

  1. Ist der Token ein set?
  2. Ist das nächste Token (ohne Leerzeichen) die Variable _?
  3. Folgt danach ein =?

          protected function process(int $tokenIndex, Tokens $tokens): void
          {
              $token = $tokens->get($tokenIndex);

              if (!$token->isMatching(Token::BLOCK_NAME_TYPE, 'set')) {
                  return;
              }

              $nameIndex = $tokens->findNext(Token::INDENT_TOKENS, $tokenIndex + 1, exclude: true);

              if (false === $nameIndex || !$tokens->get($nameIndex)->isMatching(Token::NAME_TYPE, '_')) {
                  return;
              }

              $equalIndex = $tokens->findNext(Token::INDENT_TOKENS, $nameIndex + 1, exclude: true);

              if (false === $equalIndex || !$tokens->get($equalIndex)->isMatching(Token::OPERATOR_TYPE, '=')) {
                  return;
              }
          }
          

SetUnderscoreRule: melden und fixen

Gekürzt, der Rest der Methode steht auf der Folie davor.


          $fixer = $this->addFixableError('Use "{% do … %}" instead of "{% set _ = … %}".', $token);

          if (null === $fixer) {
              return;
          }

          $valueIndex = $tokens->findNext(Token::INDENT_TOKENS, $equalIndex + 1, exclude: true);

          $fixer->beginChangeSet();
          $fixer->replaceToken($tokenIndex, 'do');

          for ($i = $nameIndex; $i < $valueIndex; ++$i) {
              $fixer->replaceToken($i, '');
          }

          $fixer->endChangeSet();
          

Getestet mit Fixtures


          {% set _ = figure.media.attributes.set('poster', fallback_poster) %}
          {%- set _ = a.b() -%}
          {% set _, x = [1, 2] %}
          {% set _ %}captured{% endset %}
          {% set foo = bar.baz() %}
          

          $this->checkRule(new SetUnderscoreRule(), [
              'SetUnderscore.Error:1:4' => 'Use "{% do … %}" …',
              'SetUnderscore.Error:2:5' => 'Use "{% do … %}" …',
          ]);
          

Zeilen 1–2 müssen anschlagen. Zeilen 3–5 nicht: die Negativfälle sind der halbe Test.

ImportSelfRule: nur melden

AbstractRule statt AbstractFixableRule: addWarning(), kein Fixer.


          protected function process(int $tokenIndex, Tokens $tokens): void
          {
              $token = $tokens->get($tokenIndex);

              if (!$token->isMatching(Token::BLOCK_NAME_TYPE, ['import', 'from'])) {
                  return;
              }

              $sourceIndex = $tokens->findNext(Token::INDENT_TOKENS, $tokenIndex + 1, exclude: true);

              if (false === $sourceIndex || !$tokens->get($sourceIndex)->isMatching(Token::NAME_TYPE, '_self')) {
                  return;
              }

              $this->addWarning(
                  \sprintf('Macros of the current template are auto-imported. Remove "{%% %s _self … %%}" and call "_self.macro()" directly.', $token->getValue()),
                  $token,
              );
          }
          

|raw: Beispiel und Regel

Im Template


          {{ data.text|raw }}
              

|raw schaltet das Escaping ab. Steht <script> im Feld, läuft es im Browser.

ForbiddenRawFilterRule, gekürzt


          protected function process(int $tokenIndex, Tokens $tokens): void
          {
              $token = $tokens->get($tokenIndex);
              if (!$token->isMatching(Token::FILTER_NAME_TYPE, 'raw')) {
                  return;
              }

              // Konfigurierte Ausnahmen werden vorher geprüft.
              $this->addWarning('You should not use the |raw filter. …', $token);
          }
              

|raw ist verboten


          {{ data.text|insert_tag }}
          {{ data.html|insert_tag_raw }}
          
  • Editor-Text → |insert_tag
  • HTML-Felder → |insert_tag_raw (Contao 5.7, ab Contao 6: |insert_tag_html)

AttrsHookMergeRule


  {% set card_attributes = attrs().addClass('card') %}
          

AttrsHookMergeRule


  {% set card_attributes = attrs(card_attributes|default).addClass('card') %}
          

Das eingehende card_attributes wurde verschluckt. Variable und Namen behalten.

AttrsHookMergeRule: im Ausdruck suchen

Gekürzt. Vorher geprüft: {% set x = attrs(, und nicht in for oder macro.


          $name = $tokens->get($nameIndex)->getValue();
          $endIndex = $tokens->findNext(Token::BLOCK_END_TYPE, $valueIndex);

          for ($i = $valueIndex + 1; $i < $endIndex; ++$i) {
              if ($tokens->get($i)->isMatching(Token::NAME_TYPE, $name)
                  && !$tokens->get($i - 1)->isMatching(Token::OPERATOR_TYPE, ['.', '?.'])
              ) {
                  return;
              }
          }

          $this->addWarning(
              \sprintf('"%1$s" replaces the hook; use "attrs(%1$s|default)" or ".mergeWith(%1$s|default)".', $name),
              $tokens->get($nameIndex),
          );
          

Kapitel 4 · Überprüfbar

Ist der Code auch korrekt?

Mechanisch

ECS · Rector · Twig-CS-Fixer

Überprüfbar

Eine mögliche Fehlerstelle im Code finden.

PHPStan · Twig-CS-Fixer

Kontextuell

Mensch · AGENTS.md · Skills

PHPStan · Praxisbeispiel

DELETE-Import

Gekürzter Code


          $objFoobar = FoobarModel::findOneBy($arrColumns, $arrValues);

          if ($preventDelete)
          {
              continue;
          }

          $this->deleteFoobarImages($objFoobar, $objCux);
          $objFoobar->delete();

          // In deleteFoobarImages():
          $deleteFolder = $this->objFilesFolder->path . '/'
              . $objCux->quux . '/'
              . $objFoobar->{$this->bar->baz};
          Files::getInstance()->rrdir($deleteFolder);
          

Der Fix

Nur löschen, wenn der Datensatz existiert


          if (null !== $objFoobar) {
              $this->deleteFoobarImages($objFoobar, $objCux);
              $objFoobar->delete();
          }

          // Zusätzlich in deleteFoobarImages():
          if (null === $objFoobar || null === $objCux) {
              return;
          }
          

PHPStan: je höher, desto genauer

Die Level bauen aufeinander auf. Beispiele, stark vereinfacht:

0 UnknownClass::run() → unbekannte Klasse
5 greet(42) → erwartet wird string
6 function greet($name) → Parametertyp fehlt
8 findUser()->getName() → findUser() kann null liefern
10 Auch ungetyptes mixed wird gemeldet

Quelle: phpstan.org/user-guide/rule-levels

GitHub Actions im Pull Request

Die Prüfungen laufen automatisch bei jedem PR.

  1. ECS
  2. Rector
  3. PHPStan
  4. Twig-CS-Fixer
  5. Biome · Stylelint

CI ✘ failing

Der Check meldet den Verstoß; der Code wird lokal korrigiert.

Was, wenn die KI den Fehler sieht?

CI ✘ failing CI ✔ passing

          GitHub Actions · Pull Request
           ✔ ECS    ✔ Rector    ✔ PHPStan
           ✘ Twig-CS-Fixer   You should not use the |raw filter. …
          

🤖 „Dann schalte ich die Regel ab …“ ⛔ Darf er nicht.

🤖 „Dann schreibe ich die Regel neu!“ ⛔ Darf er nicht. 😢

🤖 „Na gut, dann passe ich den Code an.“ Im Template: |raw → |insert_tag


          GitHub Actions · Pull Request
           ✔ ECS    ✔ Rector    ✔ PHPStan    ✔ Twig-CS-Fixer
          

Grün. Weiter.

Kapitel 5 · KI und Kontext

Determinismus

Gleiche Eingabe, immer gleiches Ergebnis.

Deterministisch

Das Ergebnis ist durch die Eingabe eindeutig festgelegt.


add(2, 3); // 5
add(2, 3); // 5
add(2, 3); // 5
              
Indeterministisch

Gleiche Eingabe, Ergebnis kann variieren.


random_int(1, 6); // 4
random_int(1, 6); // 1
random_int(1, 6); // 6
              

Ein LLM ist nicht deterministisch

„Googlemaps add permissions for maps“, viermal


// Lauf 1: PaletteManipulator
$GLOBALS['TL_PERMISSIONS'][] = 'dlh_googlemapss';
$GLOBALS['TL_PERMISSIONS'][] = 'dlh_googlemapsp';

PaletteManipulator::create()
    ->addLegend('dlh_googlemaps_legend', 'forms_legend', …)
    ->addField(['dlh_googlemapss', 'dlh_googlemapsp'], …)
    ->applyToPalette('extend', 'tl_user');

$GLOBALS['TL_DCA']['tl_user']['fields']['dlh_googlemapss'] = [
    'inputType' => 'checkbox', 'foreignKey' => 'tl_dlh_googlemaps.title', …
];
            

// Lauf 2: nur registrieren
// Maps the user can access
$GLOBALS['TL_PERMISSIONS'][] = 'dlh_googlemapss';

// Map operations: create, delete
$GLOBALS['TL_PERMISSIONS'][] = 'dlh_googlemapsp';
            

// Lauf 3: str_replace + array()
$GLOBALS['TL_PERMISSIONS'][] = 'dlh_googlemapss';
$GLOBALS['TL_PERMISSIONS'][] = 'dlh_googlemapsp';

$GLOBALS['TL_DCA']['tl_user']['palettes']['extend'] = str_replace('formp;',
    'formp;{dlh_googlemaps_legend},dlh_googlemapss,dlh_googlemapsp;', …);

foreach (array('tl_user', 'tl_user_group') as $table) {
    $GLOBALS['TL_DCA'][$table]['fields']['dlh_googlemapss'] = array(…);
}
            

// Lauf 4: Doku als return-Array
return [
    'googlemaps' => [
        'dlh_googlemapss' => ['description' => 'Allowed maps', …],
        'dlh_googlemapsp' => ['options' => ['create', 'delete'], …],
        'checks' => [
            'create' => 'requires dlh_googlemapsp: create',
            'delete' => 'map ID in dlh_googlemapss + …: delete',
        ],
    ],
];
            

Warum?

  • Für jedes nächste Token berechnet das Modell Wahrscheinlichkeiten.
  • Temperature steuert die Auswahl: höher = mehr Varianten

Bei Temperature 0 = immer* gleich.

Sheldon Cooper in einer Endlosschleife

Projektkontext

Was keine Prüfung entscheiden kann

Mechanisch

ECS · Rector · Twig-CS-Fixer

Überprüfbar

PHPStan · Twig-CS-Fixer

Kontextuell

Verstehen, was im Projekt gewollt ist.

Mensch · AGENTS.md · Skills

Architektur? Was darf sich ändern?

Ohne Projektkontext: drei Anläufe

„Hi, ich brauche Sicherheit für meine Extension.“

  • → Contao-4-Permissions, weil es noch keine gab. Hier hören viele auf.
  • „Nein, Contao 5 bitte.“ → Voter, aber immer noch $user->hasAccess()
  • „Auch Contao 6.“ → erst jetzt der AccessDecisionManager

Antwort 1: Contao 4


$GLOBALS['TL_PERMISSIONS'][] = 'dlh_googlemapss';
$GLOBALS['TL_PERMISSIONS'][] = 'dlh_googlemapsp';

$GLOBALS['TL_DCA']['tl_dlh_googlemaps']['config']['onload_callback'][] =
    ['tl_dlh_googlemaps', 'checkPermission'];

public function checkPermission(): void
{
    if ($this->User->isAdmin) {
        return;
    }

    if (!$this->User->hasAccess('create', 'dlh_googlemapsp')) { … }
    if (!\in_array(Input::get('id'), $this->User->dlh_googlemapss)) { … }
}
          

Antwort 2: „Nein, Contao 5 bitte“


class MapAccessVoter extends AbstractDataContainerVoter
{
    protected function hasAccess(TokenInterface $token, … $action): bool
    {
        $user = $token->getUser();

        if (!$user instanceof BackendUser) {
            return false;
        }

        if ($user->isAdmin) {
            return true;
        }

        return match (true) {
            $action instanceof CreateAction => $user->hasAccess('create', 'dlh_googlemapsp'),
            $action instanceof DeleteAction => $user->hasAccess('delete', 'dlh_googlemapsp') && …,
            default => \in_array((int) $mapId, (array) $user->dlh_googlemapss, true),
        };
    }
}
          

Antwort 3: „Auch Contao 6“


class MapAccessVoter extends AbstractDataContainerVoter
{
    public function __construct(
        private readonly AccessDecisionManagerInterface $accessDecisionManager,
    ) {}

    protected function hasAccess(TokenInterface $token, … $action): bool
    {
        if (!$this->accessDecisionManager->decide($token, [ContaoCorePermissions::USER_CAN_ACCESS_MODULE], 'dlh_googlemaps')) {
            return false;
        }

        return match (true) {
            $action instanceof CreateAction => $this->accessDecisionManager->decide($token, ['contao_user.dlh_googlemapsp'], 'create'),
            $action instanceof DeleteAction => $this->accessDecisionManager->decide($token, ['contao_user.dlh_googlemapsp'], 'delete') && …,
            default => $this->accessDecisionManager->decide($token, ['contao_user.dlh_googlemapss'], (int) $mapId),
        };
    }
}
          

Was sollten AGENTS.md und eine SKILL.md klären?

  • Was gibt es im Framework bereits?
  • Welche bestehenden Werte dürfen verändert werden?
  • Welche Projektbefehle und Grenzen gelten?

MoSCoW vor dem ersten Edit

Aufgabe: „Bau mir eine Card.“

Must

wird gebaut

Card-Komponente mit Titel, Text, Link

Should

gehört zum Must

Style-Optionen der Card

Could

nur vorschlagen

Hover-Animation, weitere Varianten

Won't

benennen, nicht bauen

bestehende Grid-Komponente umbauen

Erst Bestand prüfen: „Wo gibt es das schon?“ Dann fragen statt annehmen.

Skills

  • description ist der Auslösemechanismus. Leitfaden von Anthropic zu Skills [1]
  • Weitere .md: kompakt halten, längere (100+ Zeilen) Referenzen mit Inhaltsverzeichnis in /references/
  • MUST / NEVER / ASK als Grenzen (kein Muss, aber es hilft), jeweils mit einem Satz warum
  • Konkrete Pfade und Befehle statt Prinzipien
  • Negative Beispiele erklären Grenzen. Toolchain prüft konkrete Verstöße.
  • Was sich automatisieren lässt → in Skripte: spart Tokens, Zeit und Wasser

Mehr dazu gleich: der eigene Lauf.

[1] platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices

Learnings: echte Vorfälle → Regeln

  • btn-wide entfernt. Enum und Test wurden im selben Schritt geändert, die Warnung kam danach.
  • data.backgroundColor bei einer „Verbesserung“ gelöscht
  • {% embed %} pro Listeneintrag, Kommentare, die den Code nacherzählen

Negative Beispiele erklären die Grenze. Checks prüfen sie.

Die wichtigste Regel in Contao-KISS

Der Agent fasst CI und Tests nicht an.

  • Rote Checks sind keine Erlaubnis. „Mach die Tests grün“ auch nicht.
  • Nur mit exakt YES, HERE IS PERMISSION TO DO SO
  • Die Ausnahmeliste für |raw heißt SHAME_ON_YOU 😉
Fix the code, never the check.

aus dem Skill kiss-framework-extend


          ABSOLUTELY NO EDIT to any of these without explicit
          written permission IN THE CURRENT TASK.

          A failing lint or a failing test is NOT permission.
          "Make the lint pass", "make the tests green" or
          "fix CI" is NOT permission.

          Fix the code, never the check.
              

aus .twig-cs-fixer.php


          // Templates that bypass the |raw ban
          const SHAME_ON_YOU = [];

          $ruleset->addRule(new ForbiddenRawFilterRule(ignore: array_merge(
              ['mod_breadcrumb.html.twig', 'mod_newslist.html.twig', 'be_tinyMCE.html.twig'],
              SHAME_ON_YOU,
          )));
              
Ein Prompt ist eine Bitte.
Ein Tool ist eine Regel.

Kapitel 6

Tools miteinbeziehen

  • Die Prüflogik läuft lokal und braucht keine Modell-Tokens.
  • Die KI bekommt nur das Feedback der Checks und korrigiert den Code.
  • Docs und Examples werden Leitplanken
  • Migrationen: nur auf Anfrage, mit bestätigtem Mapping

Dasselbe Prinzip

Skill contao-extension-modernize auf eine alte Contao-Extension

  • dlh_googlemaps ist nun Contao 6 ready
  • 47 Tool-Aufrufe (Anrufe), 16 Minuten, 95k Tokens Ausgabe, geschätzt ca. 5 $ (Opus 5.5 - mittel)

Was sich wiederholt, wird ein Skript.

Kann ich der KI jetzt vertrauen?

Nein.

Aber der Code wird besser geliefert und wir haben weniger Feedback-Schleifen, weil die Toolchain mit prüft.

Modelle werden klüger. Deterministisch werden sie nie.

„Program testing can be used to show the presence of bugs, but never to show their absence!“

Edsger W. Dijkstra, „Notes on Structured Programming“, 1970

Wir garantieren nicht, dass der Code fehlerfrei ist.

Aber jede Regel, die wir kennen, wird jedes Mal eingehalten.

Das gilt für KI-Code genauso wie für unseren eigenen.

Kontakt