From 2435f0f6d3f6e15bf8d169ceacd5408c9ad4c603 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 2 Sep 2026 07:16:23 -0400 Subject: [PATCH] =?UTF-8?q?[ADD]=20script=20todo=20:=20/todo=5Fgenerate=5F?= =?UTF-8?q?code,=20effort=20high=20et=20r=C3=A8gles=20du=20d=C3=A9p=C3=B4t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les règles à appliquer avant de coder sont éparpillées entre les configs, les hooks et les modules maison, et la documentation en contredit plusieurs : autopep8 est recommandé alors que le script sort en 1, aucun script oca-* n'est installé, la racine n'a ni .pre-commit-config.yaml ni .pylintrc, et flake8 comme pylint-odoo ne vivent que dans le venv Odoo, que rien ne lance. Le gabarit énonce ce que l'outillage impose, à effort high, l'épinglage ultracode restant celui de l'utilisateur. Vérifié : 146 règles relevées, 142 confirmées contre leur citation, 4 retirées. Deux gardes lient chaque entrée du menu à un gabarit présent qui déclare le bon nom. --- EN --- The rules to apply before coding are scattered across the configs, the hooks and the in-house modules, and the documentation contradicts several of them: autopep8 is recommended although the script exits 1, no oca-* script is installed, the root carries neither .pre-commit-config.yaml nor .pylintrc, and flake8 as well as pylint-odoo live only in the Odoo venv, which nothing invokes. The template states what the tooling enforces, at high effort, the ultracode pin remaining the user's own. Checked: 146 rules surveyed, 142 confirmed against their citation, 4 dropped. Two guards tie each menu entry to a template that exists and declares the right name. Assisted-by: Claude Opus 5 --- ...late_claude_commands_todo_generate_code.md | 228 ++++++++++++++++++ script/todo/todo.py | 14 ++ script/todo/todo_i18n.py | 6 + test/test_todo.py | 69 ++++++ 4 files changed, 317 insertions(+) create mode 100644 conf/template_claude_commands_todo_generate_code.md diff --git a/conf/template_claude_commands_todo_generate_code.md b/conf/template_claude_commands_todo_generate_code.md new file mode 100644 index 0000000..de236e9 --- /dev/null +++ b/conf/template_claude_commands_todo_generate_code.md @@ -0,0 +1,228 @@ +--- +name: todo_generate_code +description: "Write code in ERPLibre by the rules the repository actually enforces: Odoo/OCA module conventions first, then the real format and verify toolchain." +disable-model-invocation: true +effort: high +allowed-tools: + - Read + - Edit + - Write + - Grep + - Glob + - Bash(make format:*) + - Bash(make test_unit:*) + - Bash(make test_unit_file:*) + - Bash(./script/maintenance/:*) + - Bash(python3:*) + - Bash(git status:*) + - Bash(git diff:*) + - Bash(ls:*) + - Bash(cat:*) +--- + +## Context + +- Odoo series in this checkout: !`cat .odoo-version` +- Available venvs: !`ls -d .venv.* 2>/dev/null` +- Branch and pending work: !`git status --porcelain` +- Addons trees: !`ls -d odoo*/addons/*/ 2>/dev/null | head -12` + +## Task + +Write code in this repository by the rules it ENFORCES, which are not always +the rules it documents. Establish them first, then write. The sections below +were read off the configuration files and the in-house modules, not off the +prose; where a document disagrees with a tool, the tool wins and the +disagreement is named. + +### The effort tier + +The `effort: high` above applies to this invocation — "comprehensive +implementation with extensive testing", the tier for code that has to survive +review. + +A session can be pinned above it. `/effort ultracode` sets xhigh AND turns on +dynamic workflow orchestration, and a system-reminder then asks for a workflow +on every substantive task. That standing opt-in does not apply here: writing +one module correctly is one agent's job, and fanning it out multiplies both the +token cost and the ways the pieces disagree. + +So when a reminder says ultracode is on, say in one line that this command +works at high, and ask the user to type `/effort high` — that one command sets +the tier and clears the ultracode flag in the same move. A command's +frontmatter cannot release a session pin; only the user's own `/effort` can, +from an interactive terminal. Work solo either way. + +### 1. Before writing a line + +**Find the tree.** New modules go under `odoo/addons/_//` +— the version-prefixed tree is the real addons root, and the generator +rewrites any `addons/` it is handed into `odoo/addons/` +(`script/code_generator/new_project.py:144-146`). The version is in +`.odoo-version`; the venv carries BOTH versions in its name, so find it with +`ls -d .venv.odoo*` rather than composing it from memory. + +**Read the neighbours.** Two or three in-house modules under +`odoo/addons/ERPLibre_erplibre_addons/` show the conventions in force +better than any list. Copy their shape. + +**Bootstrap rather than hand-roll.** `script/code_generator/new_project.py -d + -m ` creates a module; `create_from_existing_module.py` +clones one. A module cloned from an existing one INHERITS its comments and +docstrings — reread them before committing, a client or database name travels +that way on its own. + +**Two rules bind every line you write**, and no tool checks either: +- Nothing identifying outside `private/` — no customer or third-party + organisation, no real database, host or VM name, no IP, e-mail or path + carrying an account name. Generalise to the class of situation instead. +- A comment says how the CODE works, in the present. A sentence whose subject + is an incident, a machine, a date or a person belongs in `tasks/`, which is + not versioned. + +**Never edit a generated file.** `FICHIER.md` and `FICHIER.fr.md` come from +`FICHIER.base.md` through mmg; an edit is lost at the next `make doc_markdown`. + +### 2. Odoo module conventions + +`__manifest__.py` carries at minimum `name`, `version`, `author`, `license`, +`category`, `summary`, `depends`, `data`, `installable`. `version` is +`.1.0.0` — `18.0.1.0.0` on an 18.0 checkout — and `license` is +`AGPL-3`, which every in-house module uses. + +The `data` list is ordered `security/`, then `data/`, then `wizards/`, then +`views/`, with `views/menu.xml` last. + +Non-Odoo Python requirements go in `external_dependencies: {"python": [...]}`, +and the import is guarded in the model with `try/except ImportError` logging at +debug level — a missing optional dependency must not break the registry. + +Layout: `models/`, `views/`, `security/`, `wizards/`, `data/`, `controllers/`, +`i18n/`, `report/`, `tests/`, `static/description/`. Ship +`static/description/icon.png`. + +Naming, one for one: +- `models/.py`, one model per file — + model `devops.workspace` lives in `models/devops_workspace.py`. +- `views/.xml` for a Model; a TransientModel's Python AND its XML + both live in `wizards/`. +- `ir.ui.view` ids: `_view_` (`_view_form`, + `_view_tree`, `_view_search`, `_view_kanban`). +- `ir.actions.act_window` ids: `__action_window`; + server actions: `__server_action`. +- `res.groups` go in `security/.xml` — named after the module, not + `security.xml` — and that file is listed BEFORE `ir.model.access.csv`. + +`security/ir.model.access.csv` carries exactly the header +`id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink`. +Split the rows by privilege level — a read-only row for the broad group, a +full-CRUD row for the administrative one — rather than one blanket row. + +Every Python file opens with the AGPL licence comment; hand-written models and +hooks also carry the shebang and copyright lines. A package `__init__.py` +starts with `# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)`, +a blank line, then imports. The root `__init__.py` imports subpackages on one +line (`from . import models, wizards`) and a hook by name +(`from .hooks import post_init_hook`); hooks live in a top-level `hooks.py` and +are declared under the matching manifest key. + +Models declare `_name`, `_description` and, when mixins are used, `_inherit`; +the class name is the CamelCase of the model. Odoo symbols come in one grouped +import — `from odoo import _, api, exceptions, fields, models`. + +Translations go in `i18n/` as `.pot` plus one `.po` per locale. + +### 3. Format — the toolchain that exists + +`make format` before committing. It formats only what git reports as +modified, added, renamed or untracked, dispatching each file by extension — +so it is cheap and safe to run repeatedly. `make format_all` sweeps whole +areas instead. + +What it runs underneath, and what to match when writing by hand: + +| Kind | Tool and settings | +|------|-------------------| +| Python | `isort --profile black -l 79`, then `black -l 79 --preview -t py37` | +| XML | prettier + `@prettier/plugin-xml`, tab-width 4, print-width 120 | +| XML under `data/` | same, print-width 999999999 — long data strings stay on one line | +| js, css, scss, html | prettier, tab-width 4, print-width 120, no bracket spacing | +| Shell | `shfmt -i 2 -ci -w` | + +black and isort run from `.venv.erplibre`, prettier from the repo-pinned +`./node_modules/.bin` — a globally installed prettier is a different version +and reformats differently. Nothing under `not_supported_files/` is formatted. + +**What NOT to run**, each verified absent from this checkout rather than +assumed: +- `./script/maintenance/autopep8.sh` — `oca-autopep8` is not installed; the + script exits 1. `doc/DEVELOPMENT.base.md` still recommends it; the document + is stale. +- Any `oca-*` console script (`oca-gen-addon-readme`, `oca-towncrier`, …) — + `script/OCA_maintainer-tools` is checked out but never pip-installed, and its + install block in `install_locally_dev.sh` is commented out. Run one as a + module from that directory if you truly need it. +- `pre-commit run` at the repository root — there is no root + `.pre-commit-config.yaml`. ERPLibre's own hooks are hand-written Python, + installed once per clone with + `git config core.hooksPath script/git/hooks`. + +**A vendored OCA addon** under `odoo/addons/OCA_*` carries its own +`.pre-commit-config.yaml`: run THAT from its directory instead of the ERPLibre +format scripts, so the patch matches what upstream will accept. + +### 4. Lint + +Neither linter runs on its own — no target, no hook, no CI invokes them, so +running them is a deliberate act: + +- flake8 lives in the Odoo venv, not `.venv.erplibre`. The root `.flake8` + applies: max-line-length 80, max-complexity 16, `select = C,E,F,W,B,B9`, + ignoring E203, E501 and W503 for black compatibility. +- pylint-odoo also lives in the Odoo venv: + `.venv.odoo<...>/bin/pylint --load-plugins=pylint_odoo --rcfile= `. + The `--rcfile` is not optional — the repository root ships no `.pylintrc`, + and the ones found under vendored trees belong to those projects. + +### 5. Verify + +Verification is entirely local: `.github/` holds no workflow, so nothing +catches a mistake after the fact. + +**Repository scripts** — `make test_unit` is the fast gate: no PostgreSQL, no +Odoo, no VM, a few seconds. While iterating on one file, `make test_unit_file +F=test/test_.py`. + +A new file in `test/` must declare at least one `test_*` function and end with +`if __name__ == "__main__": unittest.main()` as the LAST top-level statement — +the suite has its own test that enforces both, since the runner selects files +by the glob `test/test_*.py`. A test that creates a real machine, installs a +system or runs for hours goes in `long_test/` instead, and undoes itself with +`--detruire`. + +**An Odoo module** — drop the database, then run the module's tests: + +```bash +./odoo_bin.sh db --drop --database test_ +./test.sh -d test_ --db-filter test_ -i +``` + +`./test.sh` is `./run.sh` with `ODOO_MODE_TEST=true --workers 0`, which adds +`--test-enable --no-http --stop-after-init`. For coverage, bracket that with +`./.venv.erplibre/bin/coverage erase` before and a combine/report after, and +set `ODOO_MODE_COVERAGE=true` — coverage is switched on through the +environment, never a CLI flag. `make open_test_coverage` opens the report. + +To run ONE test file inside a module, install the module first, then pass +`--test-file=`. + +### 6. Then, and only then, commit + +`make format`, the tests above, then `/commit` — which resolves the model, +writes the `[TYPE] portée : sujet` subject under 72 characters, the bilingual +body under ten lines per language, and the `Assisted-by:` trailer. The +`commit-msg` hook REFUSES a message that breaks the mechanical part of that; +the `pre-commit` hook only reports comments worth rereading and never blocks. + +Stage by naming files. `git add -A` sweeps in `private/` and `tasks/`, which +are untracked on purpose. diff --git a/script/todo/todo.py b/script/todo/todo.py index db73f8a..512e509 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -3068,6 +3068,12 @@ class TODO( " command" ) }, + { + "prompt_description": t( + "Todo Generate Code - Code by the OCA rules at high" + " effort" + ) + }, {"prompt_description": t("Show installed custom commands")}, ] help_info = self.fill_help_info(choices) @@ -3101,6 +3107,11 @@ class TODO( "template_claude_commands_todo_add_command.md", ) elif status == "4": + self._setup_claude_command( + "todo_generate_code", + "template_claude_commands_todo_generate_code.md", + ) + elif status == "5": self._list_claude_commands() else: print(t("Command not found !")) @@ -3232,6 +3243,9 @@ class TODO( "template_claude_commands_git_prepare_merge.md" ), "todo_add_command": "template_claude_commands_todo_add_command.md", + "todo_generate_code": ( + "template_claude_commands_todo_generate_code.md" + ), "todo_plan_max": "template_claude_commands_todo_plan_max.md", } for nom, gabarit in sorted(gabarits.items()): diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index ffd6013..8325d41 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -1187,6 +1187,12 @@ TRANSLATIONS = { ), "en": "Todo Add Command + Plan Max - Plan and add a todo.py command", }, + "Todo Generate Code - Code by the OCA rules at high effort": { + "fr": ( + "Todo Generate Code - Coder selon les règles OCA, effort élevé" + ), + "en": "Todo Generate Code - Code by the OCA rules at high effort", + }, "Enter your full name: ": { "fr": "Entrez votre nom complet : ", "en": "Enter your full name: ", diff --git a/test/test_todo.py b/test/test_todo.py index 96e6c7c..9c9534e 100644 --- a/test/test_todo.py +++ b/test/test_todo.py @@ -485,6 +485,75 @@ class TestSetupClaudeCommit(unittest.TestCase): mock_makedirs.assert_called_once() +class TestClaudeCommandTemplates(unittest.TestCase): + """Chaque commande proposée par le menu doit avoir son gabarit. + + Un nom de gabarit fautif ne se voit qu'à l'exécution, au moment où le + déploiement échoue devant l'utilisateur : rien ne relie le littéral passé + à `_setup_claude_command` au fichier de `conf/`. + """ + + @staticmethod + def _deployed_templates(): + """Les gabarits nommés dans les appels à `_setup_claude_command`.""" + import ast + + source = Path("script/todo/todo.py").read_text(encoding="utf-8") + found = [] + for node in ast.walk(ast.parse(source)): + if not isinstance(node, ast.Call): + continue + attr = getattr(node.func, "attr", None) + if attr != "_setup_claude_command": + continue + # (nom_de_commande, nom_de_gabarit) : les deux sont des littéraux, + # sans quoi le test ne peut rien affirmer. + args = [ + a.value + for a in node.args + if isinstance(a, ast.Constant) and isinstance(a.value, str) + ] + if len(args) >= 2: + found.append(args[1]) + return found + + def test_every_menu_template_exists(self): + templates = self._deployed_templates() + self.assertGreaterEqual(len(templates), 4, templates) + for name in templates: + with self.subTest(template=name): + self.assertTrue( + os.path.isfile(os.path.join("conf", name)), + f"conf/{name} est nommé par le menu et n'existe pas", + ) + + def test_every_template_declares_its_own_name(self): + """Le `name:` du frontmatter donne le nom de la commande `/…` ; un + gabarit qui en déclare un autre déploie un fichier dont le contenu + parle d'une commande différente.""" + source = Path("script/todo/todo.py").read_text(encoding="utf-8") + import ast + + pairs = [] + for node in ast.walk(ast.parse(source)): + if not isinstance(node, ast.Call): + continue + if getattr(node.func, "attr", None) != "_setup_claude_command": + continue + args = [ + a.value + for a in node.args + if isinstance(a, ast.Constant) and isinstance(a.value, str) + ] + if len(args) >= 2: + pairs.append((args[0], args[1])) + self.assertTrue(pairs) + for command, template in pairs: + with self.subTest(command=command): + text = Path("conf", template).read_text(encoding="utf-8") + self.assertIn(f"name: {command}\n", text) + + class TestClaudePlugins(unittest.TestCase): """Le menu des plugins Claude Code.