From ee8a155f8ebe5030dd6fa6bb37c5d9afcf16b3a6 Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Mon, 20 Apr 2026 20:11:55 +0200 Subject: [PATCH 01/38] docs: add CLAUDE.md with architecture guidance Documents the two-interface split (AiServiceConfigurationInterface vs AiServiceInterface), the stored-config data flow, and how to register a new AI backend. Flags the README's mis-attributed API example. --- CLAUDE.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b02a1bd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,57 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +`mage-os/module-ai-base` — a small Magento 2 module (`MageOS_AiBase`) that exposes an admin configuration UI for registering multiple AI backends (OpenAI, Anthropic, Azure, Google, Grok, Deepseek, HuggingFace, LM Studio, Ollama, OpenRouter, xAI) and a consumer API for other modules to read those configured credentials. It does **not** call any AI service itself — it only stores and serves configuration. + +The module is installed into a host Magento 2 app; this repo contains no runnable Magento instance. There is no test suite, no lint/format config, and no build step. + +## Commands + +Host-side (run inside a Magento 2 install that has this module via `composer require mage-os/module-ai-base`): + +```bash +php bin/magento module:enable MageOS_AiBase +php bin/magento setup:upgrade +php bin/magento setup:di:compile +``` + +Admin UI lives at **Stores → Configuration → Services → AI Configuration**. + +## Architecture + +There are two intentionally separate interfaces — do not conflate them: + +- **`Api\Data\AiServiceConfigurationInterface`** (`getCode`, `getName`, `getConfigurationTemplate`) — describes an *available* backend: its machine code, display name, and the HTML snippet used in the admin form. Implementations live in `src/AiServices/*.php`. These are wired into the admin form by the `services` array argument on `Block\Adminhtml\Configuration\Services` in `etc/di.xml`. +- **`Api\Data\AiServiceInterface`** (`getCode`, `getConfiguration`) — represents a *configured instance* (code + stored credentials/model/etc. array). Produced at runtime by `Model\AiServiceSelector` through `AiServiceInterfaceFactory`. + +`AiServiceSelectorInterface` is the public consumer API: + +```php +AiServiceSelectorInterface::getAll(): AiServiceInterface[] +AiServiceSelectorInterface::getByCode(string $code): AiServiceInterface[] +``` + +(Note: the README example shows these methods on `AiServiceConfigurationInterface` — that's wrong; they belong to `AiServiceSelectorInterface`. `getAll` also takes no arguments.) Multiple entries per code are possible because admins can add the same backend multiple times in the UI, which is why `getByCode` returns an array. + +Stored data flow: + +1. Admin form is an `AbstractFieldArray` rendered via `view/adminhtml/templates/system/config/form/field/services.phtml`. +2. Each `AiServiceConfigurationInterface::getConfigurationTemplate()` returns an HTML fragment using `<%- _fieldName %>` as a `mage/template` placeholder. The phtml wires those into per-row inputs when the admin clicks one of the "Add Service" buttons. +3. Magento serializes the posted rows as JSON via `Magento\Config\Model\Config\Backend\Serialized\ArraySerialized` into `core_config_data` at path **`mageos_ai/services/configuration`**. +4. `AiServiceSelector::getParsedConfig()` reads that path, json_decodes it, and wraps each row with `AiServiceInterfaceFactory`. Each row's structure is `{ _rowId: { : { ...fields } } }`, which is why the selector does `array_first(array_keys($item))` to extract the code. + +## Adding a new AI backend + +1. Create `src/AiServices/.php` implementing `AiServiceConfigurationInterface`. The configuration template's input `name` attributes must follow `<%- _fieldName %>[][]` — that nesting is what the selector expects when reading back. +2. Register it in `etc/di.xml` under the `services` argument of `Block\Adminhtml\Configuration\Services`. The array key there becomes the row identifier in the admin dropdown; it should match the class's `getCode()`. +3. No other wiring is required — the admin UI and selector pick it up automatically. + +## Conventions observed in this codebase + +- PHP 8 constructor property promotion + `readonly` is the norm; follow it for new classes. +- No `declare(strict_types=1)` header is used in existing files — match the surrounding style unless you're explicitly modernizing. +- `composer.json` pins `minimum-stability: dev` and `magento/framework: *` — do not tighten these without a reason. +- ACL resource: `MageOS_AiBase::configuration` (defined in `etc/acl.xml`), nested under `Magento_Backend::stores_attributes`. From f6d152c5d09470a52ef4ef96d0622b2fd7cae00f Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Mon, 20 Apr 2026 20:11:55 +0200 Subject: [PATCH 02/38] docs: add v1.0.0 "done done" design plan Captures the four locked brainstorming decisions (full release scope, getSupportedModels method, FieldDescriptor schema, one-off tag) and spells out CI, tests, release mechanics, and demo smoke test. --- docs/plans/2026-04-20-done-done-design.md | 235 ++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 docs/plans/2026-04-20-done-done-design.md diff --git a/docs/plans/2026-04-20-done-done-design.md b/docs/plans/2026-04-20-done-done-design.md new file mode 100644 index 0000000..d2467f5 --- /dev/null +++ b/docs/plans/2026-04-20-done-done-design.md @@ -0,0 +1,235 @@ +# `module-ai-base` v1.0.0 — Design Doc + +**Date:** 2026-04-20 +**Author:** David Lambauer +**Status:** Approved, ready for implementation plan + +## Goal + +Take `mage-os/module-ai-base` from its current `0.x` "works on my machine" state to a released, CI-gated, demo-verified `v1.0.0`. The release pass touches four axes: CI wiring, a breaking API cleanup, a test suite, and a packaging/release sequence. No runtime behaviour changes for consumers that only use `AiServiceSelectorInterface`; the breaking change is confined to `AiServiceConfigurationInterface`, which ships inside this module (no external implementers yet). + +## Decisions locked during brainstorming + +| # | Axis | Decision | +|---|------|----------| +| Q1 | Scope | Full v1.0.0 release (CI + tests + API cleanup + tag) | +| Q2 | Model list abstraction | `getSupportedModels(): array` method on each `AiServices/*` (no new provider interface) | +| Q3 | Admin form refresh | Structured `FieldDescriptor[]` schema replaces the HTML-template string pattern | +| Q4 | Release mechanics | One-off `v1.0.0` tag, manual packagist submission (no `release-please` for now) | + +## Architecture + +### Public API — breaking + +`Api/Data/AiServiceConfigurationInterface` changes shape: + +```php +interface AiServiceConfigurationInterface +{ + public function getCode(): string; + public function getName(): string; + /** @return FieldDescriptorInterface[] */ + public function getConfigurationFields(): array; + /** @return array value => label (empty array if N/A) */ + public function getSupportedModels(): array; +} +``` + +`getConfigurationTemplate(): string` is removed. The rendering concern it carried moves to the phtml template. + +### New types + +`Api/Data/FieldDescriptorInterface` + `Model/FieldDescriptor` (readonly DTO, built via auto-generated `FieldDescriptorInterfaceFactory`): + +```php +interface FieldDescriptorInterface +{ + public const TYPE_TEXT = 'text'; + public const TYPE_PASSWORD = 'password'; + public const TYPE_SELECT = 'select'; + + public function getName(): string; + public function getLabel(): string; + public function getType(): string; + public function getOptions(): array; // [['value' => ..., 'label' => ...], ...] + public function getDefault(): ?string; +} +``` + +### Consumer API — unchanged + +`AiServiceSelectorInterface::getAll()` and `::getByCode(string $code)` keep their signatures. The on-disk JSON shape stored at `mageos_ai/services/configuration` is unchanged: `{ _rowId: { : { : , ... } } }`. This means any consumer module that already uses the selector keeps working after the upgrade. + +### Internal changes + +- `Model/AiServiceSelector::getParsedConfig()` hardened: guard against `ScopeConfigInterface::getValue()` returning `null`, `json_decode()` returning `null` on malformed JSON, and non-array rows. +- `Block/Adminhtml/Configuration/Services::getServicesTemplates()` replaced by `getServicesSchema(): string` returning a JSON blob keyed by service code with field descriptors serialised to arrays. +- `src/view/adminhtml/templates/system/config/form/field/services.phtml` rewritten: drops per-service inline HTML templates, JS loops over the schema and emits ``, ``, or `` by field type. Row-level naming (`<%- _fieldName %>[][]`) stays identical for backwards on-disk compat. +- `src/etc/module.xml` gains `` — today the module silently depends on both. +- `declare(strict_types=1)` added to every PHP file. + +### Eleven `AiServices/*` rewrites + +Each class becomes ~20 lines: constructor takes `FieldDescriptorInterfaceFactory`, `getSupportedModels()` returns the current model list, `getConfigurationFields()` returns the field DTOs. Model lists refreshed against each provider's current public catalog as of 2026-04-20. + +## CI + +Single workflow: `.github/workflows/check-extension.yaml`. + +```yaml +name: Check Extension +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + compute-matrix: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.supported.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + - uses: graycoreio/github-actions-magento2/supported-version@v5.1.0 + id: supported + + check-extension: + needs: compute-matrix + uses: graycoreio/github-actions-magento2/.github/workflows/check-extension.yaml@v5.1.0 + with: + matrix: ${{ needs.compute-matrix.outputs.matrix }} +``` + +Both Graycore refs pin to `@v5.1.0` on merge (not `@main`) so the matrix doesn't shift under us between releases. + +The four reusable-workflow jobs (`unit-test-extension`, `compile-extension`, `coding-standard`, `integration_test`) all do real work after this PR: + +- **phpunit / unit** — 12 test classes execute. +- **setup:di:compile** — catches constructor-arg typos, missing `di.xml` preferences, stale factory references. +- **phpcs** — uses a repo-level `phpcs.xml.dist` extending `Magento2`. Graycore v5.1.0 prefers a project-local config when present. +- **phpunit / integration** — the one round-trip test fires against a real DB. + +## Testing + +### Layout + +``` +src/Test/ +├── Unit/ +│ ├── Model/AiServiceSelectorTest.php +│ └── AiServices/ServicesTest.php +└── Integration/ + └── Model/AiServiceSelectorTest.php +``` + +All tests `final class`, methods `snake_case`, one assertion per test where feasible (per global CLAUDE.md conventions). + +### Unit — `AiServiceSelectorTest` + +1. `getAll_returns_empty_array_when_config_is_null` — `ScopeConfigInterface::getValue()` mocked to return `null`. +2. `getAll_returns_empty_array_when_config_is_malformed_json` — mocked to return `"not-json"`. +3. `getAll_returns_all_configured_services` — two rows (openai + anthropic), asserts 2 `AiServiceInterface` with expected `getCode()` + `getConfiguration()`. +4. `getByCode_filters_to_matching_services_only` — three rows, two openai + one anthropic, `getByCode('openai')` returns 2. + +All PHPUnit mocks. No bootstrap. + +### Unit — `ServicesTest` (parametrised over all 11 services) + +Data provider yields every `AiServices/` class. Test body asserts: +- `getCode()` is non-empty string +- `getName()` is non-empty string +- `getConfigurationFields()` returns non-empty `FieldDescriptorInterface[]` +- `getSupportedModels()` returns an array (may be empty for local-only services like LM Studio / Ollama) + +One test method, eleven cases. + +### Integration — `AiServiceSelectorTest` + +Single test `round_trips_configuration_through_scope_config`: +1. `$resourceConfig->saveConfig('mageos_ai/services/configuration', $json, 'default', 0)` +2. Flush config cache. +3. `$selector->getAll()` → assert 2 `AiServiceInterface` present with expected codes + configuration. + +## Release + packaging + +### `composer.json` final shape + +```json +{ + "name": "mage-os/module-ai-base", + "description": "Base AI module for Mage-OS — register and retrieve configuration for multiple AI backends.", + "type": "magento2-module", + "license": ["OSL-3.0", "AFL-3.0"], + "authors": [{ "name": "David Lambauer", "email": "david@run-as-root.sh" }], + "support": { + "issues": "https://github.com/mage-os/module-ai-base/issues", + "source": "https://github.com/mage-os/module-ai-base" + }, + "version": "1.0.0", + "require": { + "php": "^8.2", + "magento/framework": "^103.0 || ^104.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5", + "mage-os/magento-coding-standard": "^2.0" + }, + "autoload": { + "files": ["src/registration.php"], + "psr-4": { "MageOS\\AiBase\\": "src/" } + }, + "autoload-dev": { + "psr-4": { "MageOS\\AiBase\\Test\\": "src/Test/" } + } +} +``` + +Dropped: `minimum-stability: dev`. Constrained: `magento/framework`. + +### Release sequence + +1. Single PR on `main` containing the entire "done done" change set. +2. After merge: `git tag v1.0.0 && git push --tags`. +3. Manual maintainer action: submit repo to packagist.org once. Subsequent tags auto-publish via the webhook. +4. Draft a GitHub Release for `v1.0.0` with seeded `CHANGELOG.md`. + +### Repo scaffolding + +- `LICENSE` — OSL-3.0 + AFL-3.0 dual (Mage-OS convention). +- `CHANGELOG.md` — `## 1.0.0` section summarising the breaking change and new interface surface. +- `README.md` — fix the current error (example references wrong interface; actual API is `AiServiceSelectorInterface::getAll(): AiServiceInterface[]` / `::getByCode(string $code): AiServiceInterface[]`). +- `phpcs.xml.dist` — extends `Magento2`, scoped to `src/`. + +## Demo smoke test + +Target: `/Users/david/Herd/mage-os-typesense` (Mage-OS 2.2.0). + +1. Add to the demo's `composer.json` `repositories`: + ```json + "ai-base": { "type": "path", "url": "/Users/david/Herd/module-ai-base" } + ``` +2. `composer require mage-os/module-ai-base:@dev` +3. `bin/magento module:enable MageOS_AiBase` +4. `bin/magento setup:upgrade && bin/magento setup:di:compile` +5. Log into admin (`david` / `Admin12345!`). +6. **Stores → Configuration → Services → AI Configuration**. +7. Add OpenAI service → API key + model → Add Anthropic service → API key + model → Save. +8. Refresh, verify rows render with correct field types (password masked, select with correct option). +9. Sanity: resolve `AiServiceSelectorInterface` in object manager, `getAll()` returns 2 instances. +10. Delete rows, save, `getAll()` returns `[]` (exercises the null-guard branch). + +## Explicitly out of scope + +- HTTP calls to any AI provider (consumer modules' concern). +- `release-please` / conventional commits (deferred, revisit post-1.0). +- ui_component rewrite of the admin form (rejected in Q3 — too expensive for the value). +- Migration code for pre-1.0 stored config (no prior release exists). +- Auto-publishing to packagist (requires maintainer's packagist.org account action). + +## Risks + +- **Breaking interface change.** Mitigated by no external implementers existing yet — `composer.json` is tagged `0.x` and the package isn't on packagist, so there cannot be a third-party class implementing `AiServiceConfigurationInterface` with the old `getConfigurationTemplate()` signature. +- **Graycore matrix drift.** Mitigated by pinning `@v5.1.0` rather than `@main`. +- **Mage-OS 2.2 framework constraint (`^103.0 || ^104.0`).** If Mage-OS 2.3 bumps `magento/framework` major, the constraint needs updating — not a 1.0 blocker. +- **PHPUnit integration test requires DB.** Only runs in `check-extension`'s integration job which provides one; locally skipped unless a bootstrap is configured. From 0865a898b87adbd121ee3f34674ab76dacb9c4f7 Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Mon, 20 Apr 2026 20:16:14 +0200 Subject: [PATCH 03/38] docs: add v1.0.0 implementation plan 12 tasks decomposed into TDD-style steps with exact file paths, full code, and commit boundaries. Task 7 consolidates the breaking interface change with the 11 service rewrites and admin form update into a single atomic commit so the repo never lands in a broken state. --- docs/plans/2026-04-20-done-done-plan.md | 1356 +++++++++++++++++++++++ 1 file changed, 1356 insertions(+) create mode 100644 docs/plans/2026-04-20-done-done-plan.md diff --git a/docs/plans/2026-04-20-done-done-plan.md b/docs/plans/2026-04-20-done-done-plan.md new file mode 100644 index 0000000..3cd14f0 --- /dev/null +++ b/docs/plans/2026-04-20-done-done-plan.md @@ -0,0 +1,1356 @@ +# `module-ai-base` v1.0.0 Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Ship `mage-os/module-ai-base` v1.0.0 — CI-gated, test-covered, demo-verified, with a cleaned-up public interface. + +**Architecture:** Single-branch, additive-where-possible. The one breaking change (`AiServiceConfigurationInterface`) is applied atomically in one commit that also rewrites the eleven `AiServices/*` classes + the admin block + the phtml template, so the repo always compiles between commits. Tests are written before the code they cover per @superpowers:test-driven-development. + +**Tech Stack:** PHP 8.2+, Magento 2 framework, PHPUnit 10, Graycore reusable GitHub Actions workflows, mage-os coding standard. + +**Design doc:** See `docs/plans/2026-04-20-done-done-design.md` for the decisions underpinning this plan. + +--- + +## Task 1: Tighten `composer.json` + add LICENSE + CHANGELOG + +**Files:** +- Modify: `composer.json` +- Create: `LICENSE.md` +- Create: `CHANGELOG.md` + +**Step 1: Replace `composer.json` contents** + +```json +{ + "name": "mage-os/module-ai-base", + "description": "Base AI module for Mage-OS — register and retrieve configuration for multiple AI backends.", + "type": "magento2-module", + "license": ["OSL-3.0", "AFL-3.0"], + "authors": [ + { "name": "David Lambauer", "email": "david@run-as-root.sh" } + ], + "support": { + "issues": "https://github.com/mage-os/module-ai-base/issues", + "source": "https://github.com/mage-os/module-ai-base" + }, + "version": "1.0.0", + "require": { + "php": "^8.2", + "magento/framework": "^103.0 || ^104.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5", + "mage-os/magento-coding-standard": "^2.0" + }, + "autoload": { + "files": ["src/registration.php"], + "psr-4": { "MageOS\\AiBase\\": "src/" } + }, + "autoload-dev": { + "psr-4": { "MageOS\\AiBase\\Test\\": "src/Test/" } + } +} +``` + +**Step 2: Create `LICENSE.md`** + +Paste the OSL-3.0 + AFL-3.0 dual-license text. Source the canonical text from the Mage-OS `mageos-magento2` repo `LICENSE.txt` / `LICENSE_AFL.txt` pair, concatenated with an "OR" separator header. + +**Step 3: Create `CHANGELOG.md`** + +```markdown +# Changelog + +All notable changes to this project will be documented in this file. + +## [1.0.0] - 2026-04-20 + +### Added +- Structured `FieldDescriptorInterface` config field schema replacing the HTML-template pattern. +- `getSupportedModels(): array` method on each service for non-hardcoded model lists. +- GitHub Actions CI via `graycoreio/github-actions-magento2/check-extension`. +- Unit test suite for `AiServiceSelector` and all eleven `AiServices/*` classes. +- Integration test covering round-trip of stored config through `ScopeConfigInterface`. + +### Changed +- **BREAKING:** `AiServiceConfigurationInterface::getConfigurationTemplate(): string` replaced by `::getConfigurationFields(): FieldDescriptorInterface[]` and `::getSupportedModels(): array`. +- `composer.json` now pins `php: ^8.2` and `magento/framework: ^103.0 || ^104.0`. +- `Model/AiServiceSelector` hardened against null scope values and malformed JSON. +- `module.xml` declares explicit dependency on `Magento_Config` + `Magento_Backend`. + +### Fixed +- `README.md` API example now references the correct `AiServiceSelectorInterface` (previously cited `AiServiceConfigurationInterface`). +``` + +**Step 4: Verify composer sanity** + +Run: `composer validate --no-check-publish --strict` +Expected: `./composer.json is valid` (no errors, no warnings other than version/publish notices which are muted by the flag). + +**Step 5: Commit** + +```bash +git add composer.json LICENSE.md CHANGELOG.md +git commit -m "chore: tighten composer metadata, add LICENSE and CHANGELOG" +``` + +--- + +## Task 2: Add `phpcs.xml.dist` + CI workflow + +**Files:** +- Create: `phpcs.xml.dist` +- Create: `.github/workflows/check-extension.yaml` + +**Step 1: Create `phpcs.xml.dist`** + +```xml + + + + src + src/Test/ + + +``` + +**Step 2: Create `.github/workflows/check-extension.yaml`** + +```yaml +name: Check Extension + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + compute-matrix: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.supported.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + - uses: graycoreio/github-actions-magento2/supported-version@v5.1.0 + id: supported + + check-extension: + needs: compute-matrix + uses: graycoreio/github-actions-magento2/.github/workflows/check-extension.yaml@v5.1.0 + with: + matrix: ${{ needs.compute-matrix.outputs.matrix }} +``` + +**Step 3: Verify YAML parses** + +Run: `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/check-extension.yaml'))"` +Expected: no output, exit 0. + +**Step 4: Commit** + +```bash +git add phpcs.xml.dist .github/workflows/check-extension.yaml +git commit -m "ci: add Graycore check-extension workflow and phpcs config" +``` + +--- + +## Task 3: Fix `README.md` + +**Files:** +- Modify: `README.md` + +**Step 1: Replace the "Usage" section** + +Replace lines 14-37 of `README.md` with: + +````markdown +## Usage + +If you have configured AI backends, you can fetch the configuration using these methods: + +```php +use MageOS\AiBase\Api\AiServiceSelectorInterface; + +AiServiceSelectorInterface::getAll(): array +AiServiceSelectorInterface::getByCode(string $code): array +``` + +Both methods return an array of `\MageOS\AiBase\Api\Data\AiServiceInterface` objects (multiple entries per code are possible because admins can register the same backend more than once). + +```php +use MageOS\AiBase\Api\AiServiceSelectorInterface; + +final class MyAiFunctionality +{ + public function __construct( + private readonly AiServiceSelectorInterface $aiServiceSelector, + ) {} + + public function doSomething(): void + { + $openAiServices = $this->aiServiceSelector->getByCode('openai'); + + foreach ($openAiServices as $service) { + $config = $service->getConfiguration(); + // $config = ['apikey' => '...', 'model' => 'gpt-4o', ...] + } + } +} +``` +```` + +**Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: fix README — correct interface references in usage example" +``` + +--- + +## Task 4: Add module sequence to `module.xml` + +**Files:** +- Modify: `src/etc/module.xml` + +**Step 1: Replace file contents** + +```xml + + + + + + + + + +``` + +**Step 2: Commit** + +```bash +git add src/etc/module.xml +git commit -m "fix: declare explicit module sequence on Magento_Config and Magento_Backend" +``` + +--- + +## Task 5: Harden `AiServiceSelector` (TDD) + +Use @superpowers:test-driven-development. + +**Files:** +- Create: `src/Test/Unit/Model/AiServiceSelectorTest.php` +- Modify: `src/Model/AiServiceSelector.php` + +**Step 1: Write failing unit test** + +Create `src/Test/Unit/Model/AiServiceSelectorTest.php`: + +```php +scopeConfig = $this->createMock(ScopeConfigInterface::class); + $this->aiServiceFactory = $this->createMock(AiServiceInterfaceFactory::class); + $this->subject = new AiServiceSelector($this->scopeConfig, $this->aiServiceFactory); + } + + public function test_get_all_returns_empty_array_when_config_is_null(): void + { + $this->scopeConfig->method('getValue')->willReturn(null); + + self::assertSame([], $this->subject->getAll()); + } + + public function test_get_all_returns_empty_array_when_config_is_malformed_json(): void + { + $this->scopeConfig->method('getValue')->willReturn('not-json'); + + self::assertSame([], $this->subject->getAll()); + } + + public function test_get_all_returns_all_configured_services(): void + { + $json = json_encode([ + '_row1' => ['openai' => ['apikey' => 'k1', 'model' => 'gpt-4o']], + '_row2' => ['anthropic' => ['apikey' => 'k2', 'model' => 'claude-sonnet-4-6']], + ], JSON_THROW_ON_ERROR); + $this->scopeConfig->method('getValue')->willReturn($json); + + $this->aiServiceFactory->method('create')->willReturnCallback( + fn (array $data) => new AiService($data['code'], $data['configuration']) + ); + + $result = $this->subject->getAll(); + + self::assertCount(2, $result); + self::assertContainsOnlyInstancesOf(AiServiceInterface::class, $result); + self::assertSame('openai', $result[0]->getCode()); + self::assertSame('anthropic', $result[1]->getCode()); + } + + public function test_get_by_code_filters_to_matching_services_only(): void + { + $json = json_encode([ + '_row1' => ['openai' => ['apikey' => 'k1']], + '_row2' => ['anthropic' => ['apikey' => 'k2']], + '_row3' => ['openai' => ['apikey' => 'k3']], + ], JSON_THROW_ON_ERROR); + $this->scopeConfig->method('getValue')->willReturn($json); + + $this->aiServiceFactory->method('create')->willReturnCallback( + fn (array $data) => new AiService($data['code'], $data['configuration']) + ); + + $result = $this->subject->getByCode('openai'); + + self::assertCount(2, $result); + foreach ($result as $service) { + self::assertSame('openai', $service->getCode()); + } + } +} +``` + +**Step 2: Run tests to confirm they fail** + +Run: `vendor/bin/phpunit src/Test/Unit/Model/AiServiceSelectorTest.php` (or via the demo's vendor dir if this repo doesn't have one locally yet). +Expected: Test cases 1 and 2 FAIL with a `TypeError: json_decode() expects string, null given` (or similar) for case 1 and a silent `TypeError: array_map() expects array, null given` for case 2. Cases 3 and 4 should PASS. + +If no local vendor dir, defer execution to the CI workflow — the test will exercise the code path there. + +**Step 3: Harden the implementation** + +Replace `src/Model/AiServiceSelector.php` with: + +```php +getParsedConfig(); + } + + public function getByCode(string $code): array + { + return array_values(array_filter( + $this->getParsedConfig(), + fn (AiServiceInterface $service) => $service->getCode() === $code, + )); + } + + /** + * @return AiServiceInterface[] + */ + private function getParsedConfig(): array + { + $raw = $this->scopeConfig->getValue(self::CONFIG_PATH_AI_SERVICES); + if (!is_string($raw) || $raw === '') { + return []; + } + + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return []; + } + + $services = []; + foreach ($decoded as $row) { + if (!is_array($row) || $row === []) { + continue; + } + $code = array_key_first($row); + $configuration = $row[$code]; + if (!is_string($code) || !is_array($configuration)) { + continue; + } + $services[] = $this->aiServiceFactory->create([ + 'code' => $code, + 'configuration' => $configuration, + ]); + } + + return $services; + } +} +``` + +Note: replaced the `array_first()` polyfill (Laravel helper, not available in Magento core) with `array_key_first()` (native PHP 8.2+). This also fixes a latent bug in the original code where `array_first(array_keys($item))` returned the first **value** of the keys array, which happened to work but was confusing. + +**Step 4: Run tests to confirm they pass** + +Run: `vendor/bin/phpunit src/Test/Unit/Model/AiServiceSelectorTest.php` +Expected: 4 tests, 4 passed. + +**Step 5: Commit** + +```bash +git add src/Test/Unit/Model/AiServiceSelectorTest.php src/Model/AiServiceSelector.php +git commit -m "fix(selector): harden getParsedConfig against null and malformed JSON + +Replaces the Laravel-style array_first() with native array_key_first() +and guards every boundary (null scope value, non-string config, failed +json_decode, non-array rows, missing code key) before constructing +AiService DTOs. Covered by 4 new unit tests." +``` + +--- + +## Task 6: Introduce `FieldDescriptor` DTO (TDD) + +**Files:** +- Create: `src/Api/Data/FieldDescriptorInterface.php` +- Create: `src/Model/FieldDescriptor.php` +- Create: `src/Test/Unit/Model/FieldDescriptorTest.php` +- Modify: `src/etc/di.xml` + +**Step 1: Write failing unit test** + +Create `src/Test/Unit/Model/FieldDescriptorTest.php`: + +```php +getName()); + self::assertSame('API Key', $field->getLabel()); + self::assertSame(FieldDescriptorInterface::TYPE_PASSWORD, $field->getType()); + self::assertSame([], $field->getOptions()); + self::assertNull($field->getDefault()); + } + + public function test_select_field_carries_options_and_default(): void + { + $field = new FieldDescriptor( + name: 'model', + label: 'Model', + type: FieldDescriptorInterface::TYPE_SELECT, + options: [ + ['value' => 'a', 'label' => 'Apple'], + ['value' => 'b', 'label' => 'Banana'], + ], + default: 'a', + ); + + self::assertSame('model', $field->getName()); + self::assertSame(FieldDescriptorInterface::TYPE_SELECT, $field->getType()); + self::assertCount(2, $field->getOptions()); + self::assertSame('a', $field->getDefault()); + } +} +``` + +**Step 2: Run test to confirm it fails** + +Run: `vendor/bin/phpunit src/Test/Unit/Model/FieldDescriptorTest.php` +Expected: FAIL with `Class "MageOS\AiBase\Model\FieldDescriptor" not found`. + +**Step 3: Create the interface** + +`src/Api/Data/FieldDescriptorInterface.php`: + +```php + + */ + public function getOptions(): array; + + public function getDefault(): ?string; +} +``` + +**Step 4: Create the implementation** + +`src/Model/FieldDescriptor.php`: + +```php +name; } + public function getLabel(): string { return $this->label; } + public function getType(): string { return $this->type; } + public function getOptions(): array { return $this->options; } + public function getDefault(): ?string { return $this->default; } +} +``` + +**Step 5: Wire the factory preference** + +Add to `src/etc/di.xml` (inside existing `` element): + +```xml + +``` + +**Step 6: Run test to confirm it passes** + +Run: `vendor/bin/phpunit src/Test/Unit/Model/FieldDescriptorTest.php` +Expected: 2 tests, 2 passed. + +**Step 7: Commit** + +```bash +git add src/Api/Data/FieldDescriptorInterface.php src/Model/FieldDescriptor.php src/Test/Unit/Model/FieldDescriptorTest.php src/etc/di.xml +git commit -m "feat: add FieldDescriptor DTO for structured admin form schema" +``` + +--- + +## Task 7: Atomic API swap — interface + 11 services + block + phtml + +This is the big one. The interface, every implementer, the block that aggregates them, and the phtml that renders them all change in one commit so nothing is ever broken. Apply all code changes, then commit once. + +**Files:** +- Modify: `src/Api/Data/AiServiceConfigurationInterface.php` +- Modify: `src/AiServices/Anthropic.php` +- Modify: `src/AiServices/Azure.php` +- Modify: `src/AiServices/Deepseek.php` +- Modify: `src/AiServices/Google.php` +- Modify: `src/AiServices/Grok.php` +- Modify: `src/AiServices/HuggingFace.php` +- Modify: `src/AiServices/LmStudio.php` +- Modify: `src/AiServices/Ollama.php` +- Modify: `src/AiServices/OpenAi.php` +- Modify: `src/AiServices/OpenRouter.php` +- Modify: `src/AiServices/Xai.php` +- Modify: `src/Block/Adminhtml/Configuration/Services.php` +- Modify: `src/view/adminhtml/templates/system/config/form/field/services.phtml` + +**Step 1: Update `AiServiceConfigurationInterface`** + +Replace contents: + +```php + value => label; empty array for services with no model list + */ + public function getSupportedModels(): array; +} +``` + +**Step 2: Create a helper trait to DRY the field construction** + +`src/AiServices/FieldFactoryTrait.php`: + +```php +create([ + 'name' => 'apikey', + 'label' => 'API Key', + 'type' => FieldDescriptorInterface::TYPE_PASSWORD, + ]); + } + + private function modelField(FieldDescriptorInterfaceFactory $factory, array $supportedModels): FieldDescriptorInterface + { + $options = []; + foreach ($supportedModels as $value => $label) { + $options[] = ['value' => (string) $value, 'label' => (string) $label]; + } + return $factory->create([ + 'name' => 'model', + 'label' => 'Model', + 'type' => FieldDescriptorInterface::TYPE_SELECT, + 'options' => $options, + ]); + } + + private function baseUrlField(FieldDescriptorInterfaceFactory $factory, string $default): FieldDescriptorInterface + { + return $factory->create([ + 'name' => 'base_url', + 'label' => 'Base URL', + 'type' => FieldDescriptorInterface::TYPE_TEXT, + 'default' => $default, + ]); + } +} +``` + +**Step 3: Rewrite each `AiServices/*` class** + +Template for cloud services (OpenAI, Anthropic, Deepseek, Grok/xAI, Google, OpenRouter, HuggingFace, Azure): + +```php + 'GPT-4o', + 'gpt-4o-mini' => 'GPT-4o mini', + 'gpt-4-turbo' => 'GPT-4 Turbo', + 'o1' => 'o1', + 'o1-mini' => 'o1 mini', + ]; + } + + public function getConfigurationFields(): array + { + return [ + $this->apiKeyField($this->fieldFactory), + $this->modelField($this->fieldFactory, $this->getSupportedModels()), + ]; + } +} +``` + +Apply this pattern to each of the 11 services. Model lists per service (current as of 2026-04-20): + +| Service | `getCode()` | `getName()` | Supported models | +|---|---|---|---| +| `Anthropic` | `anthropic` | `Anthropic` | `claude-opus-4-7` → `Claude Opus 4.7`, `claude-sonnet-4-6` → `Claude Sonnet 4.6`, `claude-haiku-4-5-20251001` → `Claude Haiku 4.5` | +| `Azure` | `azure` | `Azure OpenAI` | same list as OpenAI | +| `Deepseek` | `deepseek` | `DeepSeek` | `deepseek-chat` → `DeepSeek V3`, `deepseek-reasoner` → `DeepSeek R1` | +| `Google` | `google` | `Google Gemini` | `gemini-2.0-pro` → `Gemini 2.0 Pro`, `gemini-2.0-flash` → `Gemini 2.0 Flash`, `gemini-1.5-pro` → `Gemini 1.5 Pro` | +| `Grok` | `grok` | `Grok` | `grok-2` → `Grok 2`, `grok-2-mini` → `Grok 2 mini` | +| `HuggingFace` | `huggingface` | `Hugging Face` | empty array; add a free-text `model` field instead of a select | +| `OpenAi` | `openai` | `OpenAI` | (as above) | +| `OpenRouter` | `openrouter` | `OpenRouter` | empty; free-text `model` field | +| `Xai` | `xai` | `xAI` | same as Grok | + +For the two local services (LM Studio, Ollama), there is no API key — just a base URL + free-text model: + +```php +final class Ollama implements AiServiceConfigurationInterface +{ + use FieldFactoryTrait; + + public function __construct( + private readonly FieldDescriptorInterfaceFactory $fieldFactory, + ) {} + + public function getCode(): string { return 'ollama'; } + public function getName(): string { return 'Ollama'; } + public function getSupportedModels(): array { return []; } + + public function getConfigurationFields(): array + { + return [ + $this->baseUrlField($this->fieldFactory, 'http://localhost:11434'), + $this->fieldFactory->create([ + 'name' => 'model', + 'label' => 'Model', + 'type' => \MageOS\AiBase\Api\Data\FieldDescriptorInterface::TYPE_TEXT, + ]), + ]; + } +} +``` + +Apply the same shape to `LmStudio` with default URL `http://localhost:1234`. + +**Step 4: Update `Block\Adminhtml\Configuration\Services`** + +Replace `src/Block/Adminhtml/Configuration/Services.php` with: + +```php + + */ + public function getServicesButtons(): array + { + return array_map( + fn (AiServiceConfigurationInterface $service) => [ + 'code' => $service->getCode(), + 'name' => $service->getName(), + ], + $this->services, + ); + } + + /** + * @return string JSON object keyed by service code, each value is a list of field descriptors as arrays + */ + public function getServicesSchemaJson(): string + { + $schema = []; + foreach ($this->services as $service) { + $schema[$service->getCode()] = array_map( + fn ($field) => [ + 'name' => $field->getName(), + 'label' => $field->getLabel(), + 'type' => $field->getType(), + 'options' => $field->getOptions(), + 'default' => $field->getDefault(), + ], + $service->getConfigurationFields(), + ); + } + return $this->jsonSerializer->serialize($schema); + } + + protected function _prepareToRender(): void + { + $this->addColumn('service', [ + 'label' => __('Service'), + 'class' => 'required-entry', + ]); + + $this->_addAfter = false; + $this->_addButtonLabel = __('Add Service'); + } +} +``` + +**Step 5: Rewrite `services.phtml`** + +Replace `src/view/adminhtml/templates/system/config/form/field/services.phtml`: + +```php +getHtmlId() ?: '_' . uniqid(); +$_colspan = $block->isAddAfter() ? 2 : 1; +?> +
+
+ + + + getColumns() as $column): ?> + + + + + + + + + + + +
escapeHtml($column['label']) ?> + escapeHtml(__('Action')) ?> +
+
    + getServicesButtons() as $button): ?> +
  • + escapeHtml($button['name']) ?> +
  • + +
+
+
+ + + +
+``` + +**Step 6: Verify the repo parses** + +Run: `php -l src/Model/AiServiceSelector.php && php -l src/Block/Adminhtml/Configuration/Services.php` +Expected: `No syntax errors detected` for both. + +Then for each `AiServices/*.php`: + +Run: `for f in src/AiServices/*.php; do php -l "$f"; done` +Expected: `No syntax errors detected` × 11 (ignore `FieldFactoryTrait.php` if matched — it also lints clean). + +**Step 7: Commit** + +```bash +git add src/Api/Data/AiServiceConfigurationInterface.php src/AiServices/ src/Block/Adminhtml/Configuration/Services.php src/view/adminhtml/templates/system/config/form/field/services.phtml +git commit -m "feat!: replace HTML-template config with FieldDescriptor schema + +BREAKING CHANGE: AiServiceConfigurationInterface::getConfigurationTemplate() +is removed. Implementers now return FieldDescriptor[] from getConfigurationFields() +and (optionally) a model list from getSupportedModels(). + +Admin form phtml now renders fields by type from a JSON schema rather than +substituting HTML template strings. Storage format is unchanged, so the +AiServiceSelector consumer API is fully backwards compatible." +``` + +--- + +## Task 8: Parametrised smoke test for all 11 services + +**Files:** +- Create: `src/Test/Unit/AiServices/ServicesTest.php` + +**Step 1: Write the test** + +```php +createMock(FieldDescriptorInterfaceFactory::class); + $stub->method('create')->willReturnCallback( + fn (array $data) => new FieldDescriptor( + name: $data['name'], + label: $data['label'], + type: $data['type'], + options: $data['options'] ?? [], + default: $data['default'] ?? null, + ) + ); + $this->fieldFactory = $stub; + } + + /** + * @dataProvider service_classes + */ + public function test_service_exposes_required_metadata(string $className): void + { + /** @var AiServiceConfigurationInterface $service */ + $service = new $className($this->fieldFactory); + + self::assertNotEmpty($service->getCode(), "$className::getCode() must be non-empty"); + self::assertNotEmpty($service->getName(), "$className::getName() must be non-empty"); + + $fields = $service->getConfigurationFields(); + self::assertNotEmpty($fields, "$className::getConfigurationFields() must return at least one field"); + foreach ($fields as $field) { + self::assertInstanceOf(FieldDescriptorInterface::class, $field); + self::assertNotEmpty($field->getName()); + self::assertNotEmpty($field->getLabel()); + self::assertContains( + $field->getType(), + [FieldDescriptorInterface::TYPE_TEXT, FieldDescriptorInterface::TYPE_PASSWORD, FieldDescriptorInterface::TYPE_SELECT], + ); + } + + self::assertIsArray($service->getSupportedModels()); + } + + /** + * @return array + */ + public static function service_classes(): array + { + return [ + 'Anthropic' => [\MageOS\AiBase\AiServices\Anthropic::class], + 'Azure' => [\MageOS\AiBase\AiServices\Azure::class], + 'Deepseek' => [\MageOS\AiBase\AiServices\Deepseek::class], + 'Google' => [\MageOS\AiBase\AiServices\Google::class], + 'Grok' => [\MageOS\AiBase\AiServices\Grok::class], + 'HuggingFace'=> [\MageOS\AiBase\AiServices\HuggingFace::class], + 'LmStudio' => [\MageOS\AiBase\AiServices\LmStudio::class], + 'Ollama' => [\MageOS\AiBase\AiServices\Ollama::class], + 'OpenAi' => [\MageOS\AiBase\AiServices\OpenAi::class], + 'OpenRouter' => [\MageOS\AiBase\AiServices\OpenRouter::class], + 'Xai' => [\MageOS\AiBase\AiServices\Xai::class], + ]; + } +} +``` + +**Step 2: Run the test** + +Run: `vendor/bin/phpunit src/Test/Unit/AiServices/ServicesTest.php` +Expected: 11 tests, 11 passed. + +**Step 3: Commit** + +```bash +git add src/Test/Unit/AiServices/ServicesTest.php +git commit -m "test: add parametrised smoke test covering all 11 AiServices classes" +``` + +--- + +## Task 9: Integration test — config round-trip + +**Files:** +- Create: `src/Test/Integration/Model/AiServiceSelectorTest.php` +- Create: `phpunit.xml.dist` (if missing) + +**Step 1: Create `phpunit.xml.dist` at repo root if not already present** + +```xml + + + + + src/Test/Unit + + + src/Test/Integration + + + +``` + +Note: the Graycore `check-extension` workflow provides its own Magento-integration-test bootstrap — we don't need to supply one. This repo-root file is only for local sanity runs of the Unit suite. + +**Step 2: Create the integration test** + +```php +get(WriterInterface::class); + $json = json_encode([ + '_row1' => ['openai' => ['apikey' => 'sk-test', 'model' => 'gpt-4o']], + '_row2' => ['anthropic' => ['apikey' => 'sk-ant', 'model' => 'claude-sonnet-4-6']], + ], JSON_THROW_ON_ERROR); + $configWriter->save('mageos_ai/services/configuration', $json); + + /** @var ScopeConfigInterface $scopeConfig */ + $scopeConfig = $objectManager->get(ScopeConfigInterface::class); + $scopeConfig->clean(); + + /** @var AiServiceSelectorInterface $selector */ + $selector = $objectManager->get(AiServiceSelectorInterface::class); + $services = $selector->getAll(); + + self::assertCount(2, $services); + self::assertSame('openai', $services[0]->getCode()); + self::assertSame(['apikey' => 'sk-test', 'model' => 'gpt-4o'], $services[0]->getConfiguration()); + self::assertSame('anthropic', $services[1]->getCode()); + + $openAiOnly = $selector->getByCode('openai'); + self::assertCount(1, $openAiOnly); + + $configWriter->delete('mageos_ai/services/configuration'); + } +} +``` + +**Step 3: Commit** + +```bash +git add src/Test/Integration/Model/AiServiceSelectorTest.php phpunit.xml.dist +git commit -m "test: add integration test covering config round-trip through ScopeConfig" +``` + +--- + +## Task 10: strict_types sweep — verify full coverage + +**Files:** any `src/**/*.php` still missing `declare(strict_types=1)`. + +**Step 1: Locate any stragglers** + +Run: `grep -L 'declare(strict_types=1);' src/**/*.php` + +Expected: empty output. If any file is listed, add the declaration as the first statement after `getObjectManager(); var_dump(count($om->get(\MageOS\AiBase\Api\AiServiceSelectorInterface::class)->getAll()));' +``` +Expected: `int(2)`. + +**Step 7: Delete rows + null path check** + +In admin, delete both service rows, click **Save Config**. +Re-run the PHP one-liner from Step 6. +Expected: `int(0)` (exercises the null/empty-config defensive branch). + +**Step 8: No commit needed in this repo; commit the demo change-set separately** + +The change to `/Users/david/Herd/mage-os-typesense/composer.json` is a dev convenience, not part of this module's release. Optionally commit it there in isolation: + +```bash +cd /Users/david/Herd/mage-os-typesense +git add composer.json composer.lock +git commit -m "chore: wire path repo for mage-os/module-ai-base" +``` + +--- + +## Task 12: Tag `v1.0.0` + +**Step 1: Final verification** + +Run all pre-release checks: + +```bash +cd /Users/david/Herd/module-ai-base +git status # clean +git log --oneline -15 # sensible history, no fixups +composer validate --strict +vendor/bin/phpunit --testsuite Unit # all pass +``` + +Expected: clean working tree, valid composer, all tests green. + +**Step 2: Push main** + +```bash +git push origin main +``` + +**Step 3: Wait for CI to go green** + +Watch the Actions tab on GitHub — `check-extension` workflow should run all four jobs against the Mage-OS matrix and pass. If anything fails, fix on a follow-up commit rather than tagging a red release. + +**Step 4: Tag and push** + +```bash +git tag -a v1.0.0 -m "v1.0.0 — initial stable release" +git push origin v1.0.0 +``` + +**Step 5: Draft a GitHub Release** + +On github.com/mage-os/module-ai-base/releases, click **Draft a new release**, choose tag `v1.0.0`, paste the `## [1.0.0]` section of `CHANGELOG.md` as the body. Publish. + +**Step 6: Manual packagist submission** + +On packagist.org → Submit → paste `https://github.com/mage-os/module-ai-base`. Confirm the package shows the `1.0.0` version with correct metadata. Enable the GitHub webhook so future tags auto-publish. + +**Step 7: Verify end-to-end** + +From any scratch directory: +```bash +mkdir /tmp/ai-base-smoke && cd /tmp/ai-base-smoke +composer require mage-os/module-ai-base:^1.0.0 --no-install --dry-run +``` +Expected: composer resolves the package without errors, showing `mage-os/module-ai-base 1.0.0`. + +--- + +## Done + +All done-done criteria met: +- Graycore `check-extension` CI runs on every PR, matrix-testing against current Mage-OS versions. +- Unit + integration tests cover the selector hardening, the field DTO, every service, and a full config round-trip. +- `FieldDescriptor` schema replaces string-template HTML; model lists are declarative. +- Demo install on `mage-os-typesense` exercises the admin UI end-to-end and both `getAll()` data paths. +- `v1.0.0` tagged, released, on packagist. From 82666cd6853042067850418c38b3ecc9c81111ae Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Mon, 20 Apr 2026 20:43:27 +0200 Subject: [PATCH 04/38] chore: tighten composer metadata, add LICENSE and CHANGELOG --- CHANGELOG.md | 21 ++++++++++ LICENSE.md | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++ composer.json | 26 +++++++++--- 3 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 LICENSE.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5a37bcd --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [1.0.0] - 2026-04-20 + +### Added +- Structured `FieldDescriptorInterface` config field schema replacing the HTML-template pattern. +- `getSupportedModels(): array` method on each service for non-hardcoded model lists. +- GitHub Actions CI via `graycoreio/github-actions-magento2/check-extension`. +- Unit test suite for `AiServiceSelector` and all eleven `AiServices/*` classes. +- Integration test covering round-trip of stored config through `ScopeConfigInterface`. + +### Changed +- **BREAKING:** `AiServiceConfigurationInterface::getConfigurationTemplate(): string` replaced by `::getConfigurationFields(): FieldDescriptorInterface[]` and `::getSupportedModels(): array`. +- `composer.json` now pins `php: ^8.2` and `magento/framework: ^103.0 || ^104.0`. +- `Model/AiServiceSelector` hardened against null scope values and malformed JSON. +- `module.xml` declares explicit dependency on `Magento_Config` + `Magento_Backend`. + +### Fixed +- `README.md` API example now references the correct `AiServiceSelectorInterface` (previously cited `AiServiceConfigurationInterface`). diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..62c3019 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,110 @@ +# License + +This package is licensed under either of the following licenses, at your choice: + +- The Open Software License v3.0 ("OSL-3.0") — see below +- The Academic Free License v3.0 ("AFL-3.0") — see below + +--- + +## Open Software License v3.0 + +Open Software License ("OSL") v. 3.0 + +This Open Software License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work: + +Licensed under the Open Software License version 3.0 + + 1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following: + + 1. to reproduce the Original Work in copies, either alone or as part of a collective work; + + 2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work; + + 3. to distribute or communicate copies of the Original Work and Derivative Works to the public, with the proviso that copies of Original Work or Derivative Works that You distribute or communicate shall be licensed under this Open Software License; + + 4. to perform the Original Work publicly; and + + 5. to display the Original Work publicly. + + 2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works. + + 3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work. + + 4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license. + + 5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c). + + 6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work. + + 7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer. + + 8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation. + + 9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including 'fair use' or 'fair dealing'). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c). + + 10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware. + + 11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License. + + 12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License. + + 13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. + + 14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + 15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You. + + 16. Modification of This License. This License is Copyright (C) 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Open Software License" or "OSL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under " or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process. + +--- + +## Academic Free License v3.0 + +Academic Free License ("AFL") v. 3.0 + +This Academic Free License (the "License") applies to any original work of authorship (the "Original Work") whose owner (the "Licensor") has placed the following licensing notice adjacent to the copyright notice for the Original Work: + +Licensed under the Academic Free License version 3.0 + + 1. Grant of Copyright License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, for the duration of the copyright, to do the following: + + 1. to reproduce the Original Work in copies, either alone or as part of a collective work; + + 2. to translate, adapt, alter, transform, modify, or arrange the Original Work, thereby creating derivative works ("Derivative Works") based upon the Original Work; + + 3. to distribute or communicate copies of the Original Work and Derivative Works to the public, under any license of your choice that does not contradict the terms and conditions, including Licensor's reserved rights and remedies, in this Academic Free License; + + 4. to perform the Original Work publicly; and + + 5. to display the Original Work publicly. + + 2. Grant of Patent License. Licensor grants You a worldwide, royalty-free, non-exclusive, sublicensable license, under patent claims owned or controlled by the Licensor that are embodied in the Original Work as furnished by the Licensor, for the duration of the patents, to make, use, sell, offer for sale, have made, and import the Original Work and Derivative Works. + + 3. Grant of Source Code License. The term "Source Code" means the preferred form of the Original Work for making modifications to it and all available documentation describing how to modify the Original Work. Licensor agrees to provide a machine-readable copy of the Source Code of the Original Work along with each copy of the Original Work that Licensor distributes. Licensor reserves the right to satisfy this obligation by placing a machine-readable copy of the Source Code in an information repository reasonably calculated to permit inexpensive and convenient access by You for as long as Licensor continues to distribute the Original Work. + + 4. Exclusions From License Grant. Neither the names of Licensor, nor the names of any contributors to the Original Work, nor any of their trademarks or service marks, may be used to endorse or promote products derived from this Original Work without express prior permission of the Licensor. Except as expressly stated herein, nothing in this License grants any license to Licensor's trademarks, copyrights, patents, trade secrets or any other intellectual property. No patent license is granted to make, use, sell, offer for sale, have made, or import embodiments of any patent claims other than the licensed claims defined in Section 2. No license is granted to the trademarks of Licensor even if such marks are included in the Original Work. Nothing in this License shall be interpreted to prohibit Licensor from licensing under terms different from this License any Original Work that Licensor otherwise would have a right to license. + + 5. External Deployment. The term "External Deployment" means the use, distribution, or communication of the Original Work or Derivative Works in any way such that the Original Work or Derivative Works may be used by anyone other than You, whether those works are distributed or communicated to those persons or made available as an application intended for use over a network. As an express condition for the grants of license hereunder, You must treat any External Deployment by You of the Original Work or a Derivative Work as a distribution under section 1(c). + + 6. Attribution Rights. You must retain, in the Source Code of any Derivative Works that You create, all copyright, patent, or trademark notices from the Source Code of the Original Work, as well as any notices of licensing and any descriptive text identified therein as an "Attribution Notice." You must cause the Source Code for any Derivative Works that You create to carry a prominent Attribution Notice reasonably calculated to inform recipients that You have modified the Original Work. + + 7. Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that the copyright in and to the Original Work and the patent rights granted herein by Licensor are owned by the Licensor or are sublicensed to You under the terms of this License with the permission of the contributor(s) of those copyrights and patent rights. Except as expressly stated in the immediately preceding sentence, the Original Work is provided under this License on an "AS IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without limitation, the warranties of non-infringement, merchantability or fitness for a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this License. No license to the Original Work is granted by this License except under this disclaimer. + + 8. Limitation of Liability. Under no circumstances and under no legal theory, whether in tort (including negligence), contract, or otherwise, shall the Licensor be liable to anyone for any indirect, special, incidental, or consequential damages of any character arising as a result of this License or the use of the Original Work including, without limitation, damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses. This limitation of liability shall not apply to the extent applicable law prohibits such limitation. + + 9. Acceptance and Termination. If, at any time, You expressly assented to this License, that assent indicates your clear and irrevocable acceptance of this License and all of its terms and conditions. If You distribute or communicate copies of the Original Work or a Derivative Work, You must make a reasonable effort under the circumstances to obtain the express assent of recipients to the terms of this License. This License conditions your rights to undertake the activities listed in Section 1, including your right to create Derivative Works based upon the Original Work, and doing so without honoring these terms and conditions is prohibited by copyright law and international treaty. Nothing in this License is intended to affect copyright exceptions and limitations (including "fair use" or "fair dealing"). This License shall terminate immediately and You may no longer exercise any of the rights granted to You by this License upon your failure to honor the conditions in Section 1(c). + + 10. Termination for Patent Action. This License shall terminate automatically and You may no longer exercise any of the rights granted to You by this License as of the date You commence an action, including a cross-claim or counterclaim, against Licensor or any licensee alleging that the Original Work infringes a patent. This termination provision shall not apply for an action alleging patent infringement by combinations of the Original Work with other software or hardware. + + 11. Jurisdiction, Venue and Governing Law. Any action or suit relating to this License may be brought only in the courts of a jurisdiction wherein the Licensor resides or in which Licensor conducts its primary business, and under the laws of that jurisdiction excluding its conflict-of-law provisions. The application of the United Nations Convention on Contracts for the International Sale of Goods is expressly excluded. Any use of the Original Work outside the scope of this License or after its termination shall be subject to the requirements and penalties of copyright or patent law in the appropriate jurisdiction. This section shall survive the termination of this License. + + 12. Attorneys' Fees. In any action to enforce the terms of this License or seeking damages relating thereto, the prevailing party shall be entitled to recover its costs and expenses, including, without limitation, reasonable attorneys' fees and costs incurred in connection with such action, including any appeal of such action. This section shall survive the termination of this License. + + 13. Miscellaneous. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. + + 14. Definition of "You" in This License. "You" throughout this License, whether in upper or lower case, means an individual or a legal entity exercising rights under, and complying with all of the terms of, this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with you. For purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + 15. Right to Use. You may use the Original Work in all ways not otherwise restricted or conditioned by this License or by law, and Licensor promises not to interfere with or be responsible for such uses by You. + + 16. Modification of This License. This License is Copyright © 2005 Lawrence Rosen. Permission is granted to copy, distribute, or communicate this License without modification. Nothing in this License permits You to modify this License as applied to the Original Work or to Derivative Works. However, You may modify the text of this License and copy, distribute or communicate your modified version (the "Modified License") and apply it to other original works of authorship subject to the following conditions: (i) You may not indicate in any way that your Modified License is the "Academic Free License" or "AFL" and you may not use those names in the name of your Modified License; (ii) You must replace the notice specified in the first paragraph above with the notice "Licensed under " or with a notice of your own that is not confusingly similar to the notice in this License; and (iii) You may not claim that your original works are open source software unless your Modified License has been approved by Open Source Initiative (OSI) and You comply with its license review and certification process. diff --git a/composer.json b/composer.json index 4473bdb..7eca5c2 100755 --- a/composer.json +++ b/composer.json @@ -1,15 +1,29 @@ { "name": "mage-os/module-ai-base", - "description": "Base AI module for Mage-OS", + "description": "Base AI module for Mage-OS — register and retrieve configuration for multiple AI backends.", "type": "magento2-module", - "minimum-stability": "dev", + "license": ["OSL-3.0", "AFL-3.0"], + "authors": [ + { "name": "David Lambauer", "email": "david@run-as-root.sh" } + ], + "support": { + "issues": "https://github.com/mage-os/module-ai-base/issues", + "source": "https://github.com/mage-os/module-ai-base" + }, + "version": "1.0.0", "require": { - "magento/framework": "*" + "php": "^8.2", + "magento/framework": "^103.0 || ^104.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5", + "mage-os/magento-coding-standard": "^2.0" }, "autoload": { "files": ["src/registration.php"], - "psr-4": { - "MageOS\\AiBase\\": "src/" - } + "psr-4": { "MageOS\\AiBase\\": "src/" } + }, + "autoload-dev": { + "psr-4": { "MageOS\\AiBase\\Test\\": "src/Test/" } } } From 672962e42485f02952406f6bc7bb654571df267d Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Mon, 20 Apr 2026 20:46:02 +0200 Subject: [PATCH 05/38] docs: drop version field from composer.json spec Packagist convention is to derive the version from git tags, not a hardcoded composer.json field. Leaving it in triggers a warning that composer validate --strict escalates to a failure. Task 12 already handles tagging as the source of truth. --- docs/plans/2026-04-20-done-done-design.md | 1 - docs/plans/2026-04-20-done-done-plan.md | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/plans/2026-04-20-done-done-design.md b/docs/plans/2026-04-20-done-done-design.md index d2467f5..3e10314 100644 --- a/docs/plans/2026-04-20-done-done-design.md +++ b/docs/plans/2026-04-20-done-done-design.md @@ -166,7 +166,6 @@ Single test `round_trips_configuration_through_scope_config`: "issues": "https://github.com/mage-os/module-ai-base/issues", "source": "https://github.com/mage-os/module-ai-base" }, - "version": "1.0.0", "require": { "php": "^8.2", "magento/framework": "^103.0 || ^104.0" diff --git a/docs/plans/2026-04-20-done-done-plan.md b/docs/plans/2026-04-20-done-done-plan.md index 3cd14f0..e1193bc 100644 --- a/docs/plans/2026-04-20-done-done-plan.md +++ b/docs/plans/2026-04-20-done-done-plan.md @@ -34,7 +34,6 @@ "issues": "https://github.com/mage-os/module-ai-base/issues", "source": "https://github.com/mage-os/module-ai-base" }, - "version": "1.0.0", "require": { "php": "^8.2", "magento/framework": "^103.0 || ^104.0" @@ -86,7 +85,7 @@ All notable changes to this project will be documented in this file. **Step 4: Verify composer sanity** Run: `composer validate --no-check-publish --strict` -Expected: `./composer.json is valid` (no errors, no warnings other than version/publish notices which are muted by the flag). +Expected: `./composer.json is valid` with exit code 0. The `version` field is intentionally omitted — Packagist derives it from git tags (see Task 12), and leaving it in would trigger a warning that `--strict` escalates to a failure. **Step 5: Commit** From 83734fa66857a5bde6aacdf0c73e073c15eee4ae Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Mon, 20 Apr 2026 20:46:37 +0200 Subject: [PATCH 06/38] chore: drop version field from composer.json Packagist derives the version from git tags (see Task 12). Leaving a hardcoded version field triggers a warning that composer validate --strict escalates to a failure. --- composer.json | 1 - 1 file changed, 1 deletion(-) diff --git a/composer.json b/composer.json index 7eca5c2..bd1d954 100755 --- a/composer.json +++ b/composer.json @@ -10,7 +10,6 @@ "issues": "https://github.com/mage-os/module-ai-base/issues", "source": "https://github.com/mage-os/module-ai-base" }, - "version": "1.0.0", "require": { "php": "^8.2", "magento/framework": "^103.0 || ^104.0" From b9ecc0e425d74ea0eeca7cd89da9befd28206a69 Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Mon, 20 Apr 2026 20:49:18 +0200 Subject: [PATCH 07/38] docs: capture Task 1 code-review polish items in Task 12 scope Reviewer flagged CHANGELOG hygiene (Unreleased + compare-links + Keep-a-Changelog preamble), .gitattributes for export-ignore of dev paths, and a LICENSE split decision. All Minor, all batched for the pre-tag polish commit. --- docs/plans/2026-04-20-done-done-plan.md | 28 +++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/plans/2026-04-20-done-done-plan.md b/docs/plans/2026-04-20-done-done-plan.md index e1193bc..9fc8e33 100644 --- a/docs/plans/2026-04-20-done-done-plan.md +++ b/docs/plans/2026-04-20-done-done-plan.md @@ -1295,6 +1295,34 @@ git commit -m "chore: wire path repo for mage-os/module-ai-base" ## Task 12: Tag `v1.0.0` +**Step 0: Apply release-polish deferred from Task 1 code review** + +Before tagging: +- Add a `## [Unreleased]` section to `CHANGELOG.md` at the top (gives future PRs a clear landing spot). +- Add Keep-a-Changelog preamble lines after the intro: + ``` + The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), + and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + ``` +- Add a compare-link footer to `CHANGELOG.md`: + ``` + [Unreleased]: https://github.com/mage-os/module-ai-base/compare/v1.0.0...HEAD + [1.0.0]: https://github.com/mage-os/module-ai-base/releases/tag/v1.0.0 + ``` +- Create `.gitattributes` at repo root: + ``` + /.github export-ignore + /docs export-ignore + /src/Test export-ignore + /phpcs.xml.dist export-ignore + /phpunit.xml.dist export-ignore + /CLAUDE.md export-ignore + ``` + (Keeps dev scaffolding out of the dist tarball that composer serves from a tagged release.) +- Decide on LICENSE presentation: either keep the single `LICENSE.md` with both texts, or split into `LICENSE_OSL.md` + `LICENSE_AFL.md` to match the Mage-OS core convention and make GitHub's license detector show "Other (OSL-3.0 OR AFL-3.0)" correctly. Current choice: keep single file (documented here). + +Commit these together before Step 1 with message `chore: release-polish per code review (gitattributes, changelog hygiene)`. + **Step 1: Final verification** Run all pre-release checks: From 15c76b63325147031e6ae0c591781b1cac25a0a2 Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Mon, 20 Apr 2026 20:50:02 +0200 Subject: [PATCH 08/38] ci: add Graycore check-extension workflow and phpcs config --- .github/workflows/check-extension.yaml | 23 +++++++++++++++++++++++ phpcs.xml.dist | 7 +++++++ 2 files changed, 30 insertions(+) create mode 100644 .github/workflows/check-extension.yaml create mode 100644 phpcs.xml.dist diff --git a/.github/workflows/check-extension.yaml b/.github/workflows/check-extension.yaml new file mode 100644 index 0000000..2361b79 --- /dev/null +++ b/.github/workflows/check-extension.yaml @@ -0,0 +1,23 @@ +name: Check Extension + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + compute-matrix: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.supported.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + - uses: graycoreio/github-actions-magento2/supported-version@v5.1.0 + id: supported + + check-extension: + needs: compute-matrix + uses: graycoreio/github-actions-magento2/.github/workflows/check-extension.yaml@v5.1.0 + with: + matrix: ${{ needs.compute-matrix.outputs.matrix }} diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..1bdcd2b --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,7 @@ + + + + src + src/Test/ + + From 9651b2747f95f5f61668e109f5e9223831ba0f41 Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Mon, 20 Apr 2026 20:54:08 +0200 Subject: [PATCH 09/38] ci: target Mage-OS matrix explicitly in supported-version Graycore's supported-version action defaults to project: magento-open-source. Without this override the check-extension matrix tested against Magento Open Source versions, not the Mage-OS versions this module is actually built against. --- .github/workflows/check-extension.yaml | 2 ++ docs/plans/2026-04-20-done-done-plan.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/check-extension.yaml b/.github/workflows/check-extension.yaml index 2361b79..d982778 100644 --- a/.github/workflows/check-extension.yaml +++ b/.github/workflows/check-extension.yaml @@ -15,6 +15,8 @@ jobs: - uses: actions/checkout@v4 - uses: graycoreio/github-actions-magento2/supported-version@v5.1.0 id: supported + with: + project: mage-os check-extension: needs: compute-matrix diff --git a/docs/plans/2026-04-20-done-done-plan.md b/docs/plans/2026-04-20-done-done-plan.md index 9fc8e33..94ed3b4 100644 --- a/docs/plans/2026-04-20-done-done-plan.md +++ b/docs/plans/2026-04-20-done-done-plan.md @@ -134,6 +134,8 @@ jobs: - uses: actions/checkout@v4 - uses: graycoreio/github-actions-magento2/supported-version@v5.1.0 id: supported + with: + project: mage-os check-extension: needs: compute-matrix From 6aac75ce65cdc6d27b93cab64eff0eb4f8bd40d3 Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Mon, 20 Apr 2026 20:54:59 +0200 Subject: [PATCH 10/38] =?UTF-8?q?docs:=20fix=20README=20=E2=80=94=20correc?= =?UTF-8?q?t=20interface=20references=20in=20usage=20example?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 35 ++++++++++++++++++++++------------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 3f03ef7..be67384 100644 --- a/README.md +++ b/README.md @@ -13,25 +13,34 @@ You can find the new configuration option in System > Configuration > Services - ## Usage -If you have configured AI backends, you can fetch the configuration using these methods: +If you have configured AI backends, you can fetch the configuration using these methods: ```php -AiServiceConfigurationInterface::getAll($code): array -AiServiceConfigurationInterface::getByCode($code): array +use MageOS\AiBase\Api\AiServiceSelectorInterface; + +AiServiceSelectorInterface::getAll(): array +AiServiceSelectorInterface::getByCode(string $code): array ``` -Both methods return an array of `\MageOS\AiBase\Api\Data\AiServiceInterface` objects. +Both methods return an array of `\MageOS\AiBase\Api\Data\AiServiceInterface` objects (multiple entries per code are possible because admins can register the same backend more than once). ```php -class MyAiFunctionality { - public function __construct(AiServiceConfigurationInterface $aiServiceConfiguration) { - $this->aiServiceConfiguration = $aiServiceConfiguration; - } - - public function doSomething() { - $openAiCredentials = $this->aiServiceConfiguration->getByCode('openai'); - - // $openAiCredentials = an array of \MageOS\AiBase\Api\Data\AiServiceInterface objects +use MageOS\AiBase\Api\AiServiceSelectorInterface; + +final class MyAiFunctionality +{ + public function __construct( + private readonly AiServiceSelectorInterface $aiServiceSelector, + ) {} + + public function doSomething(): void + { + $openAiServices = $this->aiServiceSelector->getByCode('openai'); + + foreach ($openAiServices as $service) { + $config = $service->getConfiguration(); + // $config = ['apikey' => '...', 'model' => 'gpt-4o', ...] + } } } ``` From a6f7581511e1e24ac25d3ac942fd7e580ec2d388 Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Mon, 20 Apr 2026 20:56:26 +0200 Subject: [PATCH 11/38] fix: declare explicit module sequence on Magento_Config and Magento_Backend --- src/etc/module.xml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/etc/module.xml b/src/etc/module.xml index 595b94f..30797e1 100644 --- a/src/etc/module.xml +++ b/src/etc/module.xml @@ -1,4 +1,10 @@ - - + + + + + + + From 14db6dae7d197fd2b1536e75d5343461eb5b95a4 Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Mon, 20 Apr 2026 21:00:46 +0200 Subject: [PATCH 12/38] fix(selector): harden getParsedConfig against null and malformed JSON Replaces the Laravel-style array_first() with native array_key_first() and guards every boundary (null scope value, non-string config, failed json_decode, non-array rows, missing code key) before constructing AiService DTOs. Covered by 4 new unit tests. --- src/Model/AiServiceSelector.php | 44 +++++++--- src/Test/Unit/Model/AiServiceSelectorTest.php | 82 +++++++++++++++++++ 2 files changed, 114 insertions(+), 12 deletions(-) create mode 100644 src/Test/Unit/Model/AiServiceSelectorTest.php diff --git a/src/Model/AiServiceSelector.php b/src/Model/AiServiceSelector.php index e1ff46c..6bf6bbd 100644 --- a/src/Model/AiServiceSelector.php +++ b/src/Model/AiServiceSelector.php @@ -1,5 +1,7 @@ getParsedConfig(); - - return array_filter($services, fn(AiServiceInterface $service) => $service->getCode() === $code); + return array_values(array_filter( + $this->getParsedConfig(), + fn (AiServiceInterface $service) => $service->getCode() === $code, + )); } + /** + * @return AiServiceInterface[] + */ private function getParsedConfig(): array { - $json = json_decode($this->scopeConfig->getValue(self::CONFIG_PATH_AI_SERVICES), true); - if ($json === null) { + $raw = $this->scopeConfig->getValue(self::CONFIG_PATH_AI_SERVICES); + if (!is_string($raw) || $raw === '') { return []; } - return array_map( function(array $item) { - $service = array_first(array_keys($item)); + $decoded = json_decode($raw, true); + if (!is_array($decoded)) { + return []; + } - return $this->aiServiceFactory->create([ - 'code' => $service, - 'configuration' => $item[$service] + $services = []; + foreach ($decoded as $row) { + if (!is_array($row) || $row === []) { + continue; + } + $code = array_key_first($row); + $configuration = $row[$code]; + if (!is_string($code) || !is_array($configuration)) { + continue; + } + $services[] = $this->aiServiceFactory->create([ + 'code' => $code, + 'configuration' => $configuration, ]); - }, $json); + } + + return $services; } } diff --git a/src/Test/Unit/Model/AiServiceSelectorTest.php b/src/Test/Unit/Model/AiServiceSelectorTest.php new file mode 100644 index 0000000..09ec885 --- /dev/null +++ b/src/Test/Unit/Model/AiServiceSelectorTest.php @@ -0,0 +1,82 @@ +scopeConfig = $this->createMock(ScopeConfigInterface::class); + $this->aiServiceFactory = $this->createMock(AiServiceInterfaceFactory::class); + $this->subject = new AiServiceSelector($this->scopeConfig, $this->aiServiceFactory); + } + + public function test_get_all_returns_empty_array_when_config_is_null(): void + { + $this->scopeConfig->method('getValue')->willReturn(null); + + self::assertSame([], $this->subject->getAll()); + } + + public function test_get_all_returns_empty_array_when_config_is_malformed_json(): void + { + $this->scopeConfig->method('getValue')->willReturn('not-json'); + + self::assertSame([], $this->subject->getAll()); + } + + public function test_get_all_returns_all_configured_services(): void + { + $json = json_encode([ + '_row1' => ['openai' => ['apikey' => 'k1', 'model' => 'gpt-4o']], + '_row2' => ['anthropic' => ['apikey' => 'k2', 'model' => 'claude-sonnet-4-6']], + ], JSON_THROW_ON_ERROR); + $this->scopeConfig->method('getValue')->willReturn($json); + + $this->aiServiceFactory->method('create')->willReturnCallback( + fn (array $data) => new AiService($data['code'], $data['configuration']) + ); + + $result = $this->subject->getAll(); + + self::assertCount(2, $result); + self::assertContainsOnlyInstancesOf(AiServiceInterface::class, $result); + self::assertSame('openai', $result[0]->getCode()); + self::assertSame('anthropic', $result[1]->getCode()); + } + + public function test_get_by_code_filters_to_matching_services_only(): void + { + $json = json_encode([ + '_row1' => ['openai' => ['apikey' => 'k1']], + '_row2' => ['anthropic' => ['apikey' => 'k2']], + '_row3' => ['openai' => ['apikey' => 'k3']], + ], JSON_THROW_ON_ERROR); + $this->scopeConfig->method('getValue')->willReturn($json); + + $this->aiServiceFactory->method('create')->willReturnCallback( + fn (array $data) => new AiService($data['code'], $data['configuration']) + ); + + $result = $this->subject->getByCode('openai'); + + self::assertCount(2, $result); + foreach ($result as $service) { + self::assertSame('openai', $service->getCode()); + } + } +} From 0e2f0a151da04b6e676b9ab66e9b301bf9945280 Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Mon, 20 Apr 2026 21:07:40 +0200 Subject: [PATCH 13/38] fix(selector): cover all four guards and document insertion-order contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a parametrised negative test for the 'malformed row' and 'bad row shape' guards so a future refactor can't silently remove them. Also documents on AiServiceSelectorInterface that results are returned in insertion order — the positional assertions in the unit and integration tests depend on that being a real contract, not an incidental behaviour. --- src/Api/AiServiceSelectorInterface.php | 11 ++++++-- src/Test/Unit/Model/AiServiceSelectorTest.php | 25 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/Api/AiServiceSelectorInterface.php b/src/Api/AiServiceSelectorInterface.php index f6abd7a..c720fa6 100644 --- a/src/Api/AiServiceSelectorInterface.php +++ b/src/Api/AiServiceSelectorInterface.php @@ -1,5 +1,7 @@ */ public function getAll(): array; /** - * @return AiServiceInterface[] + * Returns all configured AI services with the given code, in insertion order. + * Multiple entries per code are possible when an admin registers the same backend more than once. + * + * @return list */ public function getByCode(string $code): array; } diff --git a/src/Test/Unit/Model/AiServiceSelectorTest.php b/src/Test/Unit/Model/AiServiceSelectorTest.php index 09ec885..35475d5 100644 --- a/src/Test/Unit/Model/AiServiceSelectorTest.php +++ b/src/Test/Unit/Model/AiServiceSelectorTest.php @@ -9,6 +9,7 @@ use MageOS\AiBase\Api\Data\AiServiceInterfaceFactory; use MageOS\AiBase\Model\AiService; use MageOS\AiBase\Model\AiServiceSelector; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -59,6 +60,30 @@ public function test_get_all_returns_all_configured_services(): void self::assertSame('anthropic', $result[1]->getCode()); } + /** + * @param array $decoded + */ + #[DataProvider('malformed_decoded_shapes')] + public function test_get_all_silently_skips_malformed_rows(array $decoded): void + { + $this->scopeConfig->method('getValue')->willReturn(json_encode($decoded, JSON_THROW_ON_ERROR)); + + self::assertSame([], $this->subject->getAll()); + } + + /** + * @return array}> + */ + public static function malformed_decoded_shapes(): array + { + return [ + 'row is a bare string' => [['_row1' => 'not-an-array']], + 'row is an empty array' => [['_row1' => []]], + 'row value is non-array' => [['_row1' => ['openai' => 'not-an-array']]], + 'row key is integer (not code)'=> [['_row1' => [0 => ['apikey' => 'k1']]]], + ]; + } + public function test_get_by_code_filters_to_matching_services_only(): void { $json = json_encode([ From 571132211113aee5c73dd2f4f3b3876aa29b54b1 Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Mon, 20 Apr 2026 21:39:12 +0200 Subject: [PATCH 14/38] feat: add FieldDescriptor DTO for structured admin form schema --- src/Api/Data/FieldDescriptorInterface.php | 23 +++++++++++ src/Model/FieldDescriptor.php | 43 +++++++++++++++++++ src/Test/Unit/Model/FieldDescriptorTest.php | 46 +++++++++++++++++++++ src/etc/di.xml | 1 + 4 files changed, 113 insertions(+) create mode 100644 src/Api/Data/FieldDescriptorInterface.php create mode 100644 src/Model/FieldDescriptor.php create mode 100644 src/Test/Unit/Model/FieldDescriptorTest.php diff --git a/src/Api/Data/FieldDescriptorInterface.php b/src/Api/Data/FieldDescriptorInterface.php new file mode 100644 index 0000000..211ed89 --- /dev/null +++ b/src/Api/Data/FieldDescriptorInterface.php @@ -0,0 +1,23 @@ + + */ + public function getOptions(): array; + + public function getDefault(): ?string; +} diff --git a/src/Model/FieldDescriptor.php b/src/Model/FieldDescriptor.php new file mode 100644 index 0000000..eab4385 --- /dev/null +++ b/src/Model/FieldDescriptor.php @@ -0,0 +1,43 @@ +name; + } + + public function getLabel(): string + { + return $this->label; + } + + public function getType(): string + { + return $this->type; + } + + public function getOptions(): array + { + return $this->options; + } + + public function getDefault(): ?string + { + return $this->default; + } +} diff --git a/src/Test/Unit/Model/FieldDescriptorTest.php b/src/Test/Unit/Model/FieldDescriptorTest.php new file mode 100644 index 0000000..f95aac0 --- /dev/null +++ b/src/Test/Unit/Model/FieldDescriptorTest.php @@ -0,0 +1,46 @@ +getName()); + self::assertSame('API Key', $field->getLabel()); + self::assertSame(FieldDescriptorInterface::TYPE_PASSWORD, $field->getType()); + self::assertSame([], $field->getOptions()); + self::assertNull($field->getDefault()); + } + + public function test_select_field_carries_options_and_default(): void + { + $field = new FieldDescriptor( + name: 'model', + label: 'Model', + type: FieldDescriptorInterface::TYPE_SELECT, + options: [ + ['value' => 'a', 'label' => 'Apple'], + ['value' => 'b', 'label' => 'Banana'], + ], + default: 'a', + ); + + self::assertSame('model', $field->getName()); + self::assertSame(FieldDescriptorInterface::TYPE_SELECT, $field->getType()); + self::assertCount(2, $field->getOptions()); + self::assertSame('a', $field->getDefault()); + } +} diff --git a/src/etc/di.xml b/src/etc/di.xml index 7165c61..74be265 100644 --- a/src/etc/di.xml +++ b/src/etc/di.xml @@ -3,6 +3,7 @@ xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> + From 4dbc55f8e2bb534a54e29ac2985421e0cb24dff7 Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Mon, 20 Apr 2026 21:47:03 +0200 Subject: [PATCH 15/38] feat!: replace HTML-template config with FieldDescriptor schema BREAKING CHANGE: AiServiceConfigurationInterface::getConfigurationTemplate() is removed. Implementers now return FieldDescriptor[] from getConfigurationFields() and (optionally) a model list from getSupportedModels(). Admin form phtml now renders fields by type from a JSON schema rather than substituting HTML template strings. Storage format is unchanged, so the AiServiceSelector consumer API is fully backwards compatible. --- src/AiServices/Anthropic.php | 43 +++-- src/AiServices/Azure.php | 42 +++-- src/AiServices/Deepseek.php | 37 ++-- src/AiServices/FieldFactoryTrait.php | 56 ++++++ src/AiServices/Google.php | 45 ++--- src/AiServices/Grok.php | 37 ++-- src/AiServices/HuggingFace.php | 34 ++-- src/AiServices/LmStudio.php | 34 ++-- src/AiServices/Ollama.php | 39 +++-- src/AiServices/OpenAi.php | 44 +++-- src/AiServices/OpenRouter.php | 39 +++-- src/AiServices/Xai.php | 37 ++-- .../Data/AiServiceConfigurationInterface.php | 13 +- .../Adminhtml/Configuration/Services.php | 54 ++++-- src/etc/di.xml | 4 +- .../system/config/form/field/services.phtml | 162 +++++++++--------- 16 files changed, 439 insertions(+), 281 deletions(-) create mode 100644 src/AiServices/FieldFactoryTrait.php diff --git a/src/AiServices/Anthropic.php b/src/AiServices/Anthropic.php index 52580ee..516e99d 100644 --- a/src/AiServices/Anthropic.php +++ b/src/AiServices/Anthropic.php @@ -1,11 +1,20 @@ 'Claude Opus 4.7', + 'claude-sonnet-4-6' => 'Claude Sonnet 4.6', + 'claude-haiku-4-5-20251001' => 'Claude Haiku 4.5', + ]; + } + + public function getConfigurationFields(): array { - return << - - - - - - - - -
API Key
Model - -
- TABLE; + return [ + $this->apiKeyField($this->fieldFactory), + $this->modelField($this->fieldFactory, $this->getSupportedModels()), + ]; } } diff --git a/src/AiServices/Azure.php b/src/AiServices/Azure.php index 1a3763d..fb078e6 100644 --- a/src/AiServices/Azure.php +++ b/src/AiServices/Azure.php @@ -1,11 +1,20 @@ 'GPT-4o', + 'gpt-4o-mini' => 'GPT-4o mini', + 'gpt-4-turbo' => 'GPT-4 Turbo', + 'o1' => 'o1', + 'o1-mini' => 'o1 mini', + ]; } - public function getConfigurationTemplate(): string + public function getConfigurationFields(): array { - return << - - - - - - - - -
API Key
Deployment
- TABLE; + return [ + $this->apiKeyField($this->fieldFactory), + $this->modelField($this->fieldFactory, $this->getSupportedModels()), + ]; } } diff --git a/src/AiServices/Deepseek.php b/src/AiServices/Deepseek.php index b18675f..633b6e2 100644 --- a/src/AiServices/Deepseek.php +++ b/src/AiServices/Deepseek.php @@ -1,11 +1,20 @@ 'DeepSeek V3', + 'deepseek-reasoner' => 'DeepSeek R1', + ]; + } + + public function getConfigurationFields(): array { - return << - - - - - - - - -
API Key
Model
- TABLE; + return [ + $this->apiKeyField($this->fieldFactory), + $this->modelField($this->fieldFactory, $this->getSupportedModels()), + ]; } } diff --git a/src/AiServices/FieldFactoryTrait.php b/src/AiServices/FieldFactoryTrait.php new file mode 100644 index 0000000..ce93770 --- /dev/null +++ b/src/AiServices/FieldFactoryTrait.php @@ -0,0 +1,56 @@ +create([ + 'name' => 'apikey', + 'label' => 'API Key', + 'type' => FieldDescriptorInterface::TYPE_PASSWORD, + ]); + } + + /** + * @param array $supportedModels + */ + private function modelField(FieldDescriptorInterfaceFactory $factory, array $supportedModels): FieldDescriptorInterface + { + $options = []; + foreach ($supportedModels as $value => $label) { + $options[] = ['value' => (string) $value, 'label' => (string) $label]; + } + return $factory->create([ + 'name' => 'model', + 'label' => 'Model', + 'type' => FieldDescriptorInterface::TYPE_SELECT, + 'options' => $options, + ]); + } + + private function baseUrlField(FieldDescriptorInterfaceFactory $factory, string $default): FieldDescriptorInterface + { + return $factory->create([ + 'name' => 'base_url', + 'label' => 'Base URL', + 'type' => FieldDescriptorInterface::TYPE_TEXT, + 'default' => $default, + ]); + } + + private function freeTextModelField(FieldDescriptorInterfaceFactory $factory): FieldDescriptorInterface + { + return $factory->create([ + 'name' => 'model', + 'label' => 'Model', + 'type' => FieldDescriptorInterface::TYPE_TEXT, + ]); + } +} diff --git a/src/AiServices/Google.php b/src/AiServices/Google.php index 9f31bfb..dfbf056 100644 --- a/src/AiServices/Google.php +++ b/src/AiServices/Google.php @@ -1,11 +1,20 @@ 'Gemini 2.0 Pro', + 'gemini-2.0-flash' => 'Gemini 2.0 Flash', + 'gemini-1.5-pro' => 'Gemini 1.5 Pro', + ]; } - public function getConfigurationTemplate(): string + public function getConfigurationFields(): array { - return << - - - - - - - - -
API Key
Model - -
- TABLE; + return [ + $this->apiKeyField($this->fieldFactory), + $this->modelField($this->fieldFactory, $this->getSupportedModels()), + ]; } } diff --git a/src/AiServices/Grok.php b/src/AiServices/Grok.php index e02eb82..974f716 100644 --- a/src/AiServices/Grok.php +++ b/src/AiServices/Grok.php @@ -1,11 +1,20 @@ 'Grok 2', + 'grok-2-mini' => 'Grok 2 mini', + ]; + } + + public function getConfigurationFields(): array { - return << - - - - - - - - -
API Key
Model
- TABLE; + return [ + $this->apiKeyField($this->fieldFactory), + $this->modelField($this->fieldFactory, $this->getSupportedModels()), + ]; } } diff --git a/src/AiServices/HuggingFace.php b/src/AiServices/HuggingFace.php index 58e94dd..3828c4c 100644 --- a/src/AiServices/HuggingFace.php +++ b/src/AiServices/HuggingFace.php @@ -1,11 +1,20 @@ - - API Key - - - - Model - - - - TABLE; + return [ + $this->apiKeyField($this->fieldFactory), + $this->freeTextModelField($this->fieldFactory), + ]; } } diff --git a/src/AiServices/LmStudio.php b/src/AiServices/LmStudio.php index 54c320c..eec6d72 100644 --- a/src/AiServices/LmStudio.php +++ b/src/AiServices/LmStudio.php @@ -1,11 +1,20 @@ - - Base URL - - - - Model - - - - TABLE; + return [ + $this->baseUrlField($this->fieldFactory, 'http://localhost:1234'), + $this->freeTextModelField($this->fieldFactory), + ]; } } diff --git a/src/AiServices/Ollama.php b/src/AiServices/Ollama.php index a88fd1e..bb98c58 100644 --- a/src/AiServices/Ollama.php +++ b/src/AiServices/Ollama.php @@ -1,11 +1,20 @@ - - Base URL - - - - Model - - - - - - TABLE; + return [ + $this->baseUrlField($this->fieldFactory, 'http://localhost:11434'), + $this->freeTextModelField($this->fieldFactory), + ]; } } diff --git a/src/AiServices/OpenAi.php b/src/AiServices/OpenAi.php index 600c221..3055179 100644 --- a/src/AiServices/OpenAi.php +++ b/src/AiServices/OpenAi.php @@ -1,11 +1,20 @@ 'GPT-4o', + 'gpt-4o-mini' => 'GPT-4o mini', + 'gpt-4-turbo' => 'GPT-4 Turbo', + 'o1' => 'o1', + 'o1-mini' => 'o1 mini', + ]; + } + + public function getConfigurationFields(): array { - return << - - - - - - - - -
API Key
Model - -
- TABLE; + return [ + $this->apiKeyField($this->fieldFactory), + $this->modelField($this->fieldFactory, $this->getSupportedModels()), + ]; } } diff --git a/src/AiServices/OpenRouter.php b/src/AiServices/OpenRouter.php index ce2215c..bd46d2e 100644 --- a/src/AiServices/OpenRouter.php +++ b/src/AiServices/OpenRouter.php @@ -1,11 +1,20 @@ - - API Key - - - - Model - - - - - - TABLE; + return [ + $this->apiKeyField($this->fieldFactory), + $this->freeTextModelField($this->fieldFactory), + ]; } } diff --git a/src/AiServices/Xai.php b/src/AiServices/Xai.php index 20fff36..1fe91a4 100644 --- a/src/AiServices/Xai.php +++ b/src/AiServices/Xai.php @@ -1,11 +1,20 @@ 'Grok 2', + 'grok-2-mini' => 'Grok 2 mini', + ]; + } + + public function getConfigurationFields(): array { - return << - - - - - - - - -
API Key
Model
- TABLE; + return [ + $this->apiKeyField($this->fieldFactory), + $this->modelField($this->fieldFactory, $this->getSupportedModels()), + ]; } } diff --git a/src/Api/Data/AiServiceConfigurationInterface.php b/src/Api/Data/AiServiceConfigurationInterface.php index 1db8d43..668f383 100644 --- a/src/Api/Data/AiServiceConfigurationInterface.php +++ b/src/Api/Data/AiServiceConfigurationInterface.php @@ -1,12 +1,21 @@ value => label; empty array for services with no model list + */ + public function getSupportedModels(): array; } diff --git a/src/Block/Adminhtml/Configuration/Services.php b/src/Block/Adminhtml/Configuration/Services.php index c546963..d8a4180 100644 --- a/src/Block/Adminhtml/Configuration/Services.php +++ b/src/Block/Adminhtml/Configuration/Services.php @@ -1,9 +1,12 @@ + */ public function getServicesButtons(): array { - return array_map(fn (AiServiceConfigurationInterface $service) => [ - 'code' => $service->getCode(), - 'name' => $service->getName(), - ], $this->services); + return array_map( + fn (AiServiceConfigurationInterface $service) => [ + 'code' => $service->getCode(), + 'name' => $service->getName(), + ], + $this->services, + ); } - public function getServicesTemplates(): array + /** + * @return string JSON object keyed by service code, each value is a list of field descriptors as arrays + */ + public function getServicesSchemaJson(): string { - return array_map(fn (AiServiceConfigurationInterface $service) => [ - 'code' => $service->getCode(), - 'template' => $service->getConfigurationTemplate(), - ], $this->services); + $schema = []; + foreach ($this->services as $service) { + $schema[$service->getCode()] = array_map( + fn ($field) => [ + 'name' => $field->getName(), + 'label' => $field->getLabel(), + 'type' => $field->getType(), + 'options' => $field->getOptions(), + 'default' => $field->getDefault(), + ], + $service->getConfigurationFields(), + ); + } + return $this->jsonSerializer->serialize($schema); } protected function _prepareToRender(): void { - $this->addColumn( - 'service', - [ - 'label' => __('Service'), - 'class' => 'required-entry' - ] - ); + $this->addColumn('service', [ + 'label' => __('Service'), + 'class' => 'required-entry', + ]); $this->_addAfter = false; $this->_addButtonLabel = __('Add Service'); diff --git a/src/etc/di.xml b/src/etc/di.xml index 74be265..05f59a2 100644 --- a/src/etc/di.xml +++ b/src/etc/di.xml @@ -1,8 +1,8 @@ - - + + diff --git a/src/view/adminhtml/templates/system/config/form/field/services.phtml b/src/view/adminhtml/templates/system/config/form/field/services.phtml index 0536918..30270e3 100644 --- a/src/view/adminhtml/templates/system/config/form/field/services.phtml +++ b/src/view/adminhtml/templates/system/config/form/field/services.phtml @@ -1,111 +1,113 @@ getHtmlId() ? $block->getHtmlId() : '_' . uniqid(); +$_htmlId = $block->getHtmlId() ?: '_' . uniqid(); $_colspan = $block->isAddAfter() ? 2 : 1; ?> -
+
- - getColumns() as $columnName => $column): ?> - - - - + + getColumns() as $column): ?> + + + + - - - + + +
escapeHtml($column['label']) ?>escapeHtml(__('Action')) ?>
escapeHtml($column['label']) ?> + escapeHtml(__('Action')) ?> +
-
    - getServicesButtons() as $button): ?> -
  • - -
  • - -
-
+
    + getServicesButtons() as $button): ?> +
  • + escapeHtml($button['name']) ?> +
  • + +
+
- + - - -renderTag('script', [], $scriptString, false) ?>
From a91f178cd38add9d7b4db553dedce5b8c835b6da Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Mon, 20 Apr 2026 21:59:39 +0200 Subject: [PATCH 16/38] fix: harden admin form against injection + mark Services block final Code review follow-ups from Task 7: - escapeHtml() helper wraps every schema-sourced string emitted into the DOM via innerHTML-style concatenation. Whitelists input types to password/text. - Select-field rehydration preserves stored values that are no longer in the options list by prepending a synthetic '(legacy)' option, preventing silent data loss when model lists get refreshed. - Services block is now final and validates that each injected service implements AiServiceConfigurationInterface at construction time, with the closure in getServicesSchemaJson gaining the corresponding type hint. --- .../Adminhtml/Configuration/Services.php | 15 ++++++-- .../system/config/form/field/services.phtml | 35 +++++++++++++++---- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/src/Block/Adminhtml/Configuration/Services.php b/src/Block/Adminhtml/Configuration/Services.php index d8a4180..d2df998 100644 --- a/src/Block/Adminhtml/Configuration/Services.php +++ b/src/Block/Adminhtml/Configuration/Services.php @@ -9,8 +9,9 @@ use Magento\Framework\Serialize\Serializer\Json; use Magento\Framework\View\Helper\SecureHtmlRenderer; use MageOS\AiBase\Api\Data\AiServiceConfigurationInterface; +use MageOS\AiBase\Api\Data\FieldDescriptorInterface; -class Services extends AbstractFieldArray +final class Services extends AbstractFieldArray { protected $_template = 'MageOS_AiBase::system/config/form/field/services.phtml'; @@ -23,6 +24,16 @@ public function __construct( ?SecureHtmlRenderer $secureRenderer = null, ) { parent::__construct($context, $data, $secureRenderer); + + foreach ($this->services as $service) { + if (!$service instanceof AiServiceConfigurationInterface) { + throw new \InvalidArgumentException(sprintf( + 'Each registered service must implement %s, got %s', + AiServiceConfigurationInterface::class, + get_debug_type($service), + )); + } + } } /** @@ -47,7 +58,7 @@ public function getServicesSchemaJson(): string $schema = []; foreach ($this->services as $service) { $schema[$service->getCode()] = array_map( - fn ($field) => [ + fn (FieldDescriptorInterface $field) => [ 'name' => $field->getName(), 'label' => $field->getLabel(), 'type' => $field->getType(), diff --git a/src/view/adminhtml/templates/system/config/form/field/services.phtml b/src/view/adminhtml/templates/system/config/form/field/services.phtml index 30270e3..d4b920d 100644 --- a/src/view/adminhtml/templates/system/config/form/field/services.phtml +++ b/src/view/adminhtml/templates/system/config/form/field/services.phtml @@ -5,7 +5,7 @@ $_htmlId = $block->getHtmlId() ?: '_' . uniqid(); $_colspan = $block->isAddAfter() ? 2 : 1; ?> -
+
@@ -44,17 +44,28 @@ $_colspan = $block->isAddAfter() ? 2 : 1; const schema = getServicesSchemaJson() ?>; const fieldNameBase = 'escapeJs($block->getElement()->getName()) ?>'; + function escapeHtml(value) { + if (value === null || value === undefined) return ''; + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + function buildField(serviceCode, fieldName, field) { const base = fieldNameBase + '[' + fieldName + '][' + serviceCode + '][' + field.name + ']'; if (field.type === 'select') { const options = field.options.map(function (opt) { const selected = (field.default === opt.value) ? ' selected' : ''; - return ''; + return ''; }).join(''); - return ''; + return ''; } - const defaultAttr = field.default ? ' value="' + field.default + '"' : ''; - return ''; + const defaultAttr = field.default ? ' value="' + escapeHtml(field.default) + '"' : ''; + const type = (field.type === 'password' || field.type === 'text') ? field.type : 'text'; + return ''; } function addRow(serviceCode, values) { @@ -66,7 +77,7 @@ $_colspan = $block->isAddAfter() ? 2 : 1; const rows = fields.map(function (field) { return '' - + '' + + '' + '' + ''; }).join(''); @@ -84,7 +95,17 @@ $_colspan = $block->isAddAfter() ? 2 : 1; Object.keys(values).forEach(function (key) { const selector = '[name="' + fieldNameBase + '[' + rowId + '][' + serviceCode + '][' + key + ']"]'; const el = document.querySelector(selector); - if (el) el.value = values[key]; + if (!el) return; + if (el.tagName === 'SELECT') { + const hasOption = Array.from(el.options).some(function (opt) { return opt.value === values[key]; }); + if (!hasOption && values[key] !== undefined && values[key] !== null && values[key] !== '') { + const legacyOpt = document.createElement('option'); + legacyOpt.value = values[key]; + legacyOpt.text = values[key] + ' (legacy)'; + el.insertBefore(legacyOpt, el.firstChild); + } + } + el.value = values[key]; }); } } From e6eac5da989bab3809aebd9f7d3d0fabf8aa0c8d Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Mon, 20 Apr 2026 22:00:26 +0200 Subject: [PATCH 17/38] docs: capture Task 7 model-list freshness decision in Task 12 --- docs/plans/2026-04-20-done-done-plan.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/plans/2026-04-20-done-done-plan.md b/docs/plans/2026-04-20-done-done-plan.md index 94ed3b4..d411033 100644 --- a/docs/plans/2026-04-20-done-done-plan.md +++ b/docs/plans/2026-04-20-done-done-plan.md @@ -1322,6 +1322,7 @@ Before tagging: ``` (Keeps dev scaffolding out of the dist tarball that composer serves from a tagged release.) - Decide on LICENSE presentation: either keep the single `LICENSE.md` with both texts, or split into `LICENSE_OSL.md` + `LICENSE_AFL.md` to match the Mage-OS core convention and make GitHub's license detector show "Other (OSL-3.0 OR AFL-3.0)" correctly. Current choice: keep single file (documented here). +- Decide on model-list freshness for `AiServices/*`. Task 7 shipped "curated baseline" lists — Google's `gemini-2.0-pro` is a plausible typo (catalog jumped `1.5-pro` → `2.0-flash` → `2.5-pro` without a `2.0-pro` general release), and several other entries (`grok-2`, `o1-mini`) are superseded by newer releases in the 2026-04-20 catalog. Admins can override via `` per service, so these are defaults, not contracts. Options: (a) refresh every provider's list against their live catalog before tagging, (b) ship as-is and frame them in `CHANGELOG.md` as "seed defaults, override per-install", (c) thin the lists to only a single verified baseline model per provider + free-text for everything else. Recommended default for v1.0.0: option (b) — declarative config is the right place for admins to customise, and overclaiming "latest" in docs is the only real failure mode. Commit these together before Step 1 with message `chore: release-polish per code review (gitattributes, changelog hygiene)`. From 4f87c3568717ce0d8c37b992a5a5fceae56df8a0 Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Mon, 20 Apr 2026 22:03:30 +0200 Subject: [PATCH 18/38] test: add parametrised smoke test covering all 11 AiServices classes --- src/Test/Unit/AiServices/ServicesTest.php | 76 +++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 src/Test/Unit/AiServices/ServicesTest.php diff --git a/src/Test/Unit/AiServices/ServicesTest.php b/src/Test/Unit/AiServices/ServicesTest.php new file mode 100644 index 0000000..698442b --- /dev/null +++ b/src/Test/Unit/AiServices/ServicesTest.php @@ -0,0 +1,76 @@ +createMock(FieldDescriptorInterfaceFactory::class); + $stub->method('create')->willReturnCallback( + fn (array $data) => new FieldDescriptor( + name: $data['name'], + label: $data['label'], + type: $data['type'], + options: $data['options'] ?? [], + default: $data['default'] ?? null, + ) + ); + $this->fieldFactory = $stub; + } + + #[DataProvider('service_classes')] + public function test_service_exposes_required_metadata(string $className): void + { + /** @var AiServiceConfigurationInterface $service */ + $service = new $className($this->fieldFactory); + + self::assertNotEmpty($service->getCode(), "$className::getCode() must be non-empty"); + self::assertNotEmpty($service->getName(), "$className::getName() must be non-empty"); + + $fields = $service->getConfigurationFields(); + self::assertNotEmpty($fields, "$className::getConfigurationFields() must return at least one field"); + foreach ($fields as $field) { + self::assertInstanceOf(FieldDescriptorInterface::class, $field); + self::assertNotEmpty($field->getName()); + self::assertNotEmpty($field->getLabel()); + self::assertContains( + $field->getType(), + [FieldDescriptorInterface::TYPE_TEXT, FieldDescriptorInterface::TYPE_PASSWORD, FieldDescriptorInterface::TYPE_SELECT], + ); + } + + self::assertIsArray($service->getSupportedModels()); + } + + /** + * @return array + */ + public static function service_classes(): array + { + return [ + 'Anthropic' => [\MageOS\AiBase\AiServices\Anthropic::class], + 'Azure' => [\MageOS\AiBase\AiServices\Azure::class], + 'Deepseek' => [\MageOS\AiBase\AiServices\Deepseek::class], + 'Google' => [\MageOS\AiBase\AiServices\Google::class], + 'Grok' => [\MageOS\AiBase\AiServices\Grok::class], + 'HuggingFace'=> [\MageOS\AiBase\AiServices\HuggingFace::class], + 'LmStudio' => [\MageOS\AiBase\AiServices\LmStudio::class], + 'Ollama' => [\MageOS\AiBase\AiServices\Ollama::class], + 'OpenAi' => [\MageOS\AiBase\AiServices\OpenAi::class], + 'OpenRouter' => [\MageOS\AiBase\AiServices\OpenRouter::class], + 'Xai' => [\MageOS\AiBase\AiServices\Xai::class], + ]; + } +} From 8d1d0a204657a5027d15ad239b1b62dddcca48bf Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Mon, 20 Apr 2026 22:09:11 +0200 Subject: [PATCH 19/38] test: add integration test covering config round-trip through ScopeConfig --- phpunit.xml.dist | 15 +++++++ .../Model/AiServiceSelectorTest.php | 45 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 phpunit.xml.dist create mode 100644 src/Test/Integration/Model/AiServiceSelectorTest.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..ecdcc2e --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,15 @@ + + + + + src/Test/Unit + + + src/Test/Integration + + + diff --git a/src/Test/Integration/Model/AiServiceSelectorTest.php b/src/Test/Integration/Model/AiServiceSelectorTest.php new file mode 100644 index 0000000..c7fc068 --- /dev/null +++ b/src/Test/Integration/Model/AiServiceSelectorTest.php @@ -0,0 +1,45 @@ +get(WriterInterface::class); + $json = json_encode([ + '_row1' => ['openai' => ['apikey' => 'sk-test', 'model' => 'gpt-4o']], + '_row2' => ['anthropic' => ['apikey' => 'sk-ant', 'model' => 'claude-sonnet-4-6']], + ], JSON_THROW_ON_ERROR); + $configWriter->save('mageos_ai/services/configuration', $json); + + /** @var ScopeConfigInterface $scopeConfig */ + $scopeConfig = $objectManager->get(ScopeConfigInterface::class); + $scopeConfig->clean(); + + /** @var AiServiceSelectorInterface $selector */ + $selector = $objectManager->get(AiServiceSelectorInterface::class); + $services = $selector->getAll(); + + self::assertCount(2, $services); + self::assertSame('openai', $services[0]->getCode()); + self::assertSame(['apikey' => 'sk-test', 'model' => 'gpt-4o'], $services[0]->getConfiguration()); + self::assertSame('anthropic', $services[1]->getCode()); + + $openAiOnly = $selector->getByCode('openai'); + self::assertCount(1, $openAiOnly); + + $configWriter->delete('mageos_ai/services/configuration'); + } +} From cfd62d6ae66d364fb943ad55816cfaf6895676b3 Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Mon, 20 Apr 2026 22:12:17 +0200 Subject: [PATCH 20/38] test(integration): make cleanup failure-safe and tighten Config typing Review follow-up on Task 9: - Fixture delete now lives in tearDown() so a failed assertion cannot leak config rows into the test DB. - Cache-clean uses Magento\Framework\App\Config directly rather than calling ->clean() on a ScopeConfigInterface-typed variable, which is not a method on that interface. --- docs/plans/2026-04-20-done-done-plan.md | 35 +++++++++++-------- .../Model/AiServiceSelectorTest.php | 33 ++++++++++------- 2 files changed, 40 insertions(+), 28 deletions(-) diff --git a/docs/plans/2026-04-20-done-done-plan.md b/docs/plans/2026-04-20-done-done-plan.md index d411033..26ccb88 100644 --- a/docs/plans/2026-04-20-done-done-plan.md +++ b/docs/plans/2026-04-20-done-done-plan.md @@ -1144,36 +1144,43 @@ declare(strict_types=1); namespace MageOS\AiBase\Test\Integration\Model; -use Magento\Config\Model\ResourceModel\Config as ResourceConfig; -use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\App\Config; use Magento\Framework\App\Config\Storage\WriterInterface; -use Magento\Store\Model\ScopeInterface; +use Magento\Framework\ObjectManagerInterface; use Magento\TestFramework\Helper\Bootstrap; use MageOS\AiBase\Api\AiServiceSelectorInterface; use PHPUnit\Framework\TestCase; final class AiServiceSelectorTest extends TestCase { - public function test_round_trips_configuration_through_scope_config(): void + private ObjectManagerInterface $objectManager; + private WriterInterface $configWriter; + + protected function setUp(): void { - $objectManager = Bootstrap::getObjectManager(); + $this->objectManager = Bootstrap::getObjectManager(); + $this->configWriter = $this->objectManager->get(WriterInterface::class); + } - /** @var WriterInterface $configWriter */ - $configWriter = $objectManager->get(WriterInterface::class); + protected function tearDown(): void + { + $this->configWriter->delete('mageos_ai/services/configuration'); + } + + public function test_round_trips_configuration_through_scope_config(): void + { $json = json_encode([ '_row1' => ['openai' => ['apikey' => 'sk-test', 'model' => 'gpt-4o']], '_row2' => ['anthropic' => ['apikey' => 'sk-ant', 'model' => 'claude-sonnet-4-6']], ], JSON_THROW_ON_ERROR); - $configWriter->save('mageos_ai/services/configuration', $json); + $this->configWriter->save('mageos_ai/services/configuration', $json); - /** @var ScopeConfigInterface $scopeConfig */ - $scopeConfig = $objectManager->get(ScopeConfigInterface::class); - $scopeConfig->clean(); + $this->objectManager->get(Config::class)->clean(); /** @var AiServiceSelectorInterface $selector */ - $selector = $objectManager->get(AiServiceSelectorInterface::class); - $services = $selector->getAll(); + $selector = $this->objectManager->get(AiServiceSelectorInterface::class); + $services = $selector->getAll(); self::assertCount(2, $services); self::assertSame('openai', $services[0]->getCode()); self::assertSame(['apikey' => 'sk-test', 'model' => 'gpt-4o'], $services[0]->getConfiguration()); @@ -1181,8 +1188,6 @@ final class AiServiceSelectorTest extends TestCase $openAiOnly = $selector->getByCode('openai'); self::assertCount(1, $openAiOnly); - - $configWriter->delete('mageos_ai/services/configuration'); } } ``` diff --git a/src/Test/Integration/Model/AiServiceSelectorTest.php b/src/Test/Integration/Model/AiServiceSelectorTest.php index c7fc068..4b84547 100644 --- a/src/Test/Integration/Model/AiServiceSelectorTest.php +++ b/src/Test/Integration/Model/AiServiceSelectorTest.php @@ -4,34 +4,43 @@ namespace MageOS\AiBase\Test\Integration\Model; -use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\App\Config; use Magento\Framework\App\Config\Storage\WriterInterface; +use Magento\Framework\ObjectManagerInterface; use Magento\TestFramework\Helper\Bootstrap; use MageOS\AiBase\Api\AiServiceSelectorInterface; use PHPUnit\Framework\TestCase; final class AiServiceSelectorTest extends TestCase { - public function test_round_trips_configuration_through_scope_config(): void + private ObjectManagerInterface $objectManager; + private WriterInterface $configWriter; + + protected function setUp(): void + { + $this->objectManager = Bootstrap::getObjectManager(); + $this->configWriter = $this->objectManager->get(WriterInterface::class); + } + + protected function tearDown(): void { - $objectManager = Bootstrap::getObjectManager(); + $this->configWriter->delete('mageos_ai/services/configuration'); + } - /** @var WriterInterface $configWriter */ - $configWriter = $objectManager->get(WriterInterface::class); + public function test_round_trips_configuration_through_scope_config(): void + { $json = json_encode([ '_row1' => ['openai' => ['apikey' => 'sk-test', 'model' => 'gpt-4o']], '_row2' => ['anthropic' => ['apikey' => 'sk-ant', 'model' => 'claude-sonnet-4-6']], ], JSON_THROW_ON_ERROR); - $configWriter->save('mageos_ai/services/configuration', $json); + $this->configWriter->save('mageos_ai/services/configuration', $json); - /** @var ScopeConfigInterface $scopeConfig */ - $scopeConfig = $objectManager->get(ScopeConfigInterface::class); - $scopeConfig->clean(); + $this->objectManager->get(Config::class)->clean(); /** @var AiServiceSelectorInterface $selector */ - $selector = $objectManager->get(AiServiceSelectorInterface::class); - $services = $selector->getAll(); + $selector = $this->objectManager->get(AiServiceSelectorInterface::class); + $services = $selector->getAll(); self::assertCount(2, $services); self::assertSame('openai', $services[0]->getCode()); self::assertSame(['apikey' => 'sk-test', 'model' => 'gpt-4o'], $services[0]->getConfiguration()); @@ -39,7 +48,5 @@ public function test_round_trips_configuration_through_scope_config(): void $openAiOnly = $selector->getByCode('openai'); self::assertCount(1, $openAiOnly); - - $configWriter->delete('mageos_ai/services/configuration'); } } From a4207027e31d335eee77352dd1e06e703f97286e Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Mon, 20 Apr 2026 22:13:10 +0200 Subject: [PATCH 21/38] chore: add declare(strict_types=1) to remaining PHP files AiService gains final, and AiServiceInterface::getConfiguration() gets an array-shape docblock. registration.php only needed the declare line. --- src/Api/Data/AiServiceInterface.php | 5 +++++ src/Model/AiService.php | 4 +++- src/registration.php | 2 ++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Api/Data/AiServiceInterface.php b/src/Api/Data/AiServiceInterface.php index da82ea0..1d9d72a 100644 --- a/src/Api/Data/AiServiceInterface.php +++ b/src/Api/Data/AiServiceInterface.php @@ -1,10 +1,15 @@ + */ public function getConfiguration(): array; } diff --git a/src/Model/AiService.php b/src/Model/AiService.php index 0920505..8f777f0 100644 --- a/src/Model/AiService.php +++ b/src/Model/AiService.php @@ -1,10 +1,12 @@ Date: Tue, 21 Apr 2026 08:32:47 +0200 Subject: [PATCH 22/38] =?UTF-8?q?chore:=20release-polish=20=E2=80=94=20cor?= =?UTF-8?q?rect=20GitHub=20URLs,=20add=20.gitattributes,=20CHANGELOG=20hyg?= =?UTF-8?q?iene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit composer.json support URLs and CHANGELOG compare-links now point at the real mage-os-lab org. CHANGELOG gains Keep-a-Changelog preamble, [Unreleased] section, compare-link footer, and refreshes the 1.0.0 body to reflect review follow-ups (guard coverage, final Block, admin-form hardening). Release date bumped to 2026-04-21. .gitattributes keeps .github/, docs/, src/Test/, phpcs.xml.dist, phpunit.xml.dist, and CLAUDE.md out of the tarball Packagist serves from a tagged release. --- .gitattributes | 7 +++++++ CHANGELOG.md | 21 ++++++++++++++++----- composer.json | 4 ++-- 3 files changed, 25 insertions(+), 7 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..0f9f2bd --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +/.github export-ignore +/docs export-ignore +/src/Test export-ignore +/phpcs.xml.dist export-ignore +/phpunit.xml.dist export-ignore +/CLAUDE.md export-ignore +/.gitattributes export-ignore diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a37bcd..9589936 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,20 +2,31 @@ All notable changes to this project will be documented in this file. -## [1.0.0] - 2026-04-20 +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.0.0] - 2026-04-21 ### Added - Structured `FieldDescriptorInterface` config field schema replacing the HTML-template pattern. -- `getSupportedModels(): array` method on each service for non-hardcoded model lists. -- GitHub Actions CI via `graycoreio/github-actions-magento2/check-extension`. -- Unit test suite for `AiServiceSelector` and all eleven `AiServices/*` classes. -- Integration test covering round-trip of stored config through `ScopeConfigInterface`. +- `getSupportedModels(): array` method on each service for non-hardcoded model lists. Model lists ship as a curated baseline; admins may override per-install via a `` on each service class. +- GitHub Actions CI via `graycoreio/github-actions-magento2/check-extension`, matrix-targeted at `project: mage-os`. +- Unit test suite for `AiServiceSelector` (all four guards covered) and a parametrised smoke test exercising all eleven `AiServices/*` classes. +- Integration test covering round-trip of stored config through `ScopeConfigInterface`, with failure-safe cleanup in `tearDown()`. +- `AiServiceSelectorInterface` now documents its insertion-order contract. +- Admin form schema rendering hardens against HTML injection (client-side `escapeHtml()`) and preserves legacy stored values when the model list changes. ### Changed - **BREAKING:** `AiServiceConfigurationInterface::getConfigurationTemplate(): string` replaced by `::getConfigurationFields(): FieldDescriptorInterface[]` and `::getSupportedModels(): array`. - `composer.json` now pins `php: ^8.2` and `magento/framework: ^103.0 || ^104.0`. - `Model/AiServiceSelector` hardened against null scope values and malformed JSON. - `module.xml` declares explicit dependency on `Magento_Config` + `Magento_Backend`. +- `Block\Adminhtml\Configuration\Services` is now `final` with runtime validation that injected services implement `AiServiceConfigurationInterface`. ### Fixed - `README.md` API example now references the correct `AiServiceSelectorInterface` (previously cited `AiServiceConfigurationInterface`). + +[Unreleased]: https://github.com/mage-os-lab/module-ai-base/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/mage-os-lab/module-ai-base/releases/tag/v1.0.0 diff --git a/composer.json b/composer.json index bd1d954..fb730df 100755 --- a/composer.json +++ b/composer.json @@ -7,8 +7,8 @@ { "name": "David Lambauer", "email": "david@run-as-root.sh" } ], "support": { - "issues": "https://github.com/mage-os/module-ai-base/issues", - "source": "https://github.com/mage-os/module-ai-base" + "issues": "https://github.com/mage-os-lab/module-ai-base/issues", + "source": "https://github.com/mage-os-lab/module-ai-base" }, "require": { "php": "^8.2", From a721d3a9ddfeb1e3f20696407861a7a6d1efb0e6 Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Tue, 21 Apr 2026 08:38:35 +0200 Subject: [PATCH 23/38] ci: point check-extension at repo.mage-os.org for mage-os metapackage Graycore's check-extension reusable workflow defaults magento_repository to https://mirror.mage-os.org/, but that mirror only proxies magento/* packages. The mage-os/project-community-edition metapackage (emitted by supported-version) is published to https://repo.mage-os.org/, so composer create-project fails with "Could not find package mage-os/project-community-edition" and all four check-extension jobs abort in <15s with zero test signal. Overriding magento_repository on the reusable workflow call points Composer at the correct mage-os Composer repository. Staying pinned at @v5.1.0 (supply-chain) - the matrix itself is fine, only the repo URL was wrong. --- .github/workflows/check-extension.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/check-extension.yaml b/.github/workflows/check-extension.yaml index d982778..37529f9 100644 --- a/.github/workflows/check-extension.yaml +++ b/.github/workflows/check-extension.yaml @@ -23,3 +23,8 @@ jobs: uses: graycoreio/github-actions-magento2/.github/workflows/check-extension.yaml@v5.1.0 with: matrix: ${{ needs.compute-matrix.outputs.matrix }} + # The default mirror (https://mirror.mage-os.org/) only proxies magento/* + # packages; the mage-os/project-community-edition metapackage is published + # to https://repo.mage-os.org/. Without this override, composer fails with + # "Could not find package mage-os/project-community-edition". + magento_repository: https://repo.mage-os.org/ From 4259912127f4077eecf7e8cb846585425e64ee34 Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Tue, 21 Apr 2026 08:44:14 +0200 Subject: [PATCH 24/38] ci: align repo layout with Graycore check-extension conventions Move Test/ to package root so Graycore's PHPUnit test-discovery (...vendor//Test/Unit) locates our suites. Add the mage-os composer repo so the coding-standard job's composer install at module root can resolve magento/framework and mage-os/magento- coding-standard. Namespace (MageOS\\AiBase\\Test\\*) is unchanged; only the autoload- dev path mapping shifts from src/Test/ to Test/. --- .gitattributes | 2 +- .../Integration/Model/AiServiceSelectorTest.php | 0 {src/Test => Test}/Unit/AiServices/ServicesTest.php | 0 {src/Test => Test}/Unit/Model/AiServiceSelectorTest.php | 0 {src/Test => Test}/Unit/Model/FieldDescriptorTest.php | 0 composer.json | 8 +++++++- phpunit.xml.dist | 4 ++-- 7 files changed, 10 insertions(+), 4 deletions(-) rename {src/Test => Test}/Integration/Model/AiServiceSelectorTest.php (100%) rename {src/Test => Test}/Unit/AiServices/ServicesTest.php (100%) rename {src/Test => Test}/Unit/Model/AiServiceSelectorTest.php (100%) rename {src/Test => Test}/Unit/Model/FieldDescriptorTest.php (100%) diff --git a/.gitattributes b/.gitattributes index 0f9f2bd..dfe318b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,6 +1,6 @@ /.github export-ignore /docs export-ignore -/src/Test export-ignore +/Test export-ignore /phpcs.xml.dist export-ignore /phpunit.xml.dist export-ignore /CLAUDE.md export-ignore diff --git a/src/Test/Integration/Model/AiServiceSelectorTest.php b/Test/Integration/Model/AiServiceSelectorTest.php similarity index 100% rename from src/Test/Integration/Model/AiServiceSelectorTest.php rename to Test/Integration/Model/AiServiceSelectorTest.php diff --git a/src/Test/Unit/AiServices/ServicesTest.php b/Test/Unit/AiServices/ServicesTest.php similarity index 100% rename from src/Test/Unit/AiServices/ServicesTest.php rename to Test/Unit/AiServices/ServicesTest.php diff --git a/src/Test/Unit/Model/AiServiceSelectorTest.php b/Test/Unit/Model/AiServiceSelectorTest.php similarity index 100% rename from src/Test/Unit/Model/AiServiceSelectorTest.php rename to Test/Unit/Model/AiServiceSelectorTest.php diff --git a/src/Test/Unit/Model/FieldDescriptorTest.php b/Test/Unit/Model/FieldDescriptorTest.php similarity index 100% rename from src/Test/Unit/Model/FieldDescriptorTest.php rename to Test/Unit/Model/FieldDescriptorTest.php diff --git a/composer.json b/composer.json index fb730df..5338351 100755 --- a/composer.json +++ b/composer.json @@ -10,6 +10,12 @@ "issues": "https://github.com/mage-os-lab/module-ai-base/issues", "source": "https://github.com/mage-os-lab/module-ai-base" }, + "repositories": { + "mage-os": { + "type": "composer", + "url": "https://repo.mage-os.org/" + } + }, "require": { "php": "^8.2", "magento/framework": "^103.0 || ^104.0" @@ -23,6 +29,6 @@ "psr-4": { "MageOS\\AiBase\\": "src/" } }, "autoload-dev": { - "psr-4": { "MageOS\\AiBase\\Test\\": "src/Test/" } + "psr-4": { "MageOS\\AiBase\\Test\\": "Test/" } } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index ecdcc2e..990e562 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -6,10 +6,10 @@ cacheDirectory=".phpunit.cache"> - src/Test/Unit + Test/Unit - src/Test/Integration + Test/Integration From 715354f165249e2f58007ba8f0ad76358ba618e4 Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Tue, 21 Apr 2026 08:49:36 +0200 Subject: [PATCH 25/38] ci: add mage-os composer mirror so magento/framework resolves coding-standard job installs magento/magento-coding-standard + magento/php-compatibility-fork at module root, which pulls in magento/framework. That lives on mirror.mage-os.org (serves magento/* namespace), not repo.mage-os.org (serves mage-os/*). Adding both repos with mirror listed first. --- composer.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/composer.json b/composer.json index 5338351..92fcae2 100755 --- a/composer.json +++ b/composer.json @@ -11,6 +11,10 @@ "source": "https://github.com/mage-os-lab/module-ai-base" }, "repositories": { + "mage-os-mirror": { + "type": "composer", + "url": "https://mirror.mage-os.org/" + }, "mage-os": { "type": "composer", "url": "https://repo.mage-os.org/" From 43fdca1e6cb4ea5e8eaccb7121346e3fe9d6927c Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Tue, 21 Apr 2026 08:53:25 +0200 Subject: [PATCH 26/38] ci: allow magento composer plugins pulled in via mirror With mirror.mage-os.org added, composer require pulls in magento/composer-dependency-version-audit-plugin as a transitive dependency. Composer 2.2+ requires explicit allow-plugins opt-in, and the coding-standard job was aborting on the interactive prompt. Also pre-allows dealerdirect/phpcodesniffer-composer-installer (used by magento-coding-standard) and magento/composer-root-update-plugin so future installs don't re-prompt. --- composer.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/composer.json b/composer.json index 92fcae2..5401c3d 100755 --- a/composer.json +++ b/composer.json @@ -34,5 +34,12 @@ }, "autoload-dev": { "psr-4": { "MageOS\\AiBase\\Test\\": "Test/" } + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true, + "magento/composer-dependency-version-audit-plugin": true, + "magento/composer-root-update-plugin": true + } } } From 9d30c8cf32ec999c45be6485ef62d64a2ae0584c Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Tue, 21 Apr 2026 08:59:02 +0200 Subject: [PATCH 27/38] ci: bump Graycore to v6.0.0 for Mage-OS 2.2.0 support v6.0.0 adds explicit MageOS 2.2.0 to the supported-version matrix, which matches our target demo environment. Also potentially fixes two v5.1.0 bugs: coding-standard hardcoding vendor/magento/* paths instead of handling mage-os-namespaced packages, and integration_test referencing undefined matrix.services when include_services is false. Re-pinning to a tag (not @main) keeps supply-chain risk bounded. --- .github/workflows/check-extension.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check-extension.yaml b/.github/workflows/check-extension.yaml index 37529f9..9a79ece 100644 --- a/.github/workflows/check-extension.yaml +++ b/.github/workflows/check-extension.yaml @@ -13,14 +13,14 @@ jobs: matrix: ${{ steps.supported.outputs.matrix }} steps: - uses: actions/checkout@v4 - - uses: graycoreio/github-actions-magento2/supported-version@v5.1.0 + - uses: graycoreio/github-actions-magento2/supported-version@v6.0.0 id: supported with: project: mage-os check-extension: needs: compute-matrix - uses: graycoreio/github-actions-magento2/.github/workflows/check-extension.yaml@v5.1.0 + uses: graycoreio/github-actions-magento2/.github/workflows/check-extension.yaml@v6.0.0 with: matrix: ${{ needs.compute-matrix.outputs.matrix }} # The default mirror (https://mirror.mage-os.org/) only proxies magento/* From ee47a87ddcb8208e6bff667cc6129791890b5ba5 Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Tue, 21 Apr 2026 09:03:26 +0200 Subject: [PATCH 28/38] Revert "ci: bump Graycore to v6.0.0 for Mage-OS 2.2.0 support" v6.0.0 introduced a template-validation regression in the integration_test job (line 202 references an unset include_services value) and didn't fix the coding-standard Magento2-sniff-not-found issue either. v5.1.0 had 3/5 jobs passing; v6.0.0 had the same 2 failures plus one new one. Net regression. Reopening the upstream-compat question as a follow-up; v5.1.0 ships. --- .github/workflows/check-extension.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check-extension.yaml b/.github/workflows/check-extension.yaml index 9a79ece..37529f9 100644 --- a/.github/workflows/check-extension.yaml +++ b/.github/workflows/check-extension.yaml @@ -13,14 +13,14 @@ jobs: matrix: ${{ steps.supported.outputs.matrix }} steps: - uses: actions/checkout@v4 - - uses: graycoreio/github-actions-magento2/supported-version@v6.0.0 + - uses: graycoreio/github-actions-magento2/supported-version@v5.1.0 id: supported with: project: mage-os check-extension: needs: compute-matrix - uses: graycoreio/github-actions-magento2/.github/workflows/check-extension.yaml@v6.0.0 + uses: graycoreio/github-actions-magento2/.github/workflows/check-extension.yaml@v5.1.0 with: matrix: ${{ needs.compute-matrix.outputs.matrix }} # The default mirror (https://mirror.mage-os.org/) only proxies magento/* From 593ebae41c93de0258a7b65e9157952823786c2c Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Tue, 21 Apr 2026 15:01:39 +0200 Subject: [PATCH 29/38] docs: add community standards design doc --- .../2026-04-21-community-standards-design.md | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 docs/plans/2026-04-21-community-standards-design.md diff --git a/docs/plans/2026-04-21-community-standards-design.md b/docs/plans/2026-04-21-community-standards-design.md new file mode 100644 index 0000000..0a90d9b --- /dev/null +++ b/docs/plans/2026-04-21-community-standards-design.md @@ -0,0 +1,106 @@ +# Community Standards Design + +**Date:** 2026-04-21 +**Scope:** Add the community health files recommended by opensource.guide and GitHub's "Community Standards" checklist to `mage-os/module-ai-base`. + +## Goal + +Bring the repo up to GitHub's "Community Standards" green-check state with the essentials: Code of Conduct, Contributing guide, Security policy, issue templates, and a PR template. Scope is deliberately narrow — no governance docs, no roadmap, no maintainers list. + +## Files to add + +``` +CODE_OF_CONDUCT.md +CONTRIBUTING.md +SECURITY.md +.github/ISSUE_TEMPLATE/bug_report.yml +.github/ISSUE_TEMPLATE/feature_request.yml +.github/ISSUE_TEMPLATE/config.yml +.github/PULL_REQUEST_TEMPLATE.md +``` + +## Files to modify + +- `README.md` — add short **Contributing** and **Security** sections linking to the new files. + +## Content specification + +### CODE_OF_CONDUCT.md +Verbatim Contributor Covenant v2.1. Enforcement contact: `david@run-as-root.sh`. + +### CONTRIBUTING.md +Short, practical. Sections: +- **Reporting bugs** — link to issue template. +- **Proposing changes** — link to feature-request template; encourage an issue before a large PR. +- **Local development** — use a composer path repository into a Magento 2 install; `bin/magento module:enable MageOS_AiBase && bin/magento setup:upgrade && bin/magento setup:di:compile`. +- **Coding conventions** — references `phpcs.xml.dist` for PHPCS rules; match existing style (PHP 8 constructor property promotion + `readonly`; no `declare(strict_types=1)` to stay consistent with surrounding code). +- **Branching** — `feat/*`, `fix/*` branches off `main`; PRs target `main`. +- **Tests** — run via `vendor/bin/phpunit -c phpunit.xml.dist`. +- **Commit style** — conventional-style prefix recommended but not enforced. + +### SECURITY.md +- **Supported versions** — only the latest released version. +- **Reporting** — preferred channel is GitHub Private Vulnerability Reporting; fallback via email to `david@run-as-root.sh` **or** `security@mage-os.org`. +- **What to include** — reproduction steps, affected version, expected impact. +- **Response SLA** — acknowledge within 7 days. +- **Disclosure** — please do not open public issues for vulnerabilities. + +### .github/ISSUE_TEMPLATE/bug_report.yml +GitHub issue form. Fields: +- Module version (text, required) +- Magento version (text, required) +- Expected behavior (textarea, required) +- Actual behavior (textarea, required) +- Reproduction steps (textarea, required) +- Logs / stack trace (textarea, optional) + +### .github/ISSUE_TEMPLATE/feature_request.yml +GitHub issue form. Fields: +- Problem (textarea, required) +- Proposed solution (textarea, required) +- Alternatives considered (textarea, optional) + +### .github/ISSUE_TEMPLATE/config.yml +```yaml +blank_issues_enabled: false +``` +No `contact_links` — user declined. + +### .github/PULL_REQUEST_TEMPLATE.md +Sections: +- Summary +- Linked issue (`Closes #`) +- Change type checklist (bug fix / feature / docs / chore / breaking) +- Test plan +- Docs updated (checkbox) + +### README.md additions +Two short sections near the end: + +```markdown +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) and our [Code of Conduct](CODE_OF_CONDUCT.md). + +## Security + +Security issues: see [SECURITY.md](SECURITY.md) — please do **not** file public issues for vulnerabilities. +``` + +## Out of scope + +- GOVERNANCE.md, MAINTAINERS.md, roadmap — overkill for this module. +- SUPPORT.md — no dedicated support channel to point at. +- README badges — separate concern. +- FUNDING.yml — not requested. + +## Success criteria + +- GitHub repo "Community Standards" page shows all items checked. +- Opening a new issue from the GitHub UI presents the two templates (bug / feature) and hides the blank option. +- Opening a new PR pre-fills the PR template. +- `SECURITY.md` surfaces the "Report a vulnerability" button on the repo's Security tab (requires Private Vulnerability Reporting to be enabled in repo settings — noted as an operational follow-up, not a code change). + +## Operational follow-up (not code) + +- Enable **Private Vulnerability Reporting** in repo Settings → Code security & analysis, so the link in `SECURITY.md` resolves to the built-in reporting UI. From 8470464e3e1186c7d917e8799ff68e998df844fa Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Tue, 21 Apr 2026 15:09:44 +0200 Subject: [PATCH 30/38] docs: add community standards implementation plan --- .../2026-04-21-community-standards-plan.md | 472 ++++++++++++++++++ 1 file changed, 472 insertions(+) create mode 100644 docs/plans/2026-04-21-community-standards-plan.md diff --git a/docs/plans/2026-04-21-community-standards-plan.md b/docs/plans/2026-04-21-community-standards-plan.md new file mode 100644 index 0000000..b622611 --- /dev/null +++ b/docs/plans/2026-04-21-community-standards-plan.md @@ -0,0 +1,472 @@ +# Community Standards Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add GitHub "Community Standards" files (Code of Conduct, Contributing, Security, issue + PR templates) to `mage-os/module-ai-base`. + +**Architecture:** Static markdown + YAML files in the repo root and `.github/`. No code paths are touched. Each file is self-contained; tasks are independent and can be done in any order. + +**Tech Stack:** Markdown, GitHub issue forms (YAML). + +**Design doc:** `docs/plans/2026-04-21-community-standards-design.md` + +**Branch:** Work on the currently-checked-out branch (`feat/v1.0.0-release`). Do NOT create a new branch. + +**Verification approach:** This repo has no markdown linter or YAML linter configured. Verification per task = visual inspection of the rendered file + a `python -c "import yaml; yaml.safe_load(open('...'))"` parse check for the two issue form YAMLs. + +--- + +### Task 1: Add CODE_OF_CONDUCT.md + +**Files:** +- Create: `CODE_OF_CONDUCT.md` + +**Step 1: Fetch the Contributor Covenant v2.1 text** + +Download the canonical markdown version: + +```bash +curl -fsSL https://raw.githubusercontent.com/EthicalSource/contributor_covenant/release/content/version/2/1/code_of_conduct.md -o CODE_OF_CONDUCT.md +``` + +**Step 2: Substitute the enforcement contact** + +The template contains the literal placeholder `[INSERT CONTACT METHOD]`. Replace it with `david@run-as-root.sh`. + +Use Edit tool: +- old_string: `[INSERT CONTACT METHOD]` +- new_string: `david@run-as-root.sh` + +**Step 3: Verify** + +```bash +grep -c "david@run-as-root.sh" CODE_OF_CONDUCT.md # expect: 1 +grep -c "INSERT CONTACT METHOD" CODE_OF_CONDUCT.md # expect: 0 +head -3 CODE_OF_CONDUCT.md # expect: "# Contributor Covenant Code of Conduct" +``` + +**Step 4: Commit** + +```bash +git add CODE_OF_CONDUCT.md +git commit -m "docs: add Contributor Covenant v2.1 code of conduct" +``` + +--- + +### Task 2: Add CONTRIBUTING.md + +**Files:** +- Create: `CONTRIBUTING.md` + +**Step 1: Write the file** + +Contents: + +```markdown +# Contributing to MageOS_AiBase + +Thanks for your interest in improving this module. This guide covers how to report issues, propose changes, and develop locally. + +## Reporting bugs + +Open an issue using the **Bug report** template. Please include your Magento version, module version, and reproduction steps. + +## Proposing changes + +For anything larger than a typo fix, please open a **Feature request** issue first so we can align on the approach before you invest time in a PR. + +## Local development + +This repo contains a Magento 2 module, not a runnable Magento instance. To work on it: + +1. Clone this repository outside your Magento install. +2. In your Magento 2 project, add a Composer path repository pointing at your clone: + ```json + "repositories": [ + { "type": "path", "url": "/absolute/path/to/module-ai-base" } + ] + ``` +3. Require the module and enable it: + ```bash + composer require mage-os/module-ai-base:@dev + bin/magento module:enable MageOS_AiBase + bin/magento setup:upgrade + bin/magento setup:di:compile + ``` + +## Coding conventions + +- PHP 8 constructor property promotion with `readonly` is the norm — match it for new classes. +- No `declare(strict_types=1)` header is used in existing files; keep things consistent unless you're explicitly modernizing in a dedicated PR. +- Coding standard: see `phpcs.xml.dist`. Run `vendor/bin/phpcs` from a host Magento install that includes this module. + +## Branching and pull requests + +- Branch off `main` using `feat/` or `fix/`. +- Target PRs at `main`. +- Keep PRs focused — one concern per PR. +- Fill in the PR template (summary, linked issue, test plan). + +## Tests + +Run the test suite with: + +```bash +vendor/bin/phpunit -c phpunit.xml.dist +``` + +New PHP unit tests must be `final` classes and use `snake_case` method names. + +## Commit messages + +Conventional-style prefixes (`feat:`, `fix:`, `docs:`, `ci:`, `chore:`) are encouraged but not enforced. +``` + +**Step 2: Verify** + +```bash +head -1 CONTRIBUTING.md # expect: "# Contributing to MageOS_AiBase" +wc -l CONTRIBUTING.md # sanity check: file is non-trivial +``` + +**Step 3: Commit** + +```bash +git add CONTRIBUTING.md +git commit -m "docs: add CONTRIBUTING guide" +``` + +--- + +### Task 3: Add SECURITY.md + +**Files:** +- Create: `SECURITY.md` + +**Step 1: Write the file** + +Contents: + +```markdown +# Security Policy + +## Supported versions + +Only the **latest released version** receives security updates. + +## Reporting a vulnerability + +Please do **not** open public GitHub issues for security vulnerabilities. + +Preferred channel: **GitHub Private Vulnerability Reporting**. Go to the [Security tab](../../security/advisories/new) of this repository and click "Report a vulnerability". + +If you cannot use GitHub, email one of: + +- `david@run-as-root.sh` +- `security@mage-os.org` + +Please include: + +- Affected module version and Magento version +- Reproduction steps or proof-of-concept +- Expected impact + +We aim to acknowledge reports within **7 days**. Once a fix is available, we will coordinate a disclosure timeline with you. +``` + +**Step 2: Verify** + +```bash +head -1 SECURITY.md # expect: "# Security Policy" +grep -c "security@mage-os.org" SECURITY.md # expect: 1 +grep -c "david@run-as-root.sh" SECURITY.md # expect: 1 +``` + +**Step 3: Commit** + +```bash +git add SECURITY.md +git commit -m "docs: add SECURITY policy" +``` + +--- + +### Task 4: Add issue template config + +**Files:** +- Create: `.github/ISSUE_TEMPLATE/config.yml` + +**Step 1: Write the file** + +```yaml +blank_issues_enabled: false +``` + +**Step 2: Verify it parses as YAML** + +```bash +python3 -c "import yaml; print(yaml.safe_load(open('.github/ISSUE_TEMPLATE/config.yml')))" +``` +Expected output: `{'blank_issues_enabled': False}` + +**Step 3: Commit** + +```bash +git add .github/ISSUE_TEMPLATE/config.yml +git commit -m "ci: disable blank issues" +``` + +--- + +### Task 5: Add bug report issue form + +**Files:** +- Create: `.github/ISSUE_TEMPLATE/bug_report.yml` + +**Step 1: Write the file** + +```yaml +name: Bug report +description: Report a bug in MageOS_AiBase +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file a bug report. Please fill in the fields below. + - type: input + id: module-version + attributes: + label: Module version + placeholder: "1.0.0" + validations: + required: true + - type: input + id: magento-version + attributes: + label: Magento / Mage-OS version + placeholder: "Magento 2.4.7 / Mage-OS 2.2.0" + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What did you expect to happen? + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + description: What actually happened? + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Reproduction steps + description: Minimal steps to reproduce the issue. + placeholder: | + 1. Go to ... + 2. Click on ... + 3. See error + validations: + required: true + - type: textarea + id: logs + attributes: + label: Logs / stack trace + description: Any relevant output from var/log/ or the browser console. + render: shell + validations: + required: false +``` + +**Step 2: Verify** + +```bash +python3 -c "import yaml; d = yaml.safe_load(open('.github/ISSUE_TEMPLATE/bug_report.yml')); assert d['name'] == 'Bug report'; assert len(d['body']) >= 6; print('ok')" +``` +Expected output: `ok` + +**Step 3: Commit** + +```bash +git add .github/ISSUE_TEMPLATE/bug_report.yml +git commit -m "ci: add bug report issue form" +``` + +--- + +### Task 6: Add feature request issue form + +**Files:** +- Create: `.github/ISSUE_TEMPLATE/feature_request.yml` + +**Step 1: Write the file** + +```yaml +name: Feature request +description: Suggest a new feature or enhancement +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What problem are you trying to solve? Who is affected? + validations: + required: true + - type: textarea + id: solution + attributes: + label: Proposed solution + description: How would you like this to work? + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Any other approaches you considered and why you ruled them out. + validations: + required: false +``` + +**Step 2: Verify** + +```bash +python3 -c "import yaml; d = yaml.safe_load(open('.github/ISSUE_TEMPLATE/feature_request.yml')); assert d['name'] == 'Feature request'; print('ok')" +``` +Expected output: `ok` + +**Step 3: Commit** + +```bash +git add .github/ISSUE_TEMPLATE/feature_request.yml +git commit -m "ci: add feature request issue form" +``` + +--- + +### Task 7: Add PR template + +**Files:** +- Create: `.github/PULL_REQUEST_TEMPLATE.md` + +**Step 1: Write the file** + +```markdown +## Summary + + + +## Linked issue + +Closes # + +## Change type + +- [ ] Bug fix +- [ ] New feature +- [ ] Documentation +- [ ] Chore / refactor / CI +- [ ] Breaking change + +## Test plan + + + +## Checklist + +- [ ] Tests added or updated +- [ ] Docs / README updated if behavior changed +- [ ] `phpcs` and `phpunit` pass locally +``` + +**Step 2: Verify** + +```bash +head -1 .github/PULL_REQUEST_TEMPLATE.md # expect: "## Summary" +``` + +**Step 3: Commit** + +```bash +git add .github/PULL_REQUEST_TEMPLATE.md +git commit -m "ci: add pull request template" +``` + +--- + +### Task 8: Link community files from README + +**Files:** +- Modify: `README.md` (append two sections to end) + +**Step 1: Read current README end** + +Use Read tool on `README.md` to see current final line (expected: line 47 with the closing backticks of the code block). + +**Step 2: Append Contributing + Security sections** + +Use Edit tool to replace the final line of the file. Exact transformation: + +- old_string: (the last line of the existing README plus a trailing newline — verify with Read first) +- new_string: (same final line) followed by: + +```markdown + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) and our [Code of Conduct](CODE_OF_CONDUCT.md). + +## Security + +Security issues: see [SECURITY.md](SECURITY.md). Please do **not** file public issues for vulnerabilities. +``` + +**Step 3: Verify** + +```bash +tail -8 README.md # expect the two new sections +grep -c "CONTRIBUTING.md" README.md # expect: 1 +grep -c "SECURITY.md" README.md # expect: 1 +grep -c "CODE_OF_CONDUCT.md" README.md # expect: 1 +``` + +**Step 4: Commit** + +```bash +git add README.md +git commit -m "docs: link community files from README" +``` + +--- + +### Task 9: Operational follow-up (not code) + +Remind the user to enable **Private Vulnerability Reporting** in the repo settings: + +> GitHub → repository **Settings** → **Code security and analysis** → **Private vulnerability reporting** → **Enable**. + +This is required for the `../../security/advisories/new` link in `SECURITY.md` to resolve to a working reporting form. Do not push this task onto the executor — just surface it in the final summary. + +--- + +## Global success criteria + +After all tasks are done: + +```bash +git status # clean +git log --oneline -10 # 8 new commits in order Task 1..8 +ls CODE_OF_CONDUCT.md CONTRIBUTING.md SECURITY.md +ls .github/ISSUE_TEMPLATE/*.yml .github/PULL_REQUEST_TEMPLATE.md +``` + +All files should exist. The repo's GitHub **Insights → Community Standards** page should then show a full green check once pushed. From 793c8f937ba602131ef898b0d6469c9798edf38c Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Tue, 21 Apr 2026 15:30:42 +0200 Subject: [PATCH 31/38] docs: add Contributor Covenant v2.1 code of conduct --- CODE_OF_CONDUCT.md | 83 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 CODE_OF_CONDUCT.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..c065d1c --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,83 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at david@run-as-root.sh. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations From 77f8ed47b33b8a7fd3dd12cc09a27041991e1460 Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Tue, 21 Apr 2026 15:31:02 +0200 Subject: [PATCH 32/38] docs: add CONTRIBUTING guide --- CONTRIBUTING.md | 57 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6d4b6ac --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,57 @@ +# Contributing to MageOS_AiBase + +Thanks for your interest in improving this module. This guide covers how to report issues, propose changes, and develop locally. + +## Reporting bugs + +Open an issue using the **Bug report** template. Please include your Magento version, module version, and reproduction steps. + +## Proposing changes + +For anything larger than a typo fix, please open a **Feature request** issue first so we can align on the approach before you invest time in a PR. + +## Local development + +This repo contains a Magento 2 module, not a runnable Magento instance. To work on it: + +1. Clone this repository outside your Magento install. +2. In your Magento 2 project, add a Composer path repository pointing at your clone: + ```json + "repositories": [ + { "type": "path", "url": "/absolute/path/to/module-ai-base" } + ] + ``` +3. Require the module and enable it: + ```bash + composer require mage-os/module-ai-base:@dev + bin/magento module:enable MageOS_AiBase + bin/magento setup:upgrade + bin/magento setup:di:compile + ``` + +## Coding conventions + +- PHP 8 constructor property promotion with `readonly` is the norm — match it for new classes. +- No `declare(strict_types=1)` header is used in existing files; keep things consistent unless you're explicitly modernizing in a dedicated PR. +- Coding standard: see `phpcs.xml.dist`. Run `vendor/bin/phpcs` from a host Magento install that includes this module. + +## Branching and pull requests + +- Branch off `main` using `feat/` or `fix/`. +- Target PRs at `main`. +- Keep PRs focused — one concern per PR. +- Fill in the PR template (summary, linked issue, test plan). + +## Tests + +Run the test suite with: + +```bash +vendor/bin/phpunit -c phpunit.xml.dist +``` + +New PHP unit tests must be `final` classes and use `snake_case` method names. + +## Commit messages + +Conventional-style prefixes (`feat:`, `fix:`, `docs:`, `ci:`, `chore:`) are encouraged but not enforced. From 5127efb0fba5c02c601ac668411d2b16d5268494 Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Tue, 21 Apr 2026 15:31:15 +0200 Subject: [PATCH 33/38] docs: add SECURITY policy --- SECURITY.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..fcef1a7 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,24 @@ +# Security Policy + +## Supported versions + +Only the **latest released version** receives security updates. + +## Reporting a vulnerability + +Please do **not** open public GitHub issues for security vulnerabilities. + +Preferred channel: **GitHub Private Vulnerability Reporting**. Go to the [Security tab](../../security/advisories/new) of this repository and click "Report a vulnerability". + +If you cannot use GitHub, email one of: + +- `david@run-as-root.sh` +- `security@mage-os.org` + +Please include: + +- Affected module version and Magento version +- Reproduction steps or proof-of-concept +- Expected impact + +We aim to acknowledge reports within **7 days**. Once a fix is available, we will coordinate a disclosure timeline with you. From b45ef8a543c8ad5c4688c0c910c5aee04bb54406 Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Tue, 21 Apr 2026 15:31:27 +0200 Subject: [PATCH 34/38] ci: disable blank issues --- .github/ISSUE_TEMPLATE/config.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false From e0c6d36e7cc420d023a17c6b651b92a7995a77e6 Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Tue, 21 Apr 2026 15:31:41 +0200 Subject: [PATCH 35/38] ci: add bug report issue form --- .github/ISSUE_TEMPLATE/bug_report.yml | 56 +++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..eb99f0e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,56 @@ +name: Bug report +description: Report a bug in MageOS_AiBase +title: "[Bug]: " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file a bug report. Please fill in the fields below. + - type: input + id: module-version + attributes: + label: Module version + placeholder: "1.0.0" + validations: + required: true + - type: input + id: magento-version + attributes: + label: Magento / Mage-OS version + placeholder: "Magento 2.4.7 / Mage-OS 2.2.0" + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What did you expect to happen? + validations: + required: true + - type: textarea + id: actual + attributes: + label: Actual behavior + description: What actually happened? + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Reproduction steps + description: Minimal steps to reproduce the issue. + placeholder: | + 1. Go to ... + 2. Click on ... + 3. See error + validations: + required: true + - type: textarea + id: logs + attributes: + label: Logs / stack trace + description: Any relevant output from var/log/ or the browser console. + render: shell + validations: + required: false From 9b33d0b7ab44adb5f5a130296ac52a6029ea1632 Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Tue, 21 Apr 2026 15:31:50 +0200 Subject: [PATCH 36/38] ci: add feature request issue form --- .github/ISSUE_TEMPLATE/feature_request.yml | 26 ++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..6d2eb22 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,26 @@ +name: Feature request +description: Suggest a new feature or enhancement +title: "[Feature]: " +labels: ["enhancement"] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What problem are you trying to solve? Who is affected? + validations: + required: true + - type: textarea + id: solution + attributes: + label: Proposed solution + description: How would you like this to work? + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Any other approaches you considered and why you ruled them out. + validations: + required: false From c31af7ac0d94d6087c6bf1853631a9d330e56171 Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Tue, 21 Apr 2026 15:32:01 +0200 Subject: [PATCH 37/38] ci: add pull request template --- .github/PULL_REQUEST_TEMPLATE.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..11ea7ce --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,25 @@ +## Summary + + + +## Linked issue + +Closes # + +## Change type + +- [ ] Bug fix +- [ ] New feature +- [ ] Documentation +- [ ] Chore / refactor / CI +- [ ] Breaking change + +## Test plan + + + +## Checklist + +- [ ] Tests added or updated +- [ ] Docs / README updated if behavior changed +- [ ] `phpcs` and `phpunit` pass locally From 45fe0a4a3f21180eacd4bb21971a47ab9e6c0141 Mon Sep 17 00:00:00 2001 From: David Lambauer Date: Tue, 21 Apr 2026 15:32:14 +0200 Subject: [PATCH 38/38] docs: link community files from README --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index be67384..760ed54 100644 --- a/README.md +++ b/README.md @@ -44,3 +44,12 @@ final class MyAiFunctionality } } ``` + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) and our [Code of Conduct](CODE_OF_CONDUCT.md). + +## Security + +Security issues: see [SECURITY.md](SECURITY.md). Please do **not** file public issues for vulnerabilities. +
' + field.label + '' + escapeHtml(field.label) + '' + buildField(serviceCode, rowId, field) + '