From 0b7cc130cecd616c8fc604d0f7523c1e6049ea9c Mon Sep 17 00:00:00 2001 From: Mark Shust Date: Wed, 27 May 2026 08:37:25 -0400 Subject: [PATCH 1/5] docs(known-drivers-registry): add WIP plan for driver registry pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Curated known-drivers.php per interface package as the single source of truth for each driver list. CI tests enforce sync between the file, each driver's composer.json conflict block, and skeleton's suggest block. Eliminates the three-locations-drift problem when adopting new drivers. 25 tasks, ~5 parallel batches. WIP — depends on PR #88 (view-twig) merging first so task 017 can be expanded to cover both view drivers. Relates to #89 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../001-known-drivers-validator-helper.md | 48 ++++ .../002-pilot-database-known-drivers.md | 43 ++++ ...t-refactor-database-no-driver-exception.md | 81 +++++++ .../004-pilot-database-conflict-blocks.md | 34 +++ .../005-pilot-database-validation-test.md | 56 +++++ .../006-errors-advanced-url-linkification.md | 84 +++++++ .../007-view-test-cleanup.md | 37 +++ .../008-rollout-cache.md | 42 ++++ .../009-rollout-errors.md | 40 ++++ .../010-rollout-filesystem.md | 40 ++++ .../011-rollout-inertia.md | 44 ++++ .../012-rollout-mail.md | 40 ++++ .../013-rollout-media.md | 40 ++++ .../014-rollout-pubsub.md | 40 ++++ .../015-rollout-queue.md | 41 ++++ .../016-rollout-session.md | 39 ++++ .../017-rollout-view.md | 47 ++++ .../018-rollout-authentication.md | 35 +++ .../019-rollout-encryption.md | 33 +++ .../020-rollout-http.md | 32 +++ .../known-drivers-registry/021-rollout-log.md | 32 +++ .../022-rollout-notification.md | 32 +++ .../023-rollout-translation.md | 32 +++ .../024-rollout-page-cache.md | 39 ++++ .../025-skeleton-suggest-consolidation.md | 125 ++++++++++ .claude/plans/known-drivers-registry/_plan.md | 221 ++++++++++++++++++ 26 files changed, 1377 insertions(+) create mode 100644 .claude/plans/known-drivers-registry/001-known-drivers-validator-helper.md create mode 100644 .claude/plans/known-drivers-registry/002-pilot-database-known-drivers.md create mode 100644 .claude/plans/known-drivers-registry/003-pilot-refactor-database-no-driver-exception.md create mode 100644 .claude/plans/known-drivers-registry/004-pilot-database-conflict-blocks.md create mode 100644 .claude/plans/known-drivers-registry/005-pilot-database-validation-test.md create mode 100644 .claude/plans/known-drivers-registry/006-errors-advanced-url-linkification.md create mode 100644 .claude/plans/known-drivers-registry/007-view-test-cleanup.md create mode 100644 .claude/plans/known-drivers-registry/008-rollout-cache.md create mode 100644 .claude/plans/known-drivers-registry/009-rollout-errors.md create mode 100644 .claude/plans/known-drivers-registry/010-rollout-filesystem.md create mode 100644 .claude/plans/known-drivers-registry/011-rollout-inertia.md create mode 100644 .claude/plans/known-drivers-registry/012-rollout-mail.md create mode 100644 .claude/plans/known-drivers-registry/013-rollout-media.md create mode 100644 .claude/plans/known-drivers-registry/014-rollout-pubsub.md create mode 100644 .claude/plans/known-drivers-registry/015-rollout-queue.md create mode 100644 .claude/plans/known-drivers-registry/016-rollout-session.md create mode 100644 .claude/plans/known-drivers-registry/017-rollout-view.md create mode 100644 .claude/plans/known-drivers-registry/018-rollout-authentication.md create mode 100644 .claude/plans/known-drivers-registry/019-rollout-encryption.md create mode 100644 .claude/plans/known-drivers-registry/020-rollout-http.md create mode 100644 .claude/plans/known-drivers-registry/021-rollout-log.md create mode 100644 .claude/plans/known-drivers-registry/022-rollout-notification.md create mode 100644 .claude/plans/known-drivers-registry/023-rollout-translation.md create mode 100644 .claude/plans/known-drivers-registry/024-rollout-page-cache.md create mode 100644 .claude/plans/known-drivers-registry/025-skeleton-suggest-consolidation.md create mode 100644 .claude/plans/known-drivers-registry/_plan.md diff --git a/.claude/plans/known-drivers-registry/001-known-drivers-validator-helper.md b/.claude/plans/known-drivers-registry/001-known-drivers-validator-helper.md new file mode 100644 index 00000000..e753ba1e --- /dev/null +++ b/.claude/plans/known-drivers-registry/001-known-drivers-validator-helper.md @@ -0,0 +1,48 @@ +# Task 001: Add KnownDriversValidator helper to marko/testing + +**Status**: pending +**Depends on**: none +**Retry count**: 0 + +## Description +Create `KnownDriversValidator` in `marko/testing` providing shared assertion methods for the per-interface validation tests created in later tasks. Centralizes the comparison logic so each interface's test file is a thin wrapper, not a duplicated implementation. + +## Context +- New file: `packages/testing/src/KnownDrivers/KnownDriversValidator.php` +- New test file: `packages/testing/tests/KnownDrivers/KnownDriversValidatorTest.php` +- Reference: existing helpers in `packages/testing/src/Fake/` for shape and namespace patterns + +The class provides three static methods (each must skip-gracefully on missing files): + +1. **`assertConflictBlocksMatch(string $knownDriversPath, string $packagesDir): void`** — reads the known-drivers.php file, then for each driver listed, locates `{$packagesDir}/{basename}/composer.json` on disk and asserts its `conflict` block contains every OTHER driver from the list (mutual exclusion is symmetric). If a driver's composer.json is not found on disk, that specific driver is skipped (not failed). If the known-drivers.php file itself is missing, the assertion fails loudly. + +2. **`assertSkeletonSuggestContainsAll(string $knownDriversPath, string $skeletonComposerPath): void`** — reads known-drivers.php, locates skeleton's composer.json, asserts every entry from known-drivers.php is present in skeleton's `suggest` block (descriptions must match verbatim). Skeleton's suggest MAY contain additional entries (add-ons, etc.). + **Skip behavior:** skip if (a) skeleton's composer.json is not on disk, OR (b) skeleton's composer.json exists but has no `suggest` key (still being built — task 025 populates it). Once skeleton has a `suggest` key, missing entries become hard failures. This three-state skip is REQUIRED so that per-interface validation tests in tasks 005, 008-024 can pass BEFORE task 025 runs. After task 025 lands, the skip falls through and real assertions fire. + +3. **`assertDocsUrlsResolveToValidPattern(string $knownDriversPath): void`** — reads known-drivers.php and asserts every key matches the `marko/*` prefix pattern (URLs are derived from package names; entries that don't follow the pattern can't generate valid URLs). + +## Requirements (Test Descriptions) +- [ ] `it reads driver list from known-drivers.php file` +- [ ] `it asserts conflict blocks contain all sibling drivers` +- [ ] `it fails assertion when a driver conflict block is missing a sibling` +- [ ] `it skips assertion gracefully when a driver package is not on disk` +- [ ] `it treats a vacuous conflict assertion as passing when only one driver is listed` +- [ ] `it asserts skeleton suggest block contains all known drivers with matching descriptions` +- [ ] `it skips skeleton assertion gracefully when skeleton composer.json is not on disk` +- [ ] `it skips skeleton assertion when skeleton composer.json has no suggest key yet` +- [ ] `it fails skeleton assertion when skeleton has a suggest key but is missing a known driver entry` +- [ ] `it fails skeleton assertion when skeleton has a suggest entry but description does not match` +- [ ] `it allows skeleton suggest to contain entries beyond the known drivers list` +- [ ] `it asserts every known driver follows marko slash prefix pattern` +- [ ] `it fails loudly when the known-drivers.php file itself is missing` + +## Acceptance Criteria +- `KnownDriversValidator` is a non-readonly class with three public static methods +- All file paths passed as parameters (no hardcoded paths inside the helper) +- **Skip mechanism:** since these are static methods (no bound `$this`), they cannot call `markTestSkipped()` directly. Throw `\PHPUnit\Framework\SkippedWithMessageException` (the same exception PHPUnit's `markTestSkipped()` throws under the hood). Pest treats this exception identically to `markTestSkipped()`. Alternative: throw a custom `KnownDriverAssertionSkipped` exception extending `\PHPUnit\Framework\SkippedTestError` for clearer semantics; either approach is acceptable. +- Comprehensive test coverage with fixture known-drivers.php files in `packages/testing/tests/KnownDrivers/fixtures/` +- **Performance:** validation methods read files synchronously from disk. For each assertion call, they parse one known-drivers.php file plus 0–N composer.json files. This is acceptable for CI (each interface package runs one validation test). Do not memoize across calls — tests should be independent. +- Code follows code standards (strict_types, typed params/returns, `@throws` tags where applicable; `@throws \PHPUnit\Framework\SkippedWithMessageException` documented on each method) + +## Implementation Notes +(Left blank — filled in by programmer during implementation) diff --git a/.claude/plans/known-drivers-registry/002-pilot-database-known-drivers.md b/.claude/plans/known-drivers-registry/002-pilot-database-known-drivers.md new file mode 100644 index 00000000..38a09ddf --- /dev/null +++ b/.claude/plans/known-drivers-registry/002-pilot-database-known-drivers.md @@ -0,0 +1,43 @@ +# Task 002: Pilot — create database known-drivers.php + +**Status**: pending +**Depends on**: none +**Retry count**: 0 + +## Description +Create the first `known-drivers.php` file for the pilot package (`marko/database`). This proves the file format and establishes the canonical shape every subsequent interface will mirror. + +## Context +- New file: `packages/database/known-drivers.php` +- New test file: `packages/database/tests/KnownDriversTest.php` (verifies file structure) +- pgsql listed first (recommended default for new projects) +- Add-ons (`marko/database-readwrite`) are NOT listed here — they belong in skeleton suggest only + +File contents: +```php + 'PostgreSQL driver (recommended for new projects — strong JSON, FTS, pgvector support)', + 'marko/database-mysql' => 'MySQL/MariaDB driver', +]; +``` + +**Description-string contract:** these exact description strings (including the em-dash `—` character, NOT a hyphen) are the canonical strings. Task 025 must write them verbatim into skeleton's `suggest` block; any divergence (typo, ASCII hyphen vs em-dash) will fail the `assertSkeletonSuggestContainsAll` test. The `_plan.md` Architecture Notes section and task 025 already reference these exact strings — DO NOT alter them when implementing. + +## Requirements (Test Descriptions) +- [ ] `it ships a known-drivers.php file in the database package` +- [ ] `it lists marko/database-pgsql as the first entry (recommended default)` +- [ ] `it lists marko/database-mysql as the second entry` +- [ ] `it does not list marko/database-readwrite (add-on, not a driver)` +- [ ] `it returns a flat package-to-description associative array` +- [ ] `it uses declare strict_types` + +## Acceptance Criteria +- `packages/database/known-drivers.php` exists with the specified contents +- File returns an array (no nested keys, no objects) +- pgsql is the first entry +- `packages/database/tests/KnownDriversTest.php` verifies all requirements (note: this is distinct from the larger `KnownDriversValidationTest.php` created in task 005 — this test verifies file shape; that test verifies cross-package sync) +- Code follows code standards diff --git a/.claude/plans/known-drivers-registry/003-pilot-refactor-database-no-driver-exception.md b/.claude/plans/known-drivers-registry/003-pilot-refactor-database-no-driver-exception.md new file mode 100644 index 00000000..0a913c60 --- /dev/null +++ b/.claude/plans/known-drivers-registry/003-pilot-refactor-database-no-driver-exception.md @@ -0,0 +1,81 @@ +# Task 003: Pilot — refactor database NoDriverException to read known-drivers.php + +**Status**: pending +**Depends on**: 002 +**Retry count**: 0 + +## Description +Refactor `Marko\Database\Exceptions\NoDriverException` to read its driver list from `known-drivers.php` instead of a hardcoded `DRIVER_PACKAGES` const. Include descriptions and derived docs URLs in the formatted suggestion text. Establishes the refactor pattern that all 17 other `NoDriverException` classes will follow. + +## Context +- Files to modify: + - `packages/database/src/Exceptions/NoDriverException.php` (remove `DRIVER_PACKAGES` const; load known-drivers.php at exception-construction time) + - `packages/database/tests/NoDriverExceptionTest.php` — has an existing test asserting `DRIVER_PACKAGES` const exists (lines 9-16 at plan creation). Remove that test or replace it with a "no longer exposes DRIVER_PACKAGES" assertion. Update the suggestion-text assertions to match the new format. +- Reference: previous implementation in `packages/view/src/Exceptions/NoDriverException.php` (which still has the hardcoded const — this task supersedes that pattern for database; view gets refactored in task 017) + +**New suggestion text format:** +``` +Install one of these drivers: +- marko/database-pgsql: PostgreSQL driver (recommended for new projects — strong JSON, FTS, pgvector support) + Install: composer require marko/database-pgsql + Docs: https://marko.build/docs/packages/database-pgsql/ +- marko/database-mysql: MySQL/MariaDB driver + Install: composer require marko/database-mysql + Docs: https://marko.build/docs/packages/database-mysql/ +``` + +**Implementation shape:** +```php +class NoDriverException extends MarkoException +{ + public static function noDriverInstalled(): self + { + $drivers = require __DIR__ . '/../../known-drivers.php'; + $packageList = self::formatDriverList($drivers); + + return new self( + message: 'No database driver installed.', + context: 'Attempted to resolve a database interface but no implementation is bound.', + suggestion: "Install one of these drivers:\n{$packageList}", + ); + } + + /** + * @param array $drivers + */ + private static function formatDriverList(array $drivers): string + { + $lines = []; + foreach ($drivers as $package => $description) { + $docsUrl = self::docsUrl($package); + $lines[] = "- {$package}: {$description}"; + $lines[] = " Install: composer require {$package}"; + $lines[] = " Docs: {$docsUrl}"; + } + return implode("\n", $lines); + } + + private static function docsUrl(string $package): string + { + $basename = substr($package, strlen('marko/')); + return "https://marko.build/docs/packages/{$basename}/"; + } +} +``` + +## Requirements (Test Descriptions) +- [ ] `it loads the driver list from known-drivers.php` +- [ ] `it includes the description for each driver in the suggestion` +- [ ] `it includes a composer require command for each driver` +- [ ] `it includes a derived docs URL for each driver` +- [ ] `it derives docs URLs from the package basename (marko slash prefix stripped)` +- [ ] `it lists pgsql first in the suggestion (matching known-drivers.php order)` +- [ ] `it no longer exposes a DRIVER_PACKAGES const` + +## Acceptance Criteria +- `NoDriverException::DRIVER_PACKAGES` const is removed +- Exception reads from `known-drivers.php` via `require __DIR__ . '/../../known-drivers.php'` +- Suggestion text includes description + composer require command + docs URL for each driver +- All existing tests pass (with updated assertions) +- New tests added for the docs URL derivation behavior +- Code follows code standards diff --git a/.claude/plans/known-drivers-registry/004-pilot-database-conflict-blocks.md b/.claude/plans/known-drivers-registry/004-pilot-database-conflict-blocks.md new file mode 100644 index 00000000..2f7f3dd3 --- /dev/null +++ b/.claude/plans/known-drivers-registry/004-pilot-database-conflict-blocks.md @@ -0,0 +1,34 @@ +# Task 004: Pilot — add mutual conflict blocks to database-mysql and database-pgsql + +**Status**: pending +**Depends on**: 002 +**Retry count**: 0 + +## Description +Add Composer `conflict` declarations to `marko/database-mysql` and `marko/database-pgsql` so they cannot be installed together. Each driver's `conflict` block must list every OTHER driver from `database/known-drivers.php` (currently just the one sibling). Note: `marko/database-readwrite` is NOT in this list — it's an add-on that coexists with both drivers. + +## Context +- Files to modify: + - `packages/database-mysql/composer.json` — add `"conflict": {"marko/database-pgsql": "*"}` + - `packages/database-pgsql/composer.json` — add `"conflict": {"marko/database-mysql": "*"}` +- New test files (one per driver, to verify the conflict declaration): + - `packages/database-mysql/tests/ComposerConflictTest.php` + - `packages/database-pgsql/tests/ComposerConflictTest.php` +- Reference: `packages/view-twig/composer.json` already has this pattern (from the previous plan); mirror its structure. + +**Important — verify current state first:** Check whether either composer.json already has a `conflict` block. If yes, extend it; if no, add it. Both packages currently lack `conflict` blocks (as of plan creation). + +`marko/database-readwrite` does NOT get a conflict declaration — it's not in known-drivers.php and is intended to coexist with mysql or pgsql. + +## Requirements (Test Descriptions) +For each driver (mysql and pgsql), in its respective test file: +- [ ] `it declares a Composer conflict with the sibling database driver` +- [ ] `it does not declare a conflict with marko/database-readwrite (add-on coexists)` +- [ ] `it uses wildcard version for the conflict declaration` + +## Acceptance Criteria +- Both driver composer.json files include a `conflict` block listing the sibling driver with `*` version +- Neither lists `marko/database-readwrite` as a conflict +- New test files verify the conflict declarations exist and have the correct shape +- Both test files pass +- Code follows code standards diff --git a/.claude/plans/known-drivers-registry/005-pilot-database-validation-test.md b/.claude/plans/known-drivers-registry/005-pilot-database-validation-test.md new file mode 100644 index 00000000..23129ac8 --- /dev/null +++ b/.claude/plans/known-drivers-registry/005-pilot-database-validation-test.md @@ -0,0 +1,56 @@ +# Task 005: Pilot — add validation test in marko/database + +**Status**: pending +**Depends on**: 001, 002, 003, 004 +**Retry count**: 0 + +## Description +Add a validation test in `marko/database` that uses the `KnownDriversValidator` helper (from task 001) to mechanically enforce sync between `database/known-drivers.php`, each driver's composer.json `conflict` block, and (when present) skeleton's `suggest` block. This is the canonical pattern that tasks 008-024 will replicate for every other interface package. + +## Context +- New file: `packages/database/tests/KnownDriversValidationTest.php` +- Reference: the `KnownDriversValidator` API created in task 001 +- The test must skip gracefully when: + - A driver package (`marko/database-mysql`, `marko/database-pgsql`) isn't on disk → that specific driver assertion is skipped + - Skeleton's composer.json isn't on disk → the skeleton-parity assertion is skipped entirely +- The test must fail loudly when: + - `known-drivers.php` is missing + - A driver IS on disk but its conflict block is wrong (missing a sibling, listing a non-driver, etc.) + - Skeleton IS on disk but its suggest block is missing a known driver entry or has a mismatched description + +**Test file shape:** +```php +context` or `$report->suggestion`** — only `$report->message` is rendered. This means all the carefully-crafted context and suggestion text in `MarkoException` subclasses (including the new docs URLs added by the known-drivers refactor in tasks 003 and 008-024) is silently dropped from the HTML output. Confirm by reading `packages/errors-advanced/src/PrettyHtmlFormatter.php` lines 58-89. +2. URLs in exception text render as plain text (not clickable) because the rendering uses a generic `htmlspecialchars` escape via the private `escape()` method. + +After this task: `context` and `suggestion` are rendered in the HTML output (each as its own paragraph block, positioned after the message), AND URLs in message/context/suggestion are auto-detected and rendered as `` links. Applied uniformly — this is a generic rendering improvement, not NoDriverException-specific. + +## Context +- Files to modify: + - `packages/errors-advanced/src/PrettyHtmlFormatter.php`: + - `formatDevelopment()` (lines 58-89): add rendering of `$report->context` (when non-empty) and `$report->suggestion` (when non-empty), each in its own paragraph (e.g., `

` and `

`, with `white-space: pre-wrap` so the multi-line installation text in NoDriverException renders correctly) + - `escape()` (line 134-138): keep as-is for non-user-facing values (filenames, request data) but introduce a new `escapeAndLinkifyUrls()` private method for user-facing exception text (message, context, suggestion) + - `getEmbeddedCss()` (lines 92-115): add `.context` and `.suggestion` rules (consider `white-space: pre-wrap` since suggestion text from NoDriverException contains literal `\n` separators) +- New test file: `packages/errors-advanced/tests/UrlLinkificationTest.php` +- Existing tests in `packages/errors-advanced/tests/` may need updates if they assert on output HTML structure + +**URL detection pattern (conservative):** +- Match `https?://` followed by non-whitespace, non-`<`, non-`"`, non-`'` characters +- Stop at whitespace, `<`, `>`, `"`, `'`, end of string +- Recommended regex: `/(https?:\/\/[^\s<>"\']+)/` +- Trim trailing punctuation that's unlikely to be part of a URL: `.`, `,`, `;`, `:`, `!`, `?`, `)`, `]` + +**Implementation shape (private helper):** +```php +private function escapeAndLinkifyUrls(string $value): string +{ + $pattern = '/(https?:\/\/[^\s<>"\']+)/'; + $parts = preg_split($pattern, $value, -1, PREG_SPLIT_DELIM_CAPTURE); + + $output = ''; + foreach ($parts as $i => $part) { + if ($i % 2 === 0) { + $output .= htmlspecialchars($part, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } else { + // Trim trailing punctuation + $trailing = ''; + while (strlen($part) > 0 && in_array($part[-1], ['.', ',', ';', ':', '!', '?', ')', ']'], true)) { + $trailing = $part[-1] . $trailing; + $part = substr($part, 0, -1); + } + $escapedUrl = htmlspecialchars($part, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $output .= "{$escapedUrl}"; + $output .= htmlspecialchars($trailing, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + } + + return $output; +} +``` + +Existing call sites that use `htmlspecialchars()` directly on user-facing exception text should be updated to use this new helper. + +## Requirements (Test Descriptions) +- [ ] `it renders the context field in the HTML output when non-empty` +- [ ] `it renders the suggestion field in the HTML output when non-empty` +- [ ] `it omits empty context and suggestion blocks (does not render empty paragraphs)` +- [ ] `it preserves newlines in the suggestion text via white-space pre-wrap` +- [ ] `it renders http URLs as anchor tags with target blank and noopener noreferrer` +- [ ] `it renders https URLs as anchor tags` +- [ ] `it htmlspecialchars-escapes non-URL text portions` +- [ ] `it preserves URLs inside mixed text correctly` +- [ ] `it does not linkify text that looks URL-ish but lacks a protocol (e.g., www.example.com without http)` +- [ ] `it trims trailing punctuation from URL matches (period, comma, etc.)` +- [ ] `it escapes HTML special characters within the URL itself (defense against malformed input)` +- [ ] `it does not double-escape when the input has no URLs` +- [ ] `it linkifies URLs that appear in the suggestion field (NoDriverException docs URLs)` + +## Acceptance Criteria +- `formatDevelopment()` renders `context` and `suggestion` fields when non-empty +- CSS includes `white-space: pre-wrap` (or equivalent) for `.suggestion` so multi-line text renders correctly +- New `escapeAndLinkifyUrls()` private method added to `PrettyHtmlFormatter` +- Replaces the `escape()` call on `$report->message` (line 61); also used for the new context and suggestion rendering +- The original `escape()` method is retained for non-user-facing values (filenames, request data) — no security regression for those callsites +- All anchor tags include both `target="_blank"` AND `rel="noopener noreferrer"` (security-required) +- Existing errors-advanced tests still pass (regression check); any tests that asserted on the old HTML structure are updated to match +- New test file covers all requirements above +- Code follows code standards (typed params/returns, `@throws` if applicable) diff --git a/.claude/plans/known-drivers-registry/007-view-test-cleanup.md b/.claude/plans/known-drivers-registry/007-view-test-cleanup.md new file mode 100644 index 00000000..09aa451e --- /dev/null +++ b/.claude/plans/known-drivers-registry/007-view-test-cleanup.md @@ -0,0 +1,37 @@ +# Task 007: Clean up marko/view test suite — zero dependency on marko/view-latte + +**Status**: pending +**Depends on**: none +**Retry count**: 0 + +## Description +`packages/view/tests/Feature/IntegrationTest.php` currently uses `Marko\View\Latte\LatteEngineFactory` and `Marko\View\Latte\LatteViewConfig` — hard dependencies on `marko/view-latte` from inside the interface package's test suite. This violates the principle that the interface package must be installable standalone (e.g., when a user is writing their own template engine driver outside Marko's core). Move the integration test into `marko/view-latte/tests/Feature/` where it logically belongs. + +## Context +- Files to modify/move: + - DELETE: `packages/view/tests/Feature/IntegrationTest.php` + - CREATE: `packages/view-latte/tests/Feature/IntegrationTest.php` (moved file, namespace and any `use` statements adjusted) +- The integration test exercises Latte-specific behavior (LatteEngineFactory + ModuleTemplateResolver end-to-end). It tests `marko/view-latte`, not `marko/view`. +- After the move, `packages/view/tests/` must contain only tests that exercise `marko/view`'s own contracts — no Latte- or Twig-specific imports. + +**Verify zero-dependency after move:** +1. Search `packages/view/tests/` for any remaining import of `Marko\View\Latte\*` or `Marko\View\Twig\*`. Should return zero matches. +2. Run `/opt/homebrew/Cellar/php/8.5.1_2/bin/php ./vendor/bin/pest packages/view/tests/ --parallel` — all tests pass. +3. Verify the moved test still passes: `/opt/homebrew/Cellar/php/8.5.1_2/bin/php ./vendor/bin/pest packages/view-latte/tests/Feature/IntegrationTest.php --parallel`. + +**Namespace adjustment:** the moved file's namespace likely changes from `Marko\View\Tests\Feature` to `Marko\View\Latte\Tests\Feature` to match its new location. Update Pest's `uses()` if applicable. + +## Requirements (Test Descriptions) +- [ ] `marko/view test suite contains no imports of Marko\\View\\Latte namespace` +- [ ] `the moved IntegrationTest passes in its new location under marko/view-latte/tests/Feature/` +- [ ] `all existing marko/view tests continue to pass after the cleanup` +- [ ] `all existing marko/view-latte tests continue to pass after the cleanup` + +**Note on view-twig:** the task description mentions checking for `Marko\View\Twig\*` imports, but no such package exists in the monorepo today. The grep for that namespace will return zero by default — this is correct/expected, not an assertion failure. + +## Acceptance Criteria +- Old file deleted, new file created at the new path +- Namespace updated to reflect new location +- Grep for `Marko\\View\\Latte` in `packages/view/tests/` returns zero results +- Both test suites pass independently +- Code follows code standards diff --git a/.claude/plans/known-drivers-registry/008-rollout-cache.md b/.claude/plans/known-drivers-registry/008-rollout-cache.md new file mode 100644 index 00000000..6e01f338 --- /dev/null +++ b/.claude/plans/known-drivers-registry/008-rollout-cache.md @@ -0,0 +1,42 @@ +# Task 008: Roll out known-drivers pattern — marko/cache + +**Status**: pending +**Depends on**: 001, 005 +**Retry count**: 0 + +## Description +Apply the pilot pattern (tasks 002–005) to `marko/cache`. Three drivers: `cache-array`, `cache-file`, `cache-redis`. All bind `CacheInterface` and are mutually exclusive. + +## Context +- Interface: `Marko\Cache\CacheInterface` +- Drivers: `marko/cache-array`, `marko/cache-file`, `marko/cache-redis` +- Recommended-first ordering: `cache-file` (sensible default for most apps; redis is opt-in for distributed/perf, array is dev/test-only) +- All three drivers already have `module.php` binding `CacheInterface` — confirmed in audit + +**Description text for known-drivers.php:** +- `marko/cache-file` → `'File-based cache driver (recommended for single-server apps)'` +- `marko/cache-redis` → `'Redis cache driver (recommended for distributed deployments and high-throughput apps)'` +- `marko/cache-array` → `'In-memory cache driver (request-lifetime only; intended for testing and dev environments)'` + +## Sub-steps (each yields one or more requirements) +1. Create `packages/cache/known-drivers.php` with the three entries (file first) +2. Refactor `packages/cache/src/Exceptions/NoDriverException.php` to read from known-drivers.php and include docs URLs (same shape as task 003). Update existing `packages/cache/tests/Exceptions/NoDriverExceptionTest.php` assertions to match the new output format. +3. Add mutual `conflict` blocks to all three driver composer.json files: each driver's conflict block lists the other two +4. Add `packages/cache/tests/KnownDriversValidationTest.php` using `KnownDriversValidator` +5. Verify `marko/testing` is in `packages/cache/composer.json` `require-dev`; add it (`"marko/testing": "self.version"`) if missing + +## Requirements (Test Descriptions) +- [ ] `it ships a known-drivers.php file listing all three cache drivers` +- [ ] `it lists marko/cache-file first as the recommended driver` +- [ ] `cache NoDriverException reads from known-drivers.php and includes docs URLs` +- [ ] `each cache driver declares conflict with both other drivers` +- [ ] `validation test confirms conflict blocks match known-drivers list` +- [ ] `validation test skips skeleton parity assertion when skeleton is absent` + +## Acceptance Criteria +- `packages/cache/known-drivers.php` exists with three entries +- `NoDriverException` refactored to mirror database/NoDriverException pattern +- All three driver composer.json files have correctly-populated `conflict` blocks +- Validation test passes +- Existing cache tests still pass +- Code follows code standards diff --git a/.claude/plans/known-drivers-registry/009-rollout-errors.md b/.claude/plans/known-drivers-registry/009-rollout-errors.md new file mode 100644 index 00000000..6ef11924 --- /dev/null +++ b/.claude/plans/known-drivers-registry/009-rollout-errors.md @@ -0,0 +1,40 @@ +# Task 009: Roll out known-drivers pattern — marko/errors + +**Status**: pending +**Depends on**: 001, 005 +**Retry count**: 0 + +## Description +Apply the pilot pattern to `marko/errors`. Two drivers: `errors-simple`, `errors-advanced`. Both bind `ErrorHandlerInterface` and are mutually exclusive. + +## Context +- Interface: `Marko\Errors\ErrorHandlerInterface` +- Drivers: `marko/errors-simple`, `marko/errors-advanced` +- Recommended-first ordering: `errors-simple` (prod-safe default — minimal info exposed; errors-advanced is for development with detailed stack traces) +- Confirm both have `module.php` binding `ErrorHandlerInterface` before writing conflict blocks + +**Description text for known-drivers.php:** +- `marko/errors-simple` → `'Simple error handler (recommended for production — minimal information disclosure)'` +- `marko/errors-advanced` → `'Advanced error handler with pretty stack traces and suggestions (recommended for development)'` + +## Sub-steps +1. Create `packages/errors/known-drivers.php` +2. Refactor `packages/errors/src/Exceptions/NoDriverException.php` to read from known-drivers.php. Update existing `packages/errors/tests/Unit/Exceptions/NoDriverExceptionTest.php` to match the new output format. +3. Add mutual `conflict` blocks to both driver composer.json files +4. Add `packages/errors/tests/KnownDriversValidationTest.php` +5. Verify `marko/testing` is in `packages/errors/composer.json` `require-dev`; add it (`"marko/testing": "self.version"`) if missing + +## Requirements (Test Descriptions) +- [ ] `it ships a known-drivers.php file listing both errors drivers` +- [ ] `it lists marko/errors-simple first as the recommended driver` +- [ ] `errors NoDriverException reads from known-drivers.php and includes docs URLs` +- [ ] `each errors driver declares conflict with the sibling driver` +- [ ] `validation test confirms conflict blocks match known-drivers list` + +## Acceptance Criteria +- `packages/errors/known-drivers.php` exists +- `NoDriverException` refactored +- Both driver composer.json files have correctly-populated `conflict` blocks +- Validation test passes +- Existing errors tests still pass +- Code follows code standards diff --git a/.claude/plans/known-drivers-registry/010-rollout-filesystem.md b/.claude/plans/known-drivers-registry/010-rollout-filesystem.md new file mode 100644 index 00000000..cbe3e7bb --- /dev/null +++ b/.claude/plans/known-drivers-registry/010-rollout-filesystem.md @@ -0,0 +1,40 @@ +# Task 010: Roll out known-drivers pattern — marko/filesystem + +**Status**: pending +**Depends on**: 001, 005 +**Retry count**: 0 + +## Description +Apply the pilot pattern to `marko/filesystem`. Two drivers: `filesystem-local`, `filesystem-s3`. Both bind `FilesystemInterface` and are mutually exclusive. + +## Context +- Interface: `Marko\Filesystem\FilesystemInterface` +- Drivers: `marko/filesystem-local`, `marko/filesystem-s3` +- Recommended-first ordering: `filesystem-local` (zero-infrastructure default; s3 for cloud/distributed) +- Confirm both have `module.php` binding `FilesystemInterface` + +**Description text for known-drivers.php:** +- `marko/filesystem-local` → `'Local disk filesystem driver (recommended default; zero infrastructure required)'` +- `marko/filesystem-s3` → `'Amazon S3 filesystem driver (for cloud and distributed deployments)'` + +## Sub-steps +1. Create `packages/filesystem/known-drivers.php` +2. Refactor `packages/filesystem/src/Exceptions/NoDriverException.php`. Update existing `packages/filesystem/tests/Unit/Exceptions/NoDriverExceptionTest.php` to match the new output format. +3. Add mutual `conflict` blocks to both driver composer.json files +4. Add `packages/filesystem/tests/KnownDriversValidationTest.php` +5. Verify `marko/testing` is in `packages/filesystem/composer.json` `require-dev`; add it if missing + +## Requirements (Test Descriptions) +- [ ] `it ships a known-drivers.php file listing both filesystem drivers` +- [ ] `it lists marko/filesystem-local first as the recommended driver` +- [ ] `filesystem NoDriverException reads from known-drivers.php and includes docs URLs` +- [ ] `each filesystem driver declares conflict with the sibling driver` +- [ ] `validation test confirms conflict blocks match known-drivers list` + +## Acceptance Criteria +- `packages/filesystem/known-drivers.php` exists +- `NoDriverException` refactored +- Both driver composer.json files have correctly-populated `conflict` blocks +- Validation test passes +- Existing filesystem tests still pass +- Code follows code standards diff --git a/.claude/plans/known-drivers-registry/011-rollout-inertia.md b/.claude/plans/known-drivers-registry/011-rollout-inertia.md new file mode 100644 index 00000000..0b695e15 --- /dev/null +++ b/.claude/plans/known-drivers-registry/011-rollout-inertia.md @@ -0,0 +1,44 @@ +# Task 011: Roll out known-drivers pattern — marko/inertia + +**Status**: pending +**Depends on**: 001, 005 +**Retry count**: 0 + +## Description +Apply the pilot pattern to `marko/inertia`. Three drivers: `inertia-react`, `inertia-svelte`, `inertia-vue`. All bind `InertiaFrontendInterface` and are mutually exclusive. + +## Context +- Interface: `Marko\Inertia\Frontend\InertiaFrontendInterface` (verified at `packages/inertia/src/Frontend/InertiaFrontendInterface.php`) +- Drivers: `marko/inertia-react`, `marko/inertia-svelte`, `marko/inertia-vue` +- Recommended-first ordering: `inertia-react` (largest community, most documentation/tooling); svelte and vue follow in alphabetical order +- Confirmed in audit: all three bind `InertiaFrontendInterface` via module.php +- **`marko/inertia` does NOT currently have a `NoDriverException`** — it must be CREATED in this task (not just refactored). Follow the same shape as `packages/database/src/Exceptions/NoDriverException.php` (post-task-003 refactor): extends a package-local exception base class or `MarkoException` directly, has a `noDriverInstalled()` static factory that reads from `known-drivers.php` and renders the package list with docs URLs. + +**Description text for known-drivers.php:** +- `marko/inertia-react` → `'React frontend driver for Inertia.js (recommended — largest community and tooling ecosystem)'` +- `marko/inertia-svelte` → `'Svelte frontend driver for Inertia.js'` +- `marko/inertia-vue` → `'Vue frontend driver for Inertia.js'` + +## Sub-steps +1. Create `packages/inertia/known-drivers.php` +2. **Create** `packages/inertia/src/Exceptions/NoDriverException.php` (file does not exist yet) using the established pattern from task 003. Add a corresponding `packages/inertia/tests/Exceptions/NoDriverExceptionTest.php`. +3. Add mutual `conflict` blocks to all three driver composer.json files (each lists the other two) +4. Add `packages/inertia/tests/KnownDriversValidationTest.php` using `KnownDriversValidator` +5. Add `marko/testing` to `packages/inertia/composer.json` `require-dev` (needed for the validation test) + +**Note:** Inertia drivers represent frontend framework choice — fundamentally not mixable (a single Inertia app uses one frontend). The conflict declaration is correct here. + +## Requirements (Test Descriptions) +- [ ] `it ships a known-drivers.php file listing all three inertia drivers` +- [ ] `it lists marko/inertia-react first as the recommended driver` +- [ ] `inertia NoDriverException reads from known-drivers.php and includes docs URLs` +- [ ] `each inertia driver declares conflict with both other drivers` +- [ ] `validation test confirms conflict blocks match known-drivers list` + +## Acceptance Criteria +- `packages/inertia/known-drivers.php` exists with three entries +- `NoDriverException` exists (created if missing) and reads from known-drivers.php +- All three driver composer.json files have correctly-populated `conflict` blocks listing both siblings +- Validation test passes +- Existing inertia tests still pass +- Code follows code standards diff --git a/.claude/plans/known-drivers-registry/012-rollout-mail.md b/.claude/plans/known-drivers-registry/012-rollout-mail.md new file mode 100644 index 00000000..78215fbf --- /dev/null +++ b/.claude/plans/known-drivers-registry/012-rollout-mail.md @@ -0,0 +1,40 @@ +# Task 012: Roll out known-drivers pattern — marko/mail + +**Status**: pending +**Depends on**: 001, 005 +**Retry count**: 0 + +## Description +Apply the pilot pattern to `marko/mail`. Two drivers: `mail-log`, `mail-smtp`. Both bind `MailerInterface` and are mutually exclusive. + +## Context +- Interface: `Marko\Mail\MailerInterface` +- Drivers: `marko/mail-log`, `marko/mail-smtp` +- Recommended-first ordering: `mail-smtp` (production-relevant default; mail-log is dev/test-only) +- Confirm both have `module.php` binding `MailerInterface` + +**Description text for known-drivers.php:** +- `marko/mail-smtp` → `'SMTP mailer driver (recommended for production)'` +- `marko/mail-log` → `'Log-only mailer driver (writes emails to LoggerInterface; intended for development and testing)'` + +## Sub-steps +1. Create `packages/mail/known-drivers.php` +2. Refactor `packages/mail/src/Exceptions/NoDriverException.php`. Update existing `packages/mail/tests/Unit/Exceptions/NoDriverExceptionTest.php` to match the new output format. +3. Add mutual `conflict` blocks to both driver composer.json files +4. Add `packages/mail/tests/KnownDriversValidationTest.php` +5. Verify `marko/testing` is in `packages/mail/composer.json` `require-dev`; add it if missing + +## Requirements (Test Descriptions) +- [ ] `it ships a known-drivers.php file listing both mail drivers` +- [ ] `it lists marko/mail-smtp first as the recommended driver` +- [ ] `mail NoDriverException reads from known-drivers.php and includes docs URLs` +- [ ] `each mail driver declares conflict with the sibling driver` +- [ ] `validation test confirms conflict blocks match known-drivers list` + +## Acceptance Criteria +- `packages/mail/known-drivers.php` exists +- `NoDriverException` refactored +- Both driver composer.json files have correctly-populated `conflict` blocks +- Validation test passes +- Existing mail tests still pass +- Code follows code standards diff --git a/.claude/plans/known-drivers-registry/013-rollout-media.md b/.claude/plans/known-drivers-registry/013-rollout-media.md new file mode 100644 index 00000000..4de90de1 --- /dev/null +++ b/.claude/plans/known-drivers-registry/013-rollout-media.md @@ -0,0 +1,40 @@ +# Task 013: Roll out known-drivers pattern — marko/media + +**Status**: pending +**Depends on**: 001, 005 +**Retry count**: 0 + +## Description +Apply the pilot pattern to `marko/media`. Two drivers: `media-gd`, `media-imagick`. Both bind `ImageProcessorInterface` and are mutually exclusive. + +## Context +- Interface: `Marko\Media\Contracts\ImageProcessorInterface` +- Drivers: `marko/media-gd`, `marko/media-imagick` +- Recommended-first ordering: `media-gd` (ext-gd ships with most PHP builds; imagick requires the ImageMagick library installed separately) +- Confirmed in audit: both bind `ImageProcessorInterface` via module.php + +**Description text for known-drivers.php:** +- `marko/media-gd` → `'GD image processor (recommended — ships with most PHP installations)'` +- `marko/media-imagick` → `'ImageMagick image processor (higher fidelity; requires ext-imagick and ImageMagick library installed)'` + +## Sub-steps +1. Create `packages/media/known-drivers.php` +2. Refactor `packages/media/src/Exceptions/NoDriverException.php`. Update existing `packages/media/tests/Exceptions/NoDriverExceptionTest.php` to match the new output format. +3. Add mutual `conflict` blocks to both driver composer.json files +4. Add `packages/media/tests/KnownDriversValidationTest.php` +5. Verify `marko/testing` is in `packages/media/composer.json` `require-dev`; add it if missing + +## Requirements (Test Descriptions) +- [ ] `it ships a known-drivers.php file listing both media drivers` +- [ ] `it lists marko/media-gd first as the recommended driver` +- [ ] `media NoDriverException reads from known-drivers.php and includes docs URLs` +- [ ] `each media driver declares conflict with the sibling driver` +- [ ] `validation test confirms conflict blocks match known-drivers list` + +## Acceptance Criteria +- `packages/media/known-drivers.php` exists +- `NoDriverException` refactored +- Both driver composer.json files have correctly-populated `conflict` blocks +- Validation test passes +- Existing media tests still pass +- Code follows code standards diff --git a/.claude/plans/known-drivers-registry/014-rollout-pubsub.md b/.claude/plans/known-drivers-registry/014-rollout-pubsub.md new file mode 100644 index 00000000..e0abb5f1 --- /dev/null +++ b/.claude/plans/known-drivers-registry/014-rollout-pubsub.md @@ -0,0 +1,40 @@ +# Task 014: Roll out known-drivers pattern — marko/pubsub + +**Status**: pending +**Depends on**: 001, 005 +**Retry count**: 0 + +## Description +Apply the pilot pattern to `marko/pubsub`. Two drivers: `pubsub-pgsql`, `pubsub-redis`. Both bind `PublisherInterface` AND `SubscriberInterface` and are mutually exclusive. + +## Context +- Interfaces: `Marko\PubSub\PublisherInterface`, `Marko\PubSub\SubscriberInterface` +- Drivers: `marko/pubsub-pgsql`, `marko/pubsub-redis` +- Recommended-first ordering: `pubsub-redis` (purpose-built for pub/sub; pgsql LISTEN/NOTIFY is functional but optimized for simpler use cases) +- Confirmed in audit: both bind PublisherInterface and SubscriberInterface + +**Description text for known-drivers.php:** +- `marko/pubsub-redis` → `'Redis pub/sub driver (recommended — purpose-built for messaging)'` +- `marko/pubsub-pgsql` → `'PostgreSQL LISTEN/NOTIFY pub/sub driver (no additional infrastructure if you already use Postgres)'` + +## Sub-steps +1. Create `packages/pubsub/known-drivers.php` +2. Refactor `packages/pubsub/src/Exceptions/NoDriverException.php`. Update existing `packages/pubsub/tests/Exceptions/NoDriverExceptionTest.php` to match the new output format. +3. Add mutual `conflict` blocks to both driver composer.json files +4. Add `packages/pubsub/tests/KnownDriversValidationTest.php` +5. Verify `marko/testing` is in `packages/pubsub/composer.json` `require-dev`; add it if missing + +## Requirements (Test Descriptions) +- [ ] `it ships a known-drivers.php file listing both pubsub drivers` +- [ ] `it lists marko/pubsub-redis first as the recommended driver` +- [ ] `pubsub NoDriverException reads from known-drivers.php and includes docs URLs` +- [ ] `each pubsub driver declares conflict with the sibling driver` +- [ ] `validation test confirms conflict blocks match known-drivers list` + +## Acceptance Criteria +- `packages/pubsub/known-drivers.php` exists +- `NoDriverException` refactored +- Both driver composer.json files have correctly-populated `conflict` blocks +- Validation test passes +- Existing pubsub tests still pass +- Code follows code standards diff --git a/.claude/plans/known-drivers-registry/015-rollout-queue.md b/.claude/plans/known-drivers-registry/015-rollout-queue.md new file mode 100644 index 00000000..2018fd4e --- /dev/null +++ b/.claude/plans/known-drivers-registry/015-rollout-queue.md @@ -0,0 +1,41 @@ +# Task 015: Roll out known-drivers pattern — marko/queue + +**Status**: pending +**Depends on**: 001, 005 +**Retry count**: 0 + +## Description +Apply the pilot pattern to `marko/queue`. Three drivers: `queue-sync`, `queue-database`, `queue-rabbitmq`. All bind `QueueInterface` and `FailedJobRepositoryInterface` and are mutually exclusive. + +## Context +- Interfaces: `Marko\Queue\QueueInterface`, `Marko\Queue\FailedJobRepositoryInterface` +- Drivers: `marko/queue-sync`, `marko/queue-database`, `marko/queue-rabbitmq` +- Recommended-first ordering: `queue-sync` (zero-infrastructure default — runs jobs inline; appropriate for dev and simple apps); `queue-database` for production without extra infra; `queue-rabbitmq` for high-throughput / production with RabbitMQ available +- Confirmed in audit: all three bind QueueInterface AND FailedJobRepositoryInterface + +**Description text for known-drivers.php:** +- `marko/queue-sync` → `'Synchronous queue driver (recommended for development — runs jobs inline, no infrastructure)'` +- `marko/queue-database` → `'Database-backed queue driver (production-ready; uses your existing database)'` +- `marko/queue-rabbitmq` → `'RabbitMQ queue driver (recommended for high-throughput production deployments)'` + +## Sub-steps +1. Create `packages/queue/known-drivers.php` +2. Refactor `packages/queue/src/Exceptions/NoDriverException.php`. Update existing `packages/queue/tests/NoDriverExceptionTest.php` to match the new output format. +3. Add mutual `conflict` blocks to all three driver composer.json files (each lists the other two) +4. Add `packages/queue/tests/KnownDriversValidationTest.php` +5. Verify `marko/testing` is in `packages/queue/composer.json` `require-dev`; add it if missing + +## Requirements (Test Descriptions) +- [ ] `it ships a known-drivers.php file listing all three queue drivers` +- [ ] `it lists marko/queue-sync first as the recommended development default` +- [ ] `queue NoDriverException reads from known-drivers.php and includes docs URLs` +- [ ] `each queue driver declares conflict with both other drivers` +- [ ] `validation test confirms conflict blocks match known-drivers list` + +## Acceptance Criteria +- `packages/queue/known-drivers.php` exists with three entries +- `NoDriverException` refactored +- All three driver composer.json files have correctly-populated `conflict` blocks listing both siblings +- Validation test passes +- Existing queue tests still pass +- Code follows code standards diff --git a/.claude/plans/known-drivers-registry/016-rollout-session.md b/.claude/plans/known-drivers-registry/016-rollout-session.md new file mode 100644 index 00000000..b9bf5b06 --- /dev/null +++ b/.claude/plans/known-drivers-registry/016-rollout-session.md @@ -0,0 +1,39 @@ +# Task 016: Roll out known-drivers pattern — marko/session + +**Status**: pending +**Depends on**: 001, 005 +**Retry count**: 0 + +## Description +Apply the pilot pattern to `marko/session`. Two drivers: `session-file`, `session-database`. Both bind `SessionHandlerInterface` and are mutually exclusive. + +## Context +- Interface: `Marko\Session\Contracts\SessionHandlerInterface` (verified at `packages/session/src/Contracts/SessionHandlerInterface.php`) +- Drivers: `marko/session-file`, `marko/session-database` +- Recommended-first ordering: `session-file` (zero-infrastructure default); session-database for distributed apps or where session data needs to be queryable + +**Description text for known-drivers.php:** +- `marko/session-file` → `'File-based session driver (recommended default for single-server apps)'` +- `marko/session-database` → `'Database-backed session driver (recommended for distributed deployments and queryable session data)'` + +## Sub-steps +1. Create `packages/session/known-drivers.php` +2. Refactor `packages/session/src/Exceptions/NoDriverException.php`. Update existing `packages/session/tests/Unit/Exceptions/NoDriverExceptionTest.php` to match the new output format. +3. Add mutual `conflict` blocks to both driver composer.json files +4. Add `packages/session/tests/KnownDriversValidationTest.php` +5. Verify `marko/testing` is in `packages/session/composer.json` `require-dev`; add it if missing + +## Requirements (Test Descriptions) +- [ ] `it ships a known-drivers.php file listing both session drivers` +- [ ] `it lists marko/session-file first as the recommended driver` +- [ ] `session NoDriverException reads from known-drivers.php and includes docs URLs` +- [ ] `each session driver declares conflict with the sibling driver` +- [ ] `validation test confirms conflict blocks match known-drivers list` + +## Acceptance Criteria +- `packages/session/known-drivers.php` exists +- `NoDriverException` refactored +- Both driver composer.json files have correctly-populated `conflict` blocks +- Validation test passes +- Existing session tests still pass +- Code follows code standards diff --git a/.claude/plans/known-drivers-registry/017-rollout-view.md b/.claude/plans/known-drivers-registry/017-rollout-view.md new file mode 100644 index 00000000..152a5de7 --- /dev/null +++ b/.claude/plans/known-drivers-registry/017-rollout-view.md @@ -0,0 +1,47 @@ +# Task 017: Roll out known-drivers pattern — marko/view + +**Status**: pending +**Depends on**: 001, 005, 007 +**Retry count**: 0 + +## Description +Apply the single-driver variant of the pilot pattern to `marko/view`. **Only one driver exists on disk: `marko/view-latte`.** Despite earlier plan notes mentioning `marko/view-twig`, that package does not exist in this monorepo and is OUT OF SCOPE for this plan. When a second driver (twig or otherwise) lands later, the entry is added to `known-drivers.php` and the validation tests automatically gain teeth. + +This task refactors the existing `NoDriverException` (currently has a `DRIVER_PACKAGES` const listing only `marko/view-latte`) to read from `known-drivers.php`. + +## Context +- Interface: `Marko\View\ViewInterface` (verified at `packages/view/src/ViewInterface.php`) +- Driver (single, currently): `marko/view-latte` +- **Pre-existing state (verified at plan creation):** + - `view-latte/composer.json` does NOT have a `conflict` block (no sibling to conflict with yet) + - `view/src/Exceptions/NoDriverException.php` has `private const array DRIVER_PACKAGES = ['marko/view-latte']` — single entry + - Existing test `packages/view/tests/Exceptions/NoDriverExceptionTest.php` asserts against the const (must be updated) +- Skeleton's composer.json does NOT yet have a `suggest` block — it will be created in task 025 + +**Description text for known-drivers.php:** +- `marko/view-latte` → `'Latte template engine driver (compile-time safety, n:attribute syntax)'` + +This description must match what task 025 writes into skeleton's `suggest` block exactly (literal-equality CI check enforced by `KnownDriversValidator::assertSkeletonSuggestContainsAll`). Task 025 must use the same string. + +## Sub-steps +1. Create `packages/view/known-drivers.php` with the single `marko/view-latte` entry +2. Refactor `packages/view/src/Exceptions/NoDriverException.php` to read from known-drivers.php (remove `DRIVER_PACKAGES` const; add `noDriverInstalled()` factory using the same shape as task 003) +3. Update `packages/view/tests/Exceptions/NoDriverExceptionTest.php` to match the new shape (remove `DRIVER_PACKAGES` assertion, add assertions for docs URL inclusion) +4. Add `packages/view/tests/KnownDriversValidationTest.php` using `KnownDriversValidator` (vacuous conflict assertion since only one driver — same as single-driver tasks 018-024) +5. Add `marko/testing` to `packages/view/composer.json` `require-dev` (needed for the validation test) + +## Requirements (Test Descriptions) +- [ ] `it ships a known-drivers.php file listing marko/view-latte` +- [ ] `view NoDriverException reads from known-drivers.php and includes a docs URL` +- [ ] `view NoDriverException no longer exposes a DRIVER_PACKAGES const` +- [ ] `validation test passes (vacuous conflict assertion with one driver)` +- [ ] `existing NoDriverExceptionTest is updated to match new shape` + +## Acceptance Criteria +- `packages/view/known-drivers.php` exists with one entry (`marko/view-latte`) +- `DRIVER_PACKAGES` const removed from view's `NoDriverException` +- Existing `NoDriverExceptionTest` updated; all assertions match new output format +- New validation test passes +- All existing view, view-latte tests still pass +- `marko/testing` added as `require-dev` in `packages/view/composer.json` if not already present +- Code follows code standards diff --git a/.claude/plans/known-drivers-registry/018-rollout-authentication.md b/.claude/plans/known-drivers-registry/018-rollout-authentication.md new file mode 100644 index 00000000..363839dd --- /dev/null +++ b/.claude/plans/known-drivers-registry/018-rollout-authentication.md @@ -0,0 +1,35 @@ +# Task 018: Roll out known-drivers pattern — marko/authentication (single-driver) + +**Status**: pending +**Depends on**: 001, 005 +**Retry count**: 0 + +## Description +Apply the pilot pattern to `marko/authentication`. Single driver: `authentication-token`. No siblings yet; the validation test still runs but the conflict-block assertion is vacuous (no other drivers to check against). When a second driver lands, the assertion automatically gains real teeth — no code changes needed. + +## Context +- Driver: `marko/authentication-token` +- Recommended-first: only one driver, so listed alone +- No mutual `conflict` declaration needed yet (no siblings) +- `marko/authentication`'s `NoDriverException` already exists — just needs refactoring + +**Description text for known-drivers.php:** +- `marko/authentication-token` → `'Token-based authentication driver (signed token sessions)'` + +## Sub-steps +1. Create `packages/authentication/known-drivers.php` with the single entry +2. Refactor `packages/authentication/src/Exceptions/NoDriverException.php`. Update existing `packages/authentication/tests/Exceptions/NoDriverExceptionTest.php` to match the new output format. +3. Add `packages/authentication/tests/KnownDriversValidationTest.php` (conflict assertion is vacuously satisfied) +4. Verify `marko/testing` is in `packages/authentication/composer.json` `require-dev`; add it if missing + +## Requirements (Test Descriptions) +- [ ] `it ships a known-drivers.php file listing marko/authentication-token` +- [ ] `authentication NoDriverException reads from known-drivers.php and includes docs URL` +- [ ] `validation test passes (vacuous conflict assertion with one driver)` + +## Acceptance Criteria +- `packages/authentication/known-drivers.php` exists with the single entry +- `NoDriverException` refactored +- Validation test passes (no conflict declaration needed on the lone driver) +- Existing authentication tests still pass +- Code follows code standards diff --git a/.claude/plans/known-drivers-registry/019-rollout-encryption.md b/.claude/plans/known-drivers-registry/019-rollout-encryption.md new file mode 100644 index 00000000..837e9d03 --- /dev/null +++ b/.claude/plans/known-drivers-registry/019-rollout-encryption.md @@ -0,0 +1,33 @@ +# Task 019: Roll out known-drivers pattern — marko/encryption (single-driver) + +**Status**: pending +**Depends on**: 001, 005 +**Retry count**: 0 + +## Description +Apply the single-driver variant of the pilot pattern to `marko/encryption`. Single driver: `encryption-openssl`. + +## Context +- Driver: `marko/encryption-openssl` +- No siblings; no conflict declaration needed + +**Description text for known-drivers.php:** +- `marko/encryption-openssl` → `'OpenSSL-based symmetric encryption driver (AES-256-GCM)'` + +## Sub-steps +1. Create `packages/encryption/known-drivers.php` +2. Refactor `packages/encryption/src/Exceptions/NoDriverException.php`. Update existing `packages/encryption/tests/Unit/Exceptions/NoDriverExceptionTest.php` to match the new output format. +3. Add `packages/encryption/tests/KnownDriversValidationTest.php` +4. Verify `marko/testing` is in `packages/encryption/composer.json` `require-dev`; add it if missing + +## Requirements (Test Descriptions) +- [ ] `it ships a known-drivers.php file listing marko/encryption-openssl` +- [ ] `encryption NoDriverException reads from known-drivers.php and includes docs URL` +- [ ] `validation test passes (vacuous conflict assertion with one driver)` + +## Acceptance Criteria +- `packages/encryption/known-drivers.php` exists with the single entry +- `NoDriverException` refactored +- Validation test passes +- Existing encryption tests still pass +- Code follows code standards diff --git a/.claude/plans/known-drivers-registry/020-rollout-http.md b/.claude/plans/known-drivers-registry/020-rollout-http.md new file mode 100644 index 00000000..b376833f --- /dev/null +++ b/.claude/plans/known-drivers-registry/020-rollout-http.md @@ -0,0 +1,32 @@ +# Task 020: Roll out known-drivers pattern — marko/http (single-driver) + +**Status**: pending +**Depends on**: 001, 005 +**Retry count**: 0 + +## Description +Apply the single-driver variant of the pilot pattern to `marko/http`. Single driver: `http-guzzle`. + +## Context +- Driver: `marko/http-guzzle` + +**Description text for known-drivers.php:** +- `marko/http-guzzle` → `'Guzzle-based HTTP client driver'` + +## Sub-steps +1. Create `packages/http/known-drivers.php` +2. Refactor `packages/http/src/Exceptions/NoDriverException.php`. Update existing `packages/http/tests/Exceptions/NoDriverExceptionTest.php` to match the new output format. +3. Add `packages/http/tests/KnownDriversValidationTest.php` +4. Verify `marko/testing` is in `packages/http/composer.json` `require-dev`; add it if missing + +## Requirements (Test Descriptions) +- [ ] `it ships a known-drivers.php file listing marko/http-guzzle` +- [ ] `http NoDriverException reads from known-drivers.php and includes docs URL` +- [ ] `validation test passes` + +## Acceptance Criteria +- `packages/http/known-drivers.php` exists with the single entry +- `NoDriverException` refactored +- Validation test passes +- Existing http tests still pass +- Code follows code standards diff --git a/.claude/plans/known-drivers-registry/021-rollout-log.md b/.claude/plans/known-drivers-registry/021-rollout-log.md new file mode 100644 index 00000000..8b7292ae --- /dev/null +++ b/.claude/plans/known-drivers-registry/021-rollout-log.md @@ -0,0 +1,32 @@ +# Task 021: Roll out known-drivers pattern — marko/log (single-driver) + +**Status**: pending +**Depends on**: 001, 005 +**Retry count**: 0 + +## Description +Apply the single-driver variant of the pilot pattern to `marko/log`. Single driver: `log-file`. + +## Context +- Driver: `marko/log-file` + +**Description text for known-drivers.php:** +- `marko/log-file` → `'File-based logger with rotation'` + +## Sub-steps +1. Create `packages/log/known-drivers.php` +2. Refactor `packages/log/src/Exceptions/NoDriverException.php`. Update existing `packages/log/tests/Unit/Exceptions/NoDriverExceptionTest.php` to match the new output format. +3. Add `packages/log/tests/KnownDriversValidationTest.php` +4. Verify `marko/testing` is in `packages/log/composer.json` `require-dev`; add it if missing + +## Requirements (Test Descriptions) +- [ ] `it ships a known-drivers.php file listing marko/log-file` +- [ ] `log NoDriverException reads from known-drivers.php and includes docs URL` +- [ ] `validation test passes` + +## Acceptance Criteria +- `packages/log/known-drivers.php` exists with the single entry +- `NoDriverException` refactored +- Validation test passes +- Existing log tests still pass +- Code follows code standards diff --git a/.claude/plans/known-drivers-registry/022-rollout-notification.md b/.claude/plans/known-drivers-registry/022-rollout-notification.md new file mode 100644 index 00000000..d50b87a4 --- /dev/null +++ b/.claude/plans/known-drivers-registry/022-rollout-notification.md @@ -0,0 +1,32 @@ +# Task 022: Roll out known-drivers pattern — marko/notification (single-driver) + +**Status**: pending +**Depends on**: 001, 005 +**Retry count**: 0 + +## Description +Apply the single-driver variant of the pilot pattern to `marko/notification`. Single driver: `notification-database`. + +## Context +- Driver: `marko/notification-database` + +**Description text for known-drivers.php:** +- `marko/notification-database` → `'Database-backed notification driver'` + +## Sub-steps +1. Create `packages/notification/known-drivers.php` +2. Refactor `packages/notification/src/Exceptions/NoDriverException.php`. Update existing `packages/notification/tests/Unit/Exceptions/NoDriverExceptionTest.php` to match the new output format. +3. Add `packages/notification/tests/KnownDriversValidationTest.php` +4. Verify `marko/testing` is in `packages/notification/composer.json` `require-dev`; add it if missing + +## Requirements (Test Descriptions) +- [ ] `it ships a known-drivers.php file listing marko/notification-database` +- [ ] `notification NoDriverException reads from known-drivers.php and includes docs URL` +- [ ] `validation test passes` + +## Acceptance Criteria +- `packages/notification/known-drivers.php` exists +- `NoDriverException` refactored +- Validation test passes +- Existing notification tests still pass +- Code follows code standards diff --git a/.claude/plans/known-drivers-registry/023-rollout-translation.md b/.claude/plans/known-drivers-registry/023-rollout-translation.md new file mode 100644 index 00000000..a0f7bf46 --- /dev/null +++ b/.claude/plans/known-drivers-registry/023-rollout-translation.md @@ -0,0 +1,32 @@ +# Task 023: Roll out known-drivers pattern — marko/translation (single-driver) + +**Status**: pending +**Depends on**: 001, 005 +**Retry count**: 0 + +## Description +Apply the single-driver variant of the pilot pattern to `marko/translation`. Single driver: `translation-file`. + +## Context +- Driver: `marko/translation-file` + +**Description text for known-drivers.php:** +- `marko/translation-file` → `'File-based translation driver (PHP array files per locale)'` + +## Sub-steps +1. Create `packages/translation/known-drivers.php` +2. Refactor `packages/translation/src/Exceptions/NoDriverException.php`. Update existing `packages/translation/tests/Exceptions/NoDriverExceptionTest.php` to match the new output format. +3. Add `packages/translation/tests/KnownDriversValidationTest.php` +4. Verify `marko/testing` is in `packages/translation/composer.json` `require-dev`; add it if missing + +## Requirements (Test Descriptions) +- [ ] `it ships a known-drivers.php file listing marko/translation-file` +- [ ] `translation NoDriverException reads from known-drivers.php and includes docs URL` +- [ ] `validation test passes` + +## Acceptance Criteria +- `packages/translation/known-drivers.php` exists +- `NoDriverException` refactored +- Validation test passes +- Existing translation tests still pass +- Code follows code standards diff --git a/.claude/plans/known-drivers-registry/024-rollout-page-cache.md b/.claude/plans/known-drivers-registry/024-rollout-page-cache.md new file mode 100644 index 00000000..0e4cc699 --- /dev/null +++ b/.claude/plans/known-drivers-registry/024-rollout-page-cache.md @@ -0,0 +1,39 @@ +# Task 024: Roll out known-drivers pattern — marko/page-cache (single-driver; entity is add-on) + +**Status**: pending +**Depends on**: 001, 005 +**Retry count**: 0 + +## Description +Apply the single-driver variant to `marko/page-cache`. The page-cache family has TWO sibling packages on disk (`marko/page-cache-file`, `marko/page-cache-entity`) but ONLY ONE is a driver — `page-cache-file` binds `PageCacheInterface`; `page-cache-entity` has empty `bindings: []` and provides cache-invalidation observers via `#[Observer]` attributes. The entity package is an ADD-ON, not a driver. + +## Context +- Driver: `marko/page-cache-file` +- Add-on (NOT enrolled in known-drivers.php): `marko/page-cache-entity` +- `marko/page-cache-entity` will appear in skeleton's suggest block (added in task 025) but is NOT subject to conflict declarations or driver-list validation. + +**Description text for known-drivers.php:** +- `marko/page-cache-file` → `'File-based page cache driver'` + +## Sub-steps +1. Create `packages/page-cache/known-drivers.php` with ONLY the file driver entry +2. Refactor `packages/page-cache/src/Exceptions/NoDriverException.php`. **Note:** unlike the other 17 NoDriverException classes (which use a `noDriverInstalled()` factory), page-cache's current factory is `noBinding()`. **Rename it to `noDriverInstalled()` for consistency** with the rest of the codebase as part of this refactor. Confirm no callers exist (per plan scope notes, the exception is currently unused — `NoDriverException` factories are not yet wired to any throw site). Update the existing test `packages/page-cache/tests/Unit/Exceptions/NoDriverExceptionTest.php` to use the new method name. +3. Add `packages/page-cache/tests/KnownDriversValidationTest.php` +4. Verify `page-cache-entity`'s composer.json has NO `conflict` block (it should coexist with the file driver) — currently verified at plan creation: no `conflict` block present. +5. Add `marko/testing` to `packages/page-cache/composer.json` `require-dev` if not already present (verify against current composer.json) + +## Requirements (Test Descriptions) +- [ ] `it ships a known-drivers.php file listing only marko/page-cache-file` +- [ ] `it does not list marko/page-cache-entity (add-on, not driver)` +- [ ] `page-cache NoDriverException reads from known-drivers.php and includes docs URL` +- [ ] `page-cache NoDriverException exposes a noDriverInstalled() factory (renamed from noBinding for consistency)` +- [ ] `marko/page-cache-entity does not declare a conflict with marko/page-cache-file` +- [ ] `validation test passes` + +## Acceptance Criteria +- `packages/page-cache/known-drivers.php` exists with the single entry +- `NoDriverException` refactored +- `page-cache-entity/composer.json` does NOT have a conflict against page-cache-file (verify; should already be absent) +- Validation test passes +- Existing page-cache tests still pass +- Code follows code standards diff --git a/.claude/plans/known-drivers-registry/025-skeleton-suggest-consolidation.md b/.claude/plans/known-drivers-registry/025-skeleton-suggest-consolidation.md new file mode 100644 index 00000000..d0ca4e08 --- /dev/null +++ b/.claude/plans/known-drivers-registry/025-skeleton-suggest-consolidation.md @@ -0,0 +1,125 @@ +# Task 025: Consolidate skeleton's composer suggest block + +**Status**: pending +**Depends on**: 003, 008, 009, 010, 011, 012, 013, 014, 015, 016, 017, 018, 019, 020, 021, 022, 023, 024 +**Retry count**: 0 + +## Description +Update `packages/skeleton/composer.json`'s `suggest` block to include every driver from every interface's `known-drivers.php`, PLUS the two confirmed optional add-ons (`marko/database-readwrite`, `marko/page-cache-entity`) with explanatory descriptions. Recommended-first ordering per interface (matching the per-interface known-drivers.php ordering). Add CI test asserting every known driver appears in skeleton's suggest block — this is the cross-cutting parity check. + +## Context +- File to modify: `packages/skeleton/composer.json` +- New test file: `packages/skeleton/tests/KnownDriversSuggestParityTest.php` + +**CRITICAL: description-string source of truth.** The exact description string for each driver is defined in its interface's `known-drivers.php` (created in tasks 002, 008-017, 018-024). Skeleton's `suggest` block MUST use those exact strings (literal equality, including em-dashes, parenthetical clauses, and trailing punctuation). The `assertSkeletonSuggestContainsAll` validation test in each interface's KnownDriversValidationTest will fail if any string diverges by even a single character. + +**Implementation procedure (do NOT copy from the sample below):** +1. For each driver entry to add to skeleton's suggest, `require` the corresponding `packages/{interface}/known-drivers.php` file +2. Use the description value from that file verbatim +3. Group by interface, recommended-first ordering within each group + +Note: `marko/view-twig` is OUT OF SCOPE (does not exist as a package). Only `marko/view-latte` is listed for view. + +**Illustrative structure (DESCRIPTION STRINGS ARE PLACEHOLDERS — read actual strings from each known-drivers.php at implementation time):** + +```json +"suggest": { + "marko/view-latte": "", + + "marko/database-pgsql": "", + "marko/database-mysql": "", + "marko/database-readwrite": "Read/write connection splitting decorator (optional — works alongside a base driver)", + + "marko/cache-file": "", + "marko/cache-redis": "", + "marko/cache-array": "", + + "marko/errors-simple": "", + "marko/errors-advanced": "", + + "marko/filesystem-local": "", + "marko/filesystem-s3": "", + + "marko/inertia-react": "", + "marko/inertia-svelte": "", + "marko/inertia-vue": "", + + "marko/mail-smtp": "", + "marko/mail-log": "", + + "marko/media-gd": "", + "marko/media-imagick": "", + + "marko/page-cache-file": "", + "marko/page-cache-entity": "Auto-purges page-cache tags on entity save/delete (optional add-on)", + + "marko/pubsub-redis": "", + "marko/pubsub-pgsql": "", + + "marko/queue-sync": "", + "marko/queue-database": "", + "marko/queue-rabbitmq": "", + + "marko/session-file": "", + "marko/session-database": "", + + "marko/authentication-token": "", + "marko/encryption-openssl": "", + "marko/http-guzzle": "", + "marko/log-file": "", + "marko/notification-database": "", + "marko/translation-file": "" +} +``` + +Only the two add-on entries (`marko/database-readwrite`, `marko/page-cache-entity`) have free-form descriptions chosen at this task's discretion; everything else MUST be read verbatim from the corresponding known-drivers.php file. + +**Description-match constraint:** Every description for a driver entry must match exactly what its interface's `known-drivers.php` file says — the per-interface validation tests assert this. Add-on descriptions are not constrained (no known-drivers.php to match against). + +**Ordering convention within each interface group:** matches the known-drivers.php ordering of that interface (recommended-first). Visual grouping in the file (blank lines between interface families, as shown above) is not enforced by composer but improves human readability. + +**Cross-cutting parity test (`KnownDriversSuggestParityTest.php`):** +```php +test('skeleton suggest block contains every entry from every known-drivers.php file', function () { + $knownDriversFiles = glob(__DIR__ . '/../../*/known-drivers.php'); + $skeletonSuggest = json_decode( + file_get_contents(__DIR__ . '/../composer.json'), + associative: true, + )['suggest'] ?? []; + + foreach ($knownDriversFiles as $knownDriversPath) { + $drivers = require $knownDriversPath; + foreach ($drivers as $package => $description) { + expect($skeletonSuggest)->toHaveKey($package); + expect($skeletonSuggest[$package])->toBe($description); + } + } +}); + +test('skeleton suggest block includes known optional add-ons', function () { + $skeletonSuggest = json_decode( + file_get_contents(__DIR__ . '/../composer.json'), + associative: true, + )['suggest'] ?? []; + + expect($skeletonSuggest)->toHaveKey('marko/database-readwrite'); + expect($skeletonSuggest)->toHaveKey('marko/page-cache-entity'); +}); +``` + +## Requirements (Test Descriptions) +- [ ] `it lists every driver from every known-drivers.php file in skeleton suggest` +- [ ] `it preserves descriptions verbatim between known-drivers.php and skeleton suggest` +- [ ] `it includes marko/database-readwrite as an optional add-on` +- [ ] `it includes marko/page-cache-entity as an optional add-on` +- [ ] `it does not move any view, database, cache, etc. drivers into require or require-dev` +- [ ] `skeleton composer.json remains valid JSON after the consolidation` + +## Acceptance Criteria +- `packages/skeleton/composer.json` `suggest` block contains entries for every driver in every interface's known-drivers.php file +- Descriptions match exactly (literal string equality) +- Add-ons (`marko/database-readwrite`, `marko/page-cache-entity`) present with explanatory descriptions +- No drivers added to `require` or `require-dev` (skeleton remains engine-agnostic) +- Cross-cutting parity test passes +- `composer validate packages/skeleton/composer.json` succeeds +- Code follows code standards diff --git a/.claude/plans/known-drivers-registry/_plan.md b/.claude/plans/known-drivers-registry/_plan.md new file mode 100644 index 00000000..f8642973 --- /dev/null +++ b/.claude/plans/known-drivers-registry/_plan.md @@ -0,0 +1,221 @@ +# Plan: known-drivers.php Registry Pattern + +## Created +2026-05-27 + +## Status +planning + +## Objective +Establish `known-drivers.php` as the single curated source of truth for each interface package's mutually-exclusive drivers. Eliminate hardcoded driver lists scattered across `NoDriverException` classes, driver `composer.json` `conflict` blocks, and skeleton's `suggest` block — mechanically enforce sync via CI tests. Roll out to every interface package with ≥1 driver. + +## Related Issues +Closes #89 + +## Discovery Notes + +**Existing state:** +- 18 packages already define a `NoDriverException` class. 17 use a `noDriverInstalled()` static factory; **`marko/page-cache` is the lone outlier — it uses `noBinding()`** (will be standardized to `noDriverInstalled()` in task 024). Most have a hardcoded `private const array DRIVER_PACKAGES = [...]`; a few have just a single hardcoded package in the suggestion text. Pattern is established but lists drift independently. +- `marko/view`'s `NoDriverException` currently lists only `marko/view-latte` (no `marko/view-twig` package exists on disk in this monorepo). This plan formalizes the registry pattern; **the actual creation of `marko/view-twig` is OUT OF SCOPE** — it's a separate plan. For now, `view/known-drivers.php` will list only `marko/view-latte`. +- **`marko/inertia` does NOT currently have a `NoDriverException`** — it must be created as part of task 011 following the established pattern. +- The `admin` package has a `NoDriverException` but no drivers (admin-api/admin-auth/admin-panel are sub-modules of an admin system, not drivers). Excluded as a separate concern. +- **Several existing tests assert against the soon-to-be-removed `DRIVER_PACKAGES` const** (e.g., `packages/database/tests/NoDriverExceptionTest.php` line 9-16). The refactor tasks (003, 008-017) must update or remove those assertions in addition to the source-file changes. + +**Driver classification (multi-driver, mutually-exclusive — bind same interface):** +| Interface | Drivers | Recommended (listed first) | +|-----------|---------|----------------------------| +| cache | array, file, redis | file | +| database | mysql, pgsql | pgsql | +| errors | simple, advanced | simple (prod-safe default) | +| filesystem | local, s3 | local | +| inertia | react, svelte, vue | react | +| mail | log, smtp | log (dev-safe default; smtp for prod) | +| media | gd, imagick | gd (broader availability) | +| pubsub | pgsql, redis | redis (purpose-built) | +| queue | sync, database, rabbitmq | sync (zero-infrastructure default) | +| session | file, database | file | +| view | latte (only driver currently — view-twig not yet built) | latte | + +**Single-driver interfaces (one driver currently — known-drivers.php still applies):** +authentication (token), encryption (openssl), http (guzzle), log (file), notification (database), translation (file), page-cache (file) + +**Optional add-ons (NOT enrolled in known-drivers.php):** +- `marko/database-readwrite` — decorator wrapping the configured driver via boot callback; coexists with mysql/pgsql. **Not present in this monorepo's `packages/` directory** (lives in a sibling split repo). Skeleton's suggest block still references it; no on-disk validation required since add-ons don't participate in the conflict-block check. +- `marko/page-cache-entity` — bridge package adding observer-based cache invalidation; coexists with page-cache-file. Present in the monorepo. + +**Mechanical rule:** a package is a driver iff its `module.php` `bindings` array contains the interface's defining contract. Add-ons have empty `bindings: []` (database-readwrite, page-cache-entity confirmed). For v1, `known-drivers.php` is the curated source — interface-package maintainer decides. Marker interfaces or `extra.marko.driver_for` declarations are out of scope but viable follow-ups if drift becomes a problem. + +**errors-advanced URL rendering:** currently `PrettyHtmlFormatter::formatDevelopment` only renders `$report->message` through `escape()` — it does NOT currently render `$report->context` or `$report->suggestion` at all (verified in `packages/errors-advanced/src/PrettyHtmlFormatter.php` line 58-89). Task 006 in this plan does TWO things: (1) adds rendering of `context` and `suggestion` to the HTML output (so users actually see the NoDriverException's installation guidance), and (2) adds URL detection + `target="_blank" rel="noopener noreferrer"` linkification, applied uniformly to message, context, and suggestion fields. Without (1), the docs URLs we're adding to NoDriverException won't be visible in errors-advanced output at all. + +**marko/view test leak:** `packages/view/tests/Feature/IntegrationTest.php` uses `Marko\View\Latte\LatteEngineFactory` — a hard dependency on view-latte from inside the interface package's test suite. Cleanup task moves it to view-latte (where the integration test logically belongs). + +**Test infrastructure decision:** A shared `KnownDriversValidator` class in `marko/testing` provides assertion helpers (`assertConflictBlocksMatch`, `assertSkeletonSuggestContainsAll`). Each interface package's test file is a thin wrapper passing its own `known-drivers.php` path. Skip gracefully when sibling driver packages or skeleton aren't on disk (enables marko/view-only installs to pass tests without view-latte present). + +## Scope + +### In Scope + +**Phase A — Pilot (database):** +- Create `packages/database/known-drivers.php` with pgsql-first ordering +- Refactor `marko/database`'s `NoDriverException` to read from known-drivers.php; include descriptions and derived docs URLs (`https://marko.build/docs/packages/{basename}/`) +- Update `database-mysql` and `database-pgsql` `composer.json` to declare mutual `conflict` +- Add CI validation test in `marko/database` (uses shared helper) + +**Phase B — Shared infrastructure (parallel with pilot):** +- Create `KnownDriversValidator` in `marko/testing` with assertion helpers +- Extend `marko/errors-advanced`'s `PrettyHtmlFormatter` to (1) render `context` and `suggestion` fields from `ErrorReport` (currently dropped), and (2) auto-detect `http(s)://` URLs in message/context/suggestion text and wrap them with `` +- Clean up `marko/view` test suite: move `IntegrationTest.php` from `marko/view/tests/Feature/` to `marko/view-latte/tests/Feature/`. Verify marko/view tests pass with view-latte uninstalled. + +**Phase C — Roll out to multi-driver interfaces (10, parallel):** +- cache, errors, filesystem, inertia, mail, media, pubsub, queue, session, view +- Each gets: known-drivers.php, refactored NoDriverException, mutual conflict blocks on drivers, validation test + +**Phase D — Roll out to single-driver interfaces (7, parallel):** +- authentication, encryption, http, log, notification, translation, page-cache +- Each gets: known-drivers.php (one entry), refactored NoDriverException, validation test (vacuous conflict assertion) + +**Phase E — Skeleton consolidation:** +- Update `marko/skeleton`'s `composer.json` `suggest` block with all drivers (recommended-first per interface) and the two confirmed add-ons (`marko/database-readwrite`, `marko/page-cache-entity`) with explanatory text + +### Out of Scope + +- `admin/NoDriverException` cleanup — admin-{api,auth,panel} are sub-modules not drivers; that NoDriverException is vestigial. Flagged for separate investigation. +- Marker interfaces (e.g., `ViewDriverInterface`) for mechanical driver enforcement — viable follow-up if curation drifts from reality +- `extra.marko.driver_for` declarations in driver composer.json — same reason as above +- Wiring `NoDriverException` to actually be thrown when no driver is bound — pre-existing gap across all 18 packages; the exception classes are defined but no code throws them. The container throws a generic `BindingException` today. Out of scope for this plan; tracked as a follow-up. +- Removing `marko/view-latte` from monorepo-wide CI runs — view-latte tests still run as part of the full suite, just no longer via marko/view's test directory. + +## Success Criteria +- [ ] Every interface package with ≥1 driver has a `known-drivers.php` file +- [ ] Every interface package with a `NoDriverException` reads its driver list from `known-drivers.php` (no more hardcoded `DRIVER_PACKAGES` consts) +- [ ] Every driver in a multi-driver family declares Composer `conflict` against all its siblings +- [ ] Skeleton's `suggest` block includes every entry from every `known-drivers.php` file plus optional add-ons +- [ ] CI validation tests enforce sync between `known-drivers.php`, driver `conflict` blocks, and skeleton `suggest` — fails build on drift +- [ ] CI tests skip gracefully (not fail) when sibling driver packages aren't on disk +- [ ] `marko/view` test suite has zero dependency on `marko/view-latte` — passes with view-latte uninstalled +- [ ] `marko/errors-advanced` renders URLs in exception suggestion text as `target="_blank"` links +- [ ] `NoDriverException::noDriverInstalled()` output includes derived docs URL for each driver +- [ ] All tests passing (`composer test`) +- [ ] Code follows project standards + +## Task Overview + +| Task | Description | Depends On | Status | +|------|-------------|------------|--------| +| 001 | Add `KnownDriversValidator` to marko/testing | - | pending | +| 002 | Pilot: database known-drivers.php | - | pending | +| 003 | Pilot: refactor database `NoDriverException` (read from file + docs URLs) | 002 | pending | +| 004 | Pilot: database-mysql/pgsql conflict blocks | 002 | pending | +| 005 | Pilot: database validation test | 001, 002, 003, 004 | pending | +| 006 | Render context/suggestion + URL linkification in errors-advanced | - | pending | +| 007 | Clean up marko/view test suite (move IntegrationTest) | - | pending | +| 008 | Roll out: cache | 001, 005 | pending | +| 009 | Roll out: errors | 001, 005 | pending | +| 010 | Roll out: filesystem | 001, 005 | pending | +| 011 | Roll out: inertia (creates new NoDriverException + 3 conflict blocks) | 001, 005 | pending | +| 012 | Roll out: mail | 001, 005 | pending | +| 013 | Roll out: media | 001, 005 | pending | +| 014 | Roll out: pubsub | 001, 005 | pending | +| 015 | Roll out: queue | 001, 005 | pending | +| 016 | Roll out: session | 001, 005 | pending | +| 017 | Roll out: view (single-driver: only view-latte; view-twig out of scope) | 001, 005, 007 | pending | +| 018 | Roll out: authentication (single-driver) | 001, 005 | pending | +| 019 | Roll out: encryption (single-driver) | 001, 005 | pending | +| 020 | Roll out: http (single-driver) | 001, 005 | pending | +| 021 | Roll out: log (single-driver) | 001, 005 | pending | +| 022 | Roll out: notification (single-driver) | 001, 005 | pending | +| 023 | Roll out: translation (single-driver) | 001, 005 | pending | +| 024 | Roll out: page-cache (single-driver; entity is add-on; renames noBinding to noDriverInstalled) | 001, 005 | pending | +| 025 | Skeleton consolidation (suggest block with all drivers + add-ons) | 003, 008–024 | pending | + +## Architecture Notes + +**File format (`known-drivers.php`):** +```php + 'PostgreSQL driver (recommended for new projects — strong JSON, FTS, pgvector support)', + 'marko/database-mysql' => 'MySQL/MariaDB driver', +]; +``` + +Flat `package => description` array. Same shape as composer's `suggest` block, so the CI test can do literal equality between known-drivers.php and the relevant subset of skeleton's suggest. + +**Docs URL derivation (in NoDriverException):** +```php +private static function docsUrl(string $package): string +{ + $basename = substr($package, strlen('marko/')); + return "https://marko.build/docs/packages/{$basename}/"; +} +``` + +Pattern works for all `marko/*` packages. If third-party drivers ever enroll, we can switch to explicit URLs in the file structure — but for now derivation suffices. + +**Refactored NoDriverException shape (every interface):** +```php +public static function noDriverInstalled(): self +{ + $drivers = require __DIR__ . '/../../known-drivers.php'; + $packageList = implode("\n", array_map( + fn (string $pkg, string $description) => + "- {$pkg}: {$description}\n Install: composer require {$pkg}\n Docs: " . self::docsUrl($pkg), + array_keys($drivers), + array_values($drivers), + )); + + return new self( + message: 'No {interface} driver installed.', + context: 'Attempted to resolve {InterfaceName} but no implementation is bound.', + suggestion: "Install one of these drivers:\n{$packageList}", + ); +} +``` + +**KnownDriversValidator interface (marko/testing):** +```php +namespace Marko\Testing\KnownDrivers; + +class KnownDriversValidator +{ + public static function assertConflictBlocksMatch(string $knownDriversPath, string $packagesDir): void; + public static function assertSkeletonSuggestContainsAll(string $knownDriversPath, string $skeletonComposerPath): void; + public static function assertDocsUrlsResolveToValidPattern(string $knownDriversPath): void; +} +``` + +Each assertion: +- Reads `known-drivers.php` +- Locates the referenced sibling/skeleton packages on disk via `$packagesDir` +- **Skips gracefully** (not fails) when files missing — since static methods cannot call `markTestSkipped()` directly, throw `\PHPUnit\Framework\SkippedWithMessageException` which Pest treats as a skip +- For `assertSkeletonSuggestContainsAll` specifically, also skip when skeleton.composer.json exists but has no `suggest` key yet — this is required because per-interface validation tests (005, 008-024) run BEFORE skeleton consolidation (025) in topological order +- Asserts the expected sync invariant when files are present and populated + +**errors-advanced URL handling:** +The `PrettyHtmlFormatter::formatDevelopment` currently only renders `$report->message` through a private `escape()` method; `$report->context` and `$report->suggestion` are never rendered. This plan: (1) adds context/suggestion rendering into the HTML heredoc (positioned after the message, with `.context` and `.suggestion` CSS classes for styling); (2) introduces a private `escapeAndLinkifyUrls()` helper that detects `http(s)://...` URLs (regex), splits the text, htmlspecialchars-escapes the non-URL portions, and wraps URL portions in `...`. The `escape()` callsite for `$report->message` is replaced with the new helper, and the new context/suggestion rendering also uses it. Plaintext output (errors-simple) is unchanged — URLs stay as plain text. + +## Risks & Mitigations + +- **Risk:** Refactoring 18 NoDriverException classes is touch-heavy; subtle bugs (typos in interface names, wrong docs URL pattern) could slip through. + **Mitigation:** The validation tests (task 005's pattern, replicated per interface in tasks 008-024) mechanically catch drift between known-drivers.php and the composer.json metadata. The docs URL derivation is centralized in one place (one helper per NoDriverException, or pulled into a shared helper). + +- **Risk:** Tests run in CI where all packages are present (monorepo), but a user installing marko/view standalone would fail validation tests if they're written naively. + **Mitigation:** Skip-gracefully behavior in `KnownDriversValidator`. Validate by running `marko/view` tests with marko/view-latte uninstalled as part of task 007 acceptance criteria. + +- **Risk:** Adding `conflict` declarations to ~25+ driver packages may break existing applications that intentionally co-install incompatible drivers (unlikely but possible during development). + **Mitigation:** This is the correct fail-loud behavior per Marko principles. Any app that worked accidentally with two drivers installed would have hit `BindingConflictException` at runtime anyway — failing at install time is strictly better. Document the change in the PR description. + +- **Risk:** `errors-advanced` URL linkification could break existing output formatting if the regex over-matches (e.g., catching text that looks URL-ish but isn't). + **Mitigation:** Conservative regex pattern (require `http://` or `https://` prefix; stop at whitespace or `<`). Add regression tests for non-URL text passing through unchanged. Tests run before merge. + +- **Risk:** Skeleton consolidation task (025) creates a long, hard-to-read suggest block in composer.json. + **Mitigation:** Composer suggest is read at install time only, never at runtime. Length is a one-time cost during scaffolding. The recommended-first ordering with descriptions makes it navigable. + +- **Risk:** Single-driver interface validation tests are vacuous (no siblings to compare conflict blocks against). The test may pass even if real misconfiguration exists. + **Mitigation:** Vacuous-but-correct is acceptable for v1. When a second driver is added to any of these interfaces, the validation test starts doing real work without code changes — that's a feature. Documented in task 018-024. + +- **Risk:** The `admin/NoDriverException` excluded from this plan could confuse maintainers who see other packages refactored but admin left behind. + **Mitigation:** Add a one-line code comment in `admin/NoDriverException` flagging it as vestigial pending investigation. Document in PR. From bb87f0b729b740ae5f8acba85d5f7c1dbab7a6bf Mon Sep 17 00:00:00 2001 From: Mark Shust Date: Wed, 27 May 2026 09:47:27 -0400 Subject: [PATCH 2/5] docs(known-drivers-registry): drop conflict-block sub-tasks after PR #92 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #92 settled the architectural question: Marko relies on DI-level BindingConflictException for double-binding detection, not Composer `conflict` declarations. This plan no longer adds conflict blocks or validates them — every rollout task is correspondingly simpler. Changes: - Deleted task 004 (pilot conflict blocks) — no longer needed - Updated _plan.md task table, objective, success criteria, risks - Removed assertConflictBlocksMatch from KnownDriversValidator (task 001); helper now exposes assertSkeletonSuggestContainsAll + assertDocsUrlsResolveToValidPattern - Stripped conflict sub-step / requirement / acceptance criterion from rollout tasks 008-016 (multi-driver) and 011 (inertia) - Rewrote task 017 (view): both view-latte and view-twig now in monorepo post-PR #92, no conflicts, both belong in known-drivers.php - Cleaned up "vacuous conflict assertion" phrasing from single-driver tasks 018-024 - Simplified task 024 (page-cache): removed page-cache-entity conflict verification (moot when no drivers have conflicts) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../001-known-drivers-validator-helper.md | 18 +++----- .../004-pilot-database-conflict-blocks.md | 34 -------------- .../005-pilot-database-validation-test.md | 21 +++------ .../008-rollout-cache.md | 8 +--- .../009-rollout-errors.md | 9 +--- .../010-rollout-filesystem.md | 8 +--- .../011-rollout-inertia.md | 9 +--- .../012-rollout-mail.md | 8 +--- .../013-rollout-media.md | 8 +--- .../014-rollout-pubsub.md | 8 +--- .../015-rollout-queue.md | 8 +--- .../016-rollout-session.md | 8 +--- .../017-rollout-view.md | 44 ++++++++++--------- .../018-rollout-authentication.md | 10 ++--- .../019-rollout-encryption.md | 4 +- .../024-rollout-page-cache.md | 7 +-- .claude/plans/known-drivers-registry/_plan.md | 40 +++++++---------- 17 files changed, 80 insertions(+), 172 deletions(-) delete mode 100644 .claude/plans/known-drivers-registry/004-pilot-database-conflict-blocks.md diff --git a/.claude/plans/known-drivers-registry/001-known-drivers-validator-helper.md b/.claude/plans/known-drivers-registry/001-known-drivers-validator-helper.md index e753ba1e..38f27781 100644 --- a/.claude/plans/known-drivers-registry/001-known-drivers-validator-helper.md +++ b/.claude/plans/known-drivers-registry/001-known-drivers-validator-helper.md @@ -12,21 +12,17 @@ Create `KnownDriversValidator` in `marko/testing` providing shared assertion met - New test file: `packages/testing/tests/KnownDrivers/KnownDriversValidatorTest.php` - Reference: existing helpers in `packages/testing/src/Fake/` for shape and namespace patterns -The class provides three static methods (each must skip-gracefully on missing files): +The class provides two static methods (each must skip-gracefully on missing files): -1. **`assertConflictBlocksMatch(string $knownDriversPath, string $packagesDir): void`** — reads the known-drivers.php file, then for each driver listed, locates `{$packagesDir}/{basename}/composer.json` on disk and asserts its `conflict` block contains every OTHER driver from the list (mutual exclusion is symmetric). If a driver's composer.json is not found on disk, that specific driver is skipped (not failed). If the known-drivers.php file itself is missing, the assertion fails loudly. - -2. **`assertSkeletonSuggestContainsAll(string $knownDriversPath, string $skeletonComposerPath): void`** — reads known-drivers.php, locates skeleton's composer.json, asserts every entry from known-drivers.php is present in skeleton's `suggest` block (descriptions must match verbatim). Skeleton's suggest MAY contain additional entries (add-ons, etc.). +1. **`assertSkeletonSuggestContainsAll(string $knownDriversPath, string $skeletonComposerPath): void`** — reads known-drivers.php, locates skeleton's composer.json, asserts every entry from known-drivers.php is present in skeleton's `suggest` block (descriptions must match verbatim). Skeleton's suggest MAY contain additional entries (add-ons, etc.). **Skip behavior:** skip if (a) skeleton's composer.json is not on disk, OR (b) skeleton's composer.json exists but has no `suggest` key (still being built — task 025 populates it). Once skeleton has a `suggest` key, missing entries become hard failures. This three-state skip is REQUIRED so that per-interface validation tests in tasks 005, 008-024 can pass BEFORE task 025 runs. After task 025 lands, the skip falls through and real assertions fire. -3. **`assertDocsUrlsResolveToValidPattern(string $knownDriversPath): void`** — reads known-drivers.php and asserts every key matches the `marko/*` prefix pattern (URLs are derived from package names; entries that don't follow the pattern can't generate valid URLs). +2. **`assertDocsUrlsResolveToValidPattern(string $knownDriversPath): void`** — reads known-drivers.php and asserts every key matches the `marko/*` prefix pattern (URLs are derived from package names; entries that don't follow the pattern can't generate valid URLs). + +**Note:** This class does NOT include `assertConflictBlocksMatch`. PR #92 settled the design question — Marko relies on DI-level `BindingConflictException` for double-binding detection, not Composer `conflict` declarations. So there are no conflict blocks to validate, only the skeleton-suggest parity. ## Requirements (Test Descriptions) - [ ] `it reads driver list from known-drivers.php file` -- [ ] `it asserts conflict blocks contain all sibling drivers` -- [ ] `it fails assertion when a driver conflict block is missing a sibling` -- [ ] `it skips assertion gracefully when a driver package is not on disk` -- [ ] `it treats a vacuous conflict assertion as passing when only one driver is listed` - [ ] `it asserts skeleton suggest block contains all known drivers with matching descriptions` - [ ] `it skips skeleton assertion gracefully when skeleton composer.json is not on disk` - [ ] `it skips skeleton assertion when skeleton composer.json has no suggest key yet` @@ -37,11 +33,11 @@ The class provides three static methods (each must skip-gracefully on missing fi - [ ] `it fails loudly when the known-drivers.php file itself is missing` ## Acceptance Criteria -- `KnownDriversValidator` is a non-readonly class with three public static methods +- `KnownDriversValidator` is a non-readonly class with two public static methods - All file paths passed as parameters (no hardcoded paths inside the helper) - **Skip mechanism:** since these are static methods (no bound `$this`), they cannot call `markTestSkipped()` directly. Throw `\PHPUnit\Framework\SkippedWithMessageException` (the same exception PHPUnit's `markTestSkipped()` throws under the hood). Pest treats this exception identically to `markTestSkipped()`. Alternative: throw a custom `KnownDriverAssertionSkipped` exception extending `\PHPUnit\Framework\SkippedTestError` for clearer semantics; either approach is acceptable. - Comprehensive test coverage with fixture known-drivers.php files in `packages/testing/tests/KnownDrivers/fixtures/` -- **Performance:** validation methods read files synchronously from disk. For each assertion call, they parse one known-drivers.php file plus 0–N composer.json files. This is acceptable for CI (each interface package runs one validation test). Do not memoize across calls — tests should be independent. +- **Performance:** validation methods read files synchronously from disk. For each assertion call, they parse one known-drivers.php file plus skeleton's composer.json. This is acceptable for CI. Do not memoize across calls — tests should be independent. - Code follows code standards (strict_types, typed params/returns, `@throws` tags where applicable; `@throws \PHPUnit\Framework\SkippedWithMessageException` documented on each method) ## Implementation Notes diff --git a/.claude/plans/known-drivers-registry/004-pilot-database-conflict-blocks.md b/.claude/plans/known-drivers-registry/004-pilot-database-conflict-blocks.md deleted file mode 100644 index 2f7f3dd3..00000000 --- a/.claude/plans/known-drivers-registry/004-pilot-database-conflict-blocks.md +++ /dev/null @@ -1,34 +0,0 @@ -# Task 004: Pilot — add mutual conflict blocks to database-mysql and database-pgsql - -**Status**: pending -**Depends on**: 002 -**Retry count**: 0 - -## Description -Add Composer `conflict` declarations to `marko/database-mysql` and `marko/database-pgsql` so they cannot be installed together. Each driver's `conflict` block must list every OTHER driver from `database/known-drivers.php` (currently just the one sibling). Note: `marko/database-readwrite` is NOT in this list — it's an add-on that coexists with both drivers. - -## Context -- Files to modify: - - `packages/database-mysql/composer.json` — add `"conflict": {"marko/database-pgsql": "*"}` - - `packages/database-pgsql/composer.json` — add `"conflict": {"marko/database-mysql": "*"}` -- New test files (one per driver, to verify the conflict declaration): - - `packages/database-mysql/tests/ComposerConflictTest.php` - - `packages/database-pgsql/tests/ComposerConflictTest.php` -- Reference: `packages/view-twig/composer.json` already has this pattern (from the previous plan); mirror its structure. - -**Important — verify current state first:** Check whether either composer.json already has a `conflict` block. If yes, extend it; if no, add it. Both packages currently lack `conflict` blocks (as of plan creation). - -`marko/database-readwrite` does NOT get a conflict declaration — it's not in known-drivers.php and is intended to coexist with mysql or pgsql. - -## Requirements (Test Descriptions) -For each driver (mysql and pgsql), in its respective test file: -- [ ] `it declares a Composer conflict with the sibling database driver` -- [ ] `it does not declare a conflict with marko/database-readwrite (add-on coexists)` -- [ ] `it uses wildcard version for the conflict declaration` - -## Acceptance Criteria -- Both driver composer.json files include a `conflict` block listing the sibling driver with `*` version -- Neither lists `marko/database-readwrite` as a conflict -- New test files verify the conflict declarations exist and have the correct shape -- Both test files pass -- Code follows code standards diff --git a/.claude/plans/known-drivers-registry/005-pilot-database-validation-test.md b/.claude/plans/known-drivers-registry/005-pilot-database-validation-test.md index 23129ac8..8d655eb4 100644 --- a/.claude/plans/known-drivers-registry/005-pilot-database-validation-test.md +++ b/.claude/plans/known-drivers-registry/005-pilot-database-validation-test.md @@ -1,22 +1,19 @@ # Task 005: Pilot — add validation test in marko/database **Status**: pending -**Depends on**: 001, 002, 003, 004 +**Depends on**: 001, 002, 003 **Retry count**: 0 ## Description -Add a validation test in `marko/database` that uses the `KnownDriversValidator` helper (from task 001) to mechanically enforce sync between `database/known-drivers.php`, each driver's composer.json `conflict` block, and (when present) skeleton's `suggest` block. This is the canonical pattern that tasks 008-024 will replicate for every other interface package. +Add a validation test in `marko/database` that uses the `KnownDriversValidator` helper (from task 001) to mechanically enforce sync between `database/known-drivers.php` and (when present) skeleton's `suggest` block. This is the canonical pattern that tasks 008-024 will replicate for every other interface package. ## Context - New file: `packages/database/tests/KnownDriversValidationTest.php` - Reference: the `KnownDriversValidator` API created in task 001 -- The test must skip gracefully when: - - A driver package (`marko/database-mysql`, `marko/database-pgsql`) isn't on disk → that specific driver assertion is skipped - - Skeleton's composer.json isn't on disk → the skeleton-parity assertion is skipped entirely +- The test must skip gracefully when skeleton's composer.json isn't on disk OR doesn't have a `suggest` key yet - The test must fail loudly when: - `known-drivers.php` is missing - - A driver IS on disk but its conflict block is wrong (missing a sibling, listing a non-driver, etc.) - - Skeleton IS on disk but its suggest block is missing a known driver entry or has a mismatched description + - Skeleton IS on disk with a `suggest` key but is missing a known driver entry or has a mismatched description **Test file shape:** ```php @@ -27,13 +24,8 @@ declare(strict_types=1); use Marko\Testing\KnownDrivers\KnownDriversValidator; $knownDriversPath = __DIR__ . '/../known-drivers.php'; -$packagesDir = __DIR__ . '/../../'; $skeletonComposerPath = __DIR__ . '/../../skeleton/composer.json'; -test('every database driver declares conflict with siblings', function () use ($knownDriversPath, $packagesDir) { - KnownDriversValidator::assertConflictBlocksMatch($knownDriversPath, $packagesDir); -}); - test('skeleton suggest block contains all database drivers', function () use ($knownDriversPath, $skeletonComposerPath) { KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath); }); @@ -44,13 +36,12 @@ test('every database driver follows marko slash prefix pattern', function () use ``` ## Requirements (Test Descriptions) -- [ ] `every database driver declares conflict with siblings` - [ ] `skeleton suggest block contains all database drivers` - [ ] `every database driver follows marko slash prefix pattern` ## Acceptance Criteria - Test file exists at `packages/database/tests/KnownDriversValidationTest.php` -- Test passes in the monorepo (where all packages are present) -- Test would also pass in a standalone `marko/database` install (where skeleton and drivers may not be present) — verify by mentally walking through the skip logic, or actually running with packages temporarily removed +- Test passes in the monorepo (where skeleton is present after task 025 runs; skip behavior kicks in before) +- Test would also pass in a standalone `marko/database` install (where skeleton may not be present) — verify by mentally walking through the skip logic - **`marko/testing` added to `packages/database/composer.json` `require-dev`** (verified at plan creation: NOT currently a dev dependency). Use `"marko/testing": "self.version"` to match monorepo conventions. - Code follows code standards diff --git a/.claude/plans/known-drivers-registry/008-rollout-cache.md b/.claude/plans/known-drivers-registry/008-rollout-cache.md index 6e01f338..e4dc0fea 100644 --- a/.claude/plans/known-drivers-registry/008-rollout-cache.md +++ b/.claude/plans/known-drivers-registry/008-rollout-cache.md @@ -21,22 +21,18 @@ Apply the pilot pattern (tasks 002–005) to `marko/cache`. Three drivers: `cach ## Sub-steps (each yields one or more requirements) 1. Create `packages/cache/known-drivers.php` with the three entries (file first) 2. Refactor `packages/cache/src/Exceptions/NoDriverException.php` to read from known-drivers.php and include docs URLs (same shape as task 003). Update existing `packages/cache/tests/Exceptions/NoDriverExceptionTest.php` assertions to match the new output format. -3. Add mutual `conflict` blocks to all three driver composer.json files: each driver's conflict block lists the other two -4. Add `packages/cache/tests/KnownDriversValidationTest.php` using `KnownDriversValidator` -5. Verify `marko/testing` is in `packages/cache/composer.json` `require-dev`; add it (`"marko/testing": "self.version"`) if missing +3. Add `packages/cache/tests/KnownDriversValidationTest.php` using `KnownDriversValidator` +4. Verify `marko/testing` is in `packages/cache/composer.json` `require-dev`; add it (`"marko/testing": "self.version"`) if missing ## Requirements (Test Descriptions) - [ ] `it ships a known-drivers.php file listing all three cache drivers` - [ ] `it lists marko/cache-file first as the recommended driver` - [ ] `cache NoDriverException reads from known-drivers.php and includes docs URLs` -- [ ] `each cache driver declares conflict with both other drivers` -- [ ] `validation test confirms conflict blocks match known-drivers list` - [ ] `validation test skips skeleton parity assertion when skeleton is absent` ## Acceptance Criteria - `packages/cache/known-drivers.php` exists with three entries - `NoDriverException` refactored to mirror database/NoDriverException pattern -- All three driver composer.json files have correctly-populated `conflict` blocks - Validation test passes - Existing cache tests still pass - Code follows code standards diff --git a/.claude/plans/known-drivers-registry/009-rollout-errors.md b/.claude/plans/known-drivers-registry/009-rollout-errors.md index 6ef11924..be59d60e 100644 --- a/.claude/plans/known-drivers-registry/009-rollout-errors.md +++ b/.claude/plans/known-drivers-registry/009-rollout-errors.md @@ -11,7 +11,6 @@ Apply the pilot pattern to `marko/errors`. Two drivers: `errors-simple`, `errors - Interface: `Marko\Errors\ErrorHandlerInterface` - Drivers: `marko/errors-simple`, `marko/errors-advanced` - Recommended-first ordering: `errors-simple` (prod-safe default — minimal info exposed; errors-advanced is for development with detailed stack traces) -- Confirm both have `module.php` binding `ErrorHandlerInterface` before writing conflict blocks **Description text for known-drivers.php:** - `marko/errors-simple` → `'Simple error handler (recommended for production — minimal information disclosure)'` @@ -20,21 +19,17 @@ Apply the pilot pattern to `marko/errors`. Two drivers: `errors-simple`, `errors ## Sub-steps 1. Create `packages/errors/known-drivers.php` 2. Refactor `packages/errors/src/Exceptions/NoDriverException.php` to read from known-drivers.php. Update existing `packages/errors/tests/Unit/Exceptions/NoDriverExceptionTest.php` to match the new output format. -3. Add mutual `conflict` blocks to both driver composer.json files -4. Add `packages/errors/tests/KnownDriversValidationTest.php` -5. Verify `marko/testing` is in `packages/errors/composer.json` `require-dev`; add it (`"marko/testing": "self.version"`) if missing +3. Add `packages/errors/tests/KnownDriversValidationTest.php` +4. Verify `marko/testing` is in `packages/errors/composer.json` `require-dev`; add it (`"marko/testing": "self.version"`) if missing ## Requirements (Test Descriptions) - [ ] `it ships a known-drivers.php file listing both errors drivers` - [ ] `it lists marko/errors-simple first as the recommended driver` - [ ] `errors NoDriverException reads from known-drivers.php and includes docs URLs` -- [ ] `each errors driver declares conflict with the sibling driver` -- [ ] `validation test confirms conflict blocks match known-drivers list` ## Acceptance Criteria - `packages/errors/known-drivers.php` exists - `NoDriverException` refactored -- Both driver composer.json files have correctly-populated `conflict` blocks - Validation test passes - Existing errors tests still pass - Code follows code standards diff --git a/.claude/plans/known-drivers-registry/010-rollout-filesystem.md b/.claude/plans/known-drivers-registry/010-rollout-filesystem.md index cbe3e7bb..21b25346 100644 --- a/.claude/plans/known-drivers-registry/010-rollout-filesystem.md +++ b/.claude/plans/known-drivers-registry/010-rollout-filesystem.md @@ -20,21 +20,17 @@ Apply the pilot pattern to `marko/filesystem`. Two drivers: `filesystem-local`, ## Sub-steps 1. Create `packages/filesystem/known-drivers.php` 2. Refactor `packages/filesystem/src/Exceptions/NoDriverException.php`. Update existing `packages/filesystem/tests/Unit/Exceptions/NoDriverExceptionTest.php` to match the new output format. -3. Add mutual `conflict` blocks to both driver composer.json files -4. Add `packages/filesystem/tests/KnownDriversValidationTest.php` -5. Verify `marko/testing` is in `packages/filesystem/composer.json` `require-dev`; add it if missing +3. Add `packages/filesystem/tests/KnownDriversValidationTest.php` +4. Verify `marko/testing` is in `packages/filesystem/composer.json` `require-dev`; add it if missing ## Requirements (Test Descriptions) - [ ] `it ships a known-drivers.php file listing both filesystem drivers` - [ ] `it lists marko/filesystem-local first as the recommended driver` - [ ] `filesystem NoDriverException reads from known-drivers.php and includes docs URLs` -- [ ] `each filesystem driver declares conflict with the sibling driver` -- [ ] `validation test confirms conflict blocks match known-drivers list` ## Acceptance Criteria - `packages/filesystem/known-drivers.php` exists - `NoDriverException` refactored -- Both driver composer.json files have correctly-populated `conflict` blocks - Validation test passes - Existing filesystem tests still pass - Code follows code standards diff --git a/.claude/plans/known-drivers-registry/011-rollout-inertia.md b/.claude/plans/known-drivers-registry/011-rollout-inertia.md index 0b695e15..6abb8699 100644 --- a/.claude/plans/known-drivers-registry/011-rollout-inertia.md +++ b/.claude/plans/known-drivers-registry/011-rollout-inertia.md @@ -22,23 +22,18 @@ Apply the pilot pattern to `marko/inertia`. Three drivers: `inertia-react`, `ine ## Sub-steps 1. Create `packages/inertia/known-drivers.php` 2. **Create** `packages/inertia/src/Exceptions/NoDriverException.php` (file does not exist yet) using the established pattern from task 003. Add a corresponding `packages/inertia/tests/Exceptions/NoDriverExceptionTest.php`. -3. Add mutual `conflict` blocks to all three driver composer.json files (each lists the other two) -4. Add `packages/inertia/tests/KnownDriversValidationTest.php` using `KnownDriversValidator` -5. Add `marko/testing` to `packages/inertia/composer.json` `require-dev` (needed for the validation test) +3. Add `packages/inertia/tests/KnownDriversValidationTest.php` using `KnownDriversValidator` +4. Add `marko/testing` to `packages/inertia/composer.json` `require-dev` (needed for the validation test) -**Note:** Inertia drivers represent frontend framework choice — fundamentally not mixable (a single Inertia app uses one frontend). The conflict declaration is correct here. ## Requirements (Test Descriptions) - [ ] `it ships a known-drivers.php file listing all three inertia drivers` - [ ] `it lists marko/inertia-react first as the recommended driver` - [ ] `inertia NoDriverException reads from known-drivers.php and includes docs URLs` -- [ ] `each inertia driver declares conflict with both other drivers` -- [ ] `validation test confirms conflict blocks match known-drivers list` ## Acceptance Criteria - `packages/inertia/known-drivers.php` exists with three entries - `NoDriverException` exists (created if missing) and reads from known-drivers.php -- All three driver composer.json files have correctly-populated `conflict` blocks listing both siblings - Validation test passes - Existing inertia tests still pass - Code follows code standards diff --git a/.claude/plans/known-drivers-registry/012-rollout-mail.md b/.claude/plans/known-drivers-registry/012-rollout-mail.md index 78215fbf..bebb5eda 100644 --- a/.claude/plans/known-drivers-registry/012-rollout-mail.md +++ b/.claude/plans/known-drivers-registry/012-rollout-mail.md @@ -20,21 +20,17 @@ Apply the pilot pattern to `marko/mail`. Two drivers: `mail-log`, `mail-smtp`. B ## Sub-steps 1. Create `packages/mail/known-drivers.php` 2. Refactor `packages/mail/src/Exceptions/NoDriverException.php`. Update existing `packages/mail/tests/Unit/Exceptions/NoDriverExceptionTest.php` to match the new output format. -3. Add mutual `conflict` blocks to both driver composer.json files -4. Add `packages/mail/tests/KnownDriversValidationTest.php` -5. Verify `marko/testing` is in `packages/mail/composer.json` `require-dev`; add it if missing +3. Add `packages/mail/tests/KnownDriversValidationTest.php` +4. Verify `marko/testing` is in `packages/mail/composer.json` `require-dev`; add it if missing ## Requirements (Test Descriptions) - [ ] `it ships a known-drivers.php file listing both mail drivers` - [ ] `it lists marko/mail-smtp first as the recommended driver` - [ ] `mail NoDriverException reads from known-drivers.php and includes docs URLs` -- [ ] `each mail driver declares conflict with the sibling driver` -- [ ] `validation test confirms conflict blocks match known-drivers list` ## Acceptance Criteria - `packages/mail/known-drivers.php` exists - `NoDriverException` refactored -- Both driver composer.json files have correctly-populated `conflict` blocks - Validation test passes - Existing mail tests still pass - Code follows code standards diff --git a/.claude/plans/known-drivers-registry/013-rollout-media.md b/.claude/plans/known-drivers-registry/013-rollout-media.md index 4de90de1..c1ce0b09 100644 --- a/.claude/plans/known-drivers-registry/013-rollout-media.md +++ b/.claude/plans/known-drivers-registry/013-rollout-media.md @@ -20,21 +20,17 @@ Apply the pilot pattern to `marko/media`. Two drivers: `media-gd`, `media-imagic ## Sub-steps 1. Create `packages/media/known-drivers.php` 2. Refactor `packages/media/src/Exceptions/NoDriverException.php`. Update existing `packages/media/tests/Exceptions/NoDriverExceptionTest.php` to match the new output format. -3. Add mutual `conflict` blocks to both driver composer.json files -4. Add `packages/media/tests/KnownDriversValidationTest.php` -5. Verify `marko/testing` is in `packages/media/composer.json` `require-dev`; add it if missing +3. Add `packages/media/tests/KnownDriversValidationTest.php` +4. Verify `marko/testing` is in `packages/media/composer.json` `require-dev`; add it if missing ## Requirements (Test Descriptions) - [ ] `it ships a known-drivers.php file listing both media drivers` - [ ] `it lists marko/media-gd first as the recommended driver` - [ ] `media NoDriverException reads from known-drivers.php and includes docs URLs` -- [ ] `each media driver declares conflict with the sibling driver` -- [ ] `validation test confirms conflict blocks match known-drivers list` ## Acceptance Criteria - `packages/media/known-drivers.php` exists - `NoDriverException` refactored -- Both driver composer.json files have correctly-populated `conflict` blocks - Validation test passes - Existing media tests still pass - Code follows code standards diff --git a/.claude/plans/known-drivers-registry/014-rollout-pubsub.md b/.claude/plans/known-drivers-registry/014-rollout-pubsub.md index e0abb5f1..d7b7a25b 100644 --- a/.claude/plans/known-drivers-registry/014-rollout-pubsub.md +++ b/.claude/plans/known-drivers-registry/014-rollout-pubsub.md @@ -20,21 +20,17 @@ Apply the pilot pattern to `marko/pubsub`. Two drivers: `pubsub-pgsql`, `pubsub- ## Sub-steps 1. Create `packages/pubsub/known-drivers.php` 2. Refactor `packages/pubsub/src/Exceptions/NoDriverException.php`. Update existing `packages/pubsub/tests/Exceptions/NoDriverExceptionTest.php` to match the new output format. -3. Add mutual `conflict` blocks to both driver composer.json files -4. Add `packages/pubsub/tests/KnownDriversValidationTest.php` -5. Verify `marko/testing` is in `packages/pubsub/composer.json` `require-dev`; add it if missing +3. Add `packages/pubsub/tests/KnownDriversValidationTest.php` +4. Verify `marko/testing` is in `packages/pubsub/composer.json` `require-dev`; add it if missing ## Requirements (Test Descriptions) - [ ] `it ships a known-drivers.php file listing both pubsub drivers` - [ ] `it lists marko/pubsub-redis first as the recommended driver` - [ ] `pubsub NoDriverException reads from known-drivers.php and includes docs URLs` -- [ ] `each pubsub driver declares conflict with the sibling driver` -- [ ] `validation test confirms conflict blocks match known-drivers list` ## Acceptance Criteria - `packages/pubsub/known-drivers.php` exists - `NoDriverException` refactored -- Both driver composer.json files have correctly-populated `conflict` blocks - Validation test passes - Existing pubsub tests still pass - Code follows code standards diff --git a/.claude/plans/known-drivers-registry/015-rollout-queue.md b/.claude/plans/known-drivers-registry/015-rollout-queue.md index 2018fd4e..2a0cb295 100644 --- a/.claude/plans/known-drivers-registry/015-rollout-queue.md +++ b/.claude/plans/known-drivers-registry/015-rollout-queue.md @@ -21,21 +21,17 @@ Apply the pilot pattern to `marko/queue`. Three drivers: `queue-sync`, `queue-da ## Sub-steps 1. Create `packages/queue/known-drivers.php` 2. Refactor `packages/queue/src/Exceptions/NoDriverException.php`. Update existing `packages/queue/tests/NoDriverExceptionTest.php` to match the new output format. -3. Add mutual `conflict` blocks to all three driver composer.json files (each lists the other two) -4. Add `packages/queue/tests/KnownDriversValidationTest.php` -5. Verify `marko/testing` is in `packages/queue/composer.json` `require-dev`; add it if missing +3. Add `packages/queue/tests/KnownDriversValidationTest.php` +4. Verify `marko/testing` is in `packages/queue/composer.json` `require-dev`; add it if missing ## Requirements (Test Descriptions) - [ ] `it ships a known-drivers.php file listing all three queue drivers` - [ ] `it lists marko/queue-sync first as the recommended development default` - [ ] `queue NoDriverException reads from known-drivers.php and includes docs URLs` -- [ ] `each queue driver declares conflict with both other drivers` -- [ ] `validation test confirms conflict blocks match known-drivers list` ## Acceptance Criteria - `packages/queue/known-drivers.php` exists with three entries - `NoDriverException` refactored -- All three driver composer.json files have correctly-populated `conflict` blocks listing both siblings - Validation test passes - Existing queue tests still pass - Code follows code standards diff --git a/.claude/plans/known-drivers-registry/016-rollout-session.md b/.claude/plans/known-drivers-registry/016-rollout-session.md index b9bf5b06..4c09478b 100644 --- a/.claude/plans/known-drivers-registry/016-rollout-session.md +++ b/.claude/plans/known-drivers-registry/016-rollout-session.md @@ -19,21 +19,17 @@ Apply the pilot pattern to `marko/session`. Two drivers: `session-file`, `sessio ## Sub-steps 1. Create `packages/session/known-drivers.php` 2. Refactor `packages/session/src/Exceptions/NoDriverException.php`. Update existing `packages/session/tests/Unit/Exceptions/NoDriverExceptionTest.php` to match the new output format. -3. Add mutual `conflict` blocks to both driver composer.json files -4. Add `packages/session/tests/KnownDriversValidationTest.php` -5. Verify `marko/testing` is in `packages/session/composer.json` `require-dev`; add it if missing +3. Add `packages/session/tests/KnownDriversValidationTest.php` +4. Verify `marko/testing` is in `packages/session/composer.json` `require-dev`; add it if missing ## Requirements (Test Descriptions) - [ ] `it ships a known-drivers.php file listing both session drivers` - [ ] `it lists marko/session-file first as the recommended driver` - [ ] `session NoDriverException reads from known-drivers.php and includes docs URLs` -- [ ] `each session driver declares conflict with the sibling driver` -- [ ] `validation test confirms conflict blocks match known-drivers list` ## Acceptance Criteria - `packages/session/known-drivers.php` exists - `NoDriverException` refactored -- Both driver composer.json files have correctly-populated `conflict` blocks - Validation test passes - Existing session tests still pass - Code follows code standards diff --git a/.claude/plans/known-drivers-registry/017-rollout-view.md b/.claude/plans/known-drivers-registry/017-rollout-view.md index 152a5de7..ba092bb3 100644 --- a/.claude/plans/known-drivers-registry/017-rollout-view.md +++ b/.claude/plans/known-drivers-registry/017-rollout-view.md @@ -5,43 +5,47 @@ **Retry count**: 0 ## Description -Apply the single-driver variant of the pilot pattern to `marko/view`. **Only one driver exists on disk: `marko/view-latte`.** Despite earlier plan notes mentioning `marko/view-twig`, that package does not exist in this monorepo and is OUT OF SCOPE for this plan. When a second driver (twig or otherwise) lands later, the entry is added to `known-drivers.php` and the validation tests automatically gain teeth. +Apply the rollout pattern to `marko/view`. After PR #92, **both `marko/view-latte` and `marko/view-twig` exist in the monorepo** with no Composer `conflict` between them (Marko uses runtime `BindingConflictException` for double-binding detection). Both drivers belong in `known-drivers.php`. -This task refactors the existing `NoDriverException` (currently has a `DRIVER_PACKAGES` const listing only `marko/view-latte`) to read from `known-drivers.php`. +This task refactors the existing `NoDriverException` (currently has a `DRIVER_PACKAGES` const) to read from `known-drivers.php`. ## Context -- Interface: `Marko\View\ViewInterface` (verified at `packages/view/src/ViewInterface.php`) -- Driver (single, currently): `marko/view-latte` -- **Pre-existing state (verified at plan creation):** - - `view-latte/composer.json` does NOT have a `conflict` block (no sibling to conflict with yet) - - `view/src/Exceptions/NoDriverException.php` has `private const array DRIVER_PACKAGES = ['marko/view-latte']` — single entry +- Interface: `Marko\View\ViewInterface` +- Drivers: `marko/view-twig`, `marko/view-latte` (both present in monorepo after PR #92) +- Recommended-first ordering: `view-twig` (broader ecosystem familiarity — established when view-twig was introduced in PR #88) +- **Pre-existing state (verified):** + - Neither driver has a `conflict` block (PR #92 removed them) + - `view/src/Exceptions/NoDriverException.php` has `private const array DRIVER_PACKAGES = ['marko/view-latte', 'marko/view-twig']` - Existing test `packages/view/tests/Exceptions/NoDriverExceptionTest.php` asserts against the const (must be updated) -- Skeleton's composer.json does NOT yet have a `suggest` block — it will be created in task 025 +- Skeleton's composer.json — task 025 populates the `suggest` block; this task ships known-drivers.php content that task 025 must mirror verbatim **Description text for known-drivers.php:** +- `marko/view-twig` → `'Twig template engine driver (recommended for broader ecosystem familiarity)'` - `marko/view-latte` → `'Latte template engine driver (compile-time safety, n:attribute syntax)'` -This description must match what task 025 writes into skeleton's `suggest` block exactly (literal-equality CI check enforced by `KnownDriversValidator::assertSkeletonSuggestContainsAll`). Task 025 must use the same string. +These descriptions must match what task 025 writes into skeleton's `suggest` block exactly (literal-equality CI check). ## Sub-steps -1. Create `packages/view/known-drivers.php` with the single `marko/view-latte` entry -2. Refactor `packages/view/src/Exceptions/NoDriverException.php` to read from known-drivers.php (remove `DRIVER_PACKAGES` const; add `noDriverInstalled()` factory using the same shape as task 003) -3. Update `packages/view/tests/Exceptions/NoDriverExceptionTest.php` to match the new shape (remove `DRIVER_PACKAGES` assertion, add assertions for docs URL inclusion) -4. Add `packages/view/tests/KnownDriversValidationTest.php` using `KnownDriversValidator` (vacuous conflict assertion since only one driver — same as single-driver tasks 018-024) -5. Add `marko/testing` to `packages/view/composer.json` `require-dev` (needed for the validation test) +1. Create `packages/view/known-drivers.php` with both entries (Twig first) +2. Refactor `packages/view/src/Exceptions/NoDriverException.php` to read from known-drivers.php (remove `DRIVER_PACKAGES` const; add docs URL derivation as in task 003) +3. Update `packages/view/tests/Exceptions/NoDriverExceptionTest.php` to match the new shape +4. Add `packages/view/tests/KnownDriversValidationTest.php` using `KnownDriversValidator` +5. Add `marko/testing` to `packages/view/composer.json` `require-dev` if not already present ## Requirements (Test Descriptions) -- [ ] `it ships a known-drivers.php file listing marko/view-latte` -- [ ] `view NoDriverException reads from known-drivers.php and includes a docs URL` +- [ ] `it ships a known-drivers.php file listing both view drivers` +- [ ] `it lists marko/view-twig first as the recommended driver` +- [ ] `view NoDriverException reads from known-drivers.php and includes docs URLs` - [ ] `view NoDriverException no longer exposes a DRIVER_PACKAGES const` -- [ ] `validation test passes (vacuous conflict assertion with one driver)` +- [ ] `validation test confirms skeleton suggest matches known-drivers.php (after task 025 runs; skip behavior holds before)` - [ ] `existing NoDriverExceptionTest is updated to match new shape` ## Acceptance Criteria -- `packages/view/known-drivers.php` exists with one entry (`marko/view-latte`) +- `packages/view/known-drivers.php` exists with both entries, view-twig first - `DRIVER_PACKAGES` const removed from view's `NoDriverException` - Existing `NoDriverExceptionTest` updated; all assertions match new output format - New validation test passes -- All existing view, view-latte tests still pass -- `marko/testing` added as `require-dev` in `packages/view/composer.json` if not already present +- All existing view, view-latte, view-twig tests still pass +- Description text in known-drivers.php matches task 025's skeleton suggest entries verbatim +- `marko/testing` in `packages/view/composer.json` `require-dev` - Code follows code standards diff --git a/.claude/plans/known-drivers-registry/018-rollout-authentication.md b/.claude/plans/known-drivers-registry/018-rollout-authentication.md index 363839dd..b4a66dc4 100644 --- a/.claude/plans/known-drivers-registry/018-rollout-authentication.md +++ b/.claude/plans/known-drivers-registry/018-rollout-authentication.md @@ -5,12 +5,12 @@ **Retry count**: 0 ## Description -Apply the pilot pattern to `marko/authentication`. Single driver: `authentication-token`. No siblings yet; the validation test still runs but the conflict-block assertion is vacuous (no other drivers to check against). When a second driver lands, the assertion automatically gains real teeth — no code changes needed. +Apply the pilot pattern to `marko/authentication`. Single driver: `authentication-token`. No siblings yet; ## Context - Driver: `marko/authentication-token` - Recommended-first: only one driver, so listed alone -- No mutual `conflict` declaration needed yet (no siblings) + - `marko/authentication`'s `NoDriverException` already exists — just needs refactoring **Description text for known-drivers.php:** @@ -19,17 +19,17 @@ Apply the pilot pattern to `marko/authentication`. Single driver: `authenticatio ## Sub-steps 1. Create `packages/authentication/known-drivers.php` with the single entry 2. Refactor `packages/authentication/src/Exceptions/NoDriverException.php`. Update existing `packages/authentication/tests/Exceptions/NoDriverExceptionTest.php` to match the new output format. -3. Add `packages/authentication/tests/KnownDriversValidationTest.php` (conflict assertion is vacuously satisfied) +3. Add `packages/authentication/tests/KnownDriversValidationTest.php` 4. Verify `marko/testing` is in `packages/authentication/composer.json` `require-dev`; add it if missing ## Requirements (Test Descriptions) - [ ] `it ships a known-drivers.php file listing marko/authentication-token` - [ ] `authentication NoDriverException reads from known-drivers.php and includes docs URL` -- [ ] `validation test passes (vacuous conflict assertion with one driver)` +- [ ] `validation test passes` ## Acceptance Criteria - `packages/authentication/known-drivers.php` exists with the single entry - `NoDriverException` refactored -- Validation test passes (no conflict declaration needed on the lone driver) +- Validation test passes - Existing authentication tests still pass - Code follows code standards diff --git a/.claude/plans/known-drivers-registry/019-rollout-encryption.md b/.claude/plans/known-drivers-registry/019-rollout-encryption.md index 837e9d03..cdbfa0fb 100644 --- a/.claude/plans/known-drivers-registry/019-rollout-encryption.md +++ b/.claude/plans/known-drivers-registry/019-rollout-encryption.md @@ -9,7 +9,7 @@ Apply the single-driver variant of the pilot pattern to `marko/encryption`. Sing ## Context - Driver: `marko/encryption-openssl` -- No siblings; no conflict declaration needed + **Description text for known-drivers.php:** - `marko/encryption-openssl` → `'OpenSSL-based symmetric encryption driver (AES-256-GCM)'` @@ -23,7 +23,7 @@ Apply the single-driver variant of the pilot pattern to `marko/encryption`. Sing ## Requirements (Test Descriptions) - [ ] `it ships a known-drivers.php file listing marko/encryption-openssl` - [ ] `encryption NoDriverException reads from known-drivers.php and includes docs URL` -- [ ] `validation test passes (vacuous conflict assertion with one driver)` +- [ ] `validation test passes` ## Acceptance Criteria - `packages/encryption/known-drivers.php` exists with the single entry diff --git a/.claude/plans/known-drivers-registry/024-rollout-page-cache.md b/.claude/plans/known-drivers-registry/024-rollout-page-cache.md index 0e4cc699..8a5c1be8 100644 --- a/.claude/plans/known-drivers-registry/024-rollout-page-cache.md +++ b/.claude/plans/known-drivers-registry/024-rollout-page-cache.md @@ -10,7 +10,7 @@ Apply the single-driver variant to `marko/page-cache`. The page-cache family has ## Context - Driver: `marko/page-cache-file` - Add-on (NOT enrolled in known-drivers.php): `marko/page-cache-entity` -- `marko/page-cache-entity` will appear in skeleton's suggest block (added in task 025) but is NOT subject to conflict declarations or driver-list validation. +- `marko/page-cache-entity` will appear in skeleton's suggest block (added in task 025) but is NOT enrolled in known-drivers.php (add-on, not a driver). **Description text for known-drivers.php:** - `marko/page-cache-file` → `'File-based page cache driver'` @@ -19,21 +19,18 @@ Apply the single-driver variant to `marko/page-cache`. The page-cache family has 1. Create `packages/page-cache/known-drivers.php` with ONLY the file driver entry 2. Refactor `packages/page-cache/src/Exceptions/NoDriverException.php`. **Note:** unlike the other 17 NoDriverException classes (which use a `noDriverInstalled()` factory), page-cache's current factory is `noBinding()`. **Rename it to `noDriverInstalled()` for consistency** with the rest of the codebase as part of this refactor. Confirm no callers exist (per plan scope notes, the exception is currently unused — `NoDriverException` factories are not yet wired to any throw site). Update the existing test `packages/page-cache/tests/Unit/Exceptions/NoDriverExceptionTest.php` to use the new method name. 3. Add `packages/page-cache/tests/KnownDriversValidationTest.php` -4. Verify `page-cache-entity`'s composer.json has NO `conflict` block (it should coexist with the file driver) — currently verified at plan creation: no `conflict` block present. -5. Add `marko/testing` to `packages/page-cache/composer.json` `require-dev` if not already present (verify against current composer.json) +4. Add `marko/testing` to `packages/page-cache/composer.json` `require-dev` if not already present (verify against current composer.json) ## Requirements (Test Descriptions) - [ ] `it ships a known-drivers.php file listing only marko/page-cache-file` - [ ] `it does not list marko/page-cache-entity (add-on, not driver)` - [ ] `page-cache NoDriverException reads from known-drivers.php and includes docs URL` - [ ] `page-cache NoDriverException exposes a noDriverInstalled() factory (renamed from noBinding for consistency)` -- [ ] `marko/page-cache-entity does not declare a conflict with marko/page-cache-file` - [ ] `validation test passes` ## Acceptance Criteria - `packages/page-cache/known-drivers.php` exists with the single entry - `NoDriverException` refactored -- `page-cache-entity/composer.json` does NOT have a conflict against page-cache-file (verify; should already be absent) - Validation test passes - Existing page-cache tests still pass - Code follows code standards diff --git a/.claude/plans/known-drivers-registry/_plan.md b/.claude/plans/known-drivers-registry/_plan.md index f8642973..d3891b3c 100644 --- a/.claude/plans/known-drivers-registry/_plan.md +++ b/.claude/plans/known-drivers-registry/_plan.md @@ -7,7 +7,9 @@ planning ## Objective -Establish `known-drivers.php` as the single curated source of truth for each interface package's mutually-exclusive drivers. Eliminate hardcoded driver lists scattered across `NoDriverException` classes, driver `composer.json` `conflict` blocks, and skeleton's `suggest` block — mechanically enforce sync via CI tests. Roll out to every interface package with ≥1 driver. +Establish `known-drivers.php` as the single curated source of truth for each interface package's drivers. Eliminate hardcoded driver lists scattered across `NoDriverException` classes and skeleton's `suggest` block — mechanically enforce sync via CI tests. Roll out to every interface package with ≥1 driver. + +**Note on scope reduction:** an earlier version of this plan also added mutual Composer `conflict` declarations to every multi-driver family. PR #92 settled the broader design question: Marko relies on DI-level `BindingConflictException` (boot-time) for double-binding detection, NOT Composer-level conflict declarations. So this plan no longer adds conflict blocks, no longer validates them, and the pilot's standalone "add conflict blocks" task has been removed. ## Related Issues Closes #89 @@ -40,7 +42,7 @@ Closes #89 authentication (token), encryption (openssl), http (guzzle), log (file), notification (database), translation (file), page-cache (file) **Optional add-ons (NOT enrolled in known-drivers.php):** -- `marko/database-readwrite` — decorator wrapping the configured driver via boot callback; coexists with mysql/pgsql. **Not present in this monorepo's `packages/` directory** (lives in a sibling split repo). Skeleton's suggest block still references it; no on-disk validation required since add-ons don't participate in the conflict-block check. +- `marko/database-readwrite` — decorator wrapping the configured driver via boot callback; coexists with mysql/pgsql. **Not present in this monorepo's `packages/` directory** (lives in a sibling split repo). Skeleton's suggest block still references it; no on-disk validation required since add-ons don't appear in known-drivers.php. - `marko/page-cache-entity` — bridge package adding observer-based cache invalidation; coexists with page-cache-file. Present in the monorepo. **Mechanical rule:** a package is a driver iff its `module.php` `bindings` array contains the interface's defining contract. Add-ons have empty `bindings: []` (database-readwrite, page-cache-entity confirmed). For v1, `known-drivers.php` is the curated source — interface-package maintainer decides. Marker interfaces or `extra.marko.driver_for` declarations are out of scope but viable follow-ups if drift becomes a problem. @@ -49,7 +51,7 @@ authentication (token), encryption (openssl), http (guzzle), log (file), notific **marko/view test leak:** `packages/view/tests/Feature/IntegrationTest.php` uses `Marko\View\Latte\LatteEngineFactory` — a hard dependency on view-latte from inside the interface package's test suite. Cleanup task moves it to view-latte (where the integration test logically belongs). -**Test infrastructure decision:** A shared `KnownDriversValidator` class in `marko/testing` provides assertion helpers (`assertConflictBlocksMatch`, `assertSkeletonSuggestContainsAll`). Each interface package's test file is a thin wrapper passing its own `known-drivers.php` path. Skip gracefully when sibling driver packages or skeleton aren't on disk (enables marko/view-only installs to pass tests without view-latte present). +**Test infrastructure decision:** A shared `KnownDriversValidator` class in `marko/testing` provides one assertion helper: `assertSkeletonSuggestContainsAll`. Each interface package's test file is a thin wrapper passing its own `known-drivers.php` path. Skip gracefully when skeleton isn't on disk (enables marko/view-only installs to pass tests without skeleton present). ## Scope @@ -58,8 +60,7 @@ authentication (token), encryption (openssl), http (guzzle), log (file), notific **Phase A — Pilot (database):** - Create `packages/database/known-drivers.php` with pgsql-first ordering - Refactor `marko/database`'s `NoDriverException` to read from known-drivers.php; include descriptions and derived docs URLs (`https://marko.build/docs/packages/{basename}/`) -- Update `database-mysql` and `database-pgsql` `composer.json` to declare mutual `conflict` -- Add CI validation test in `marko/database` (uses shared helper) +- Add CI validation test in `marko/database` (uses shared helper) — asserts skeleton suggest parity only **Phase B — Shared infrastructure (parallel with pilot):** - Create `KnownDriversValidator` in `marko/testing` with assertion helpers @@ -68,11 +69,11 @@ authentication (token), encryption (openssl), http (guzzle), log (file), notific **Phase C — Roll out to multi-driver interfaces (10, parallel):** - cache, errors, filesystem, inertia, mail, media, pubsub, queue, session, view -- Each gets: known-drivers.php, refactored NoDriverException, mutual conflict blocks on drivers, validation test +- Each gets: known-drivers.php, refactored NoDriverException, validation test (skeleton-suggest parity) **Phase D — Roll out to single-driver interfaces (7, parallel):** - authentication, encryption, http, log, notification, translation, page-cache -- Each gets: known-drivers.php (one entry), refactored NoDriverException, validation test (vacuous conflict assertion) +- Each gets: known-drivers.php (one entry), refactored NoDriverException, validation test **Phase E — Skeleton consolidation:** - Update `marko/skeleton`'s `composer.json` `suggest` block with all drivers (recommended-first per interface) and the two confirmed add-ons (`marko/database-readwrite`, `marko/page-cache-entity`) with explanatory text @@ -88,9 +89,8 @@ authentication (token), encryption (openssl), http (guzzle), log (file), notific ## Success Criteria - [ ] Every interface package with ≥1 driver has a `known-drivers.php` file - [ ] Every interface package with a `NoDriverException` reads its driver list from `known-drivers.php` (no more hardcoded `DRIVER_PACKAGES` consts) -- [ ] Every driver in a multi-driver family declares Composer `conflict` against all its siblings - [ ] Skeleton's `suggest` block includes every entry from every `known-drivers.php` file plus optional add-ons -- [ ] CI validation tests enforce sync between `known-drivers.php`, driver `conflict` blocks, and skeleton `suggest` — fails build on drift +- [ ] CI validation tests enforce sync between `known-drivers.php` and skeleton `suggest` — fails build on drift - [ ] CI tests skip gracefully (not fail) when sibling driver packages aren't on disk - [ ] `marko/view` test suite has zero dependency on `marko/view-latte` — passes with view-latte uninstalled - [ ] `marko/errors-advanced` renders URLs in exception suggestion text as `target="_blank"` links @@ -105,14 +105,13 @@ authentication (token), encryption (openssl), http (guzzle), log (file), notific | 001 | Add `KnownDriversValidator` to marko/testing | - | pending | | 002 | Pilot: database known-drivers.php | - | pending | | 003 | Pilot: refactor database `NoDriverException` (read from file + docs URLs) | 002 | pending | -| 004 | Pilot: database-mysql/pgsql conflict blocks | 002 | pending | -| 005 | Pilot: database validation test | 001, 002, 003, 004 | pending | +| 005 | Pilot: database validation test | 001, 002, 003 | pending | | 006 | Render context/suggestion + URL linkification in errors-advanced | - | pending | | 007 | Clean up marko/view test suite (move IntegrationTest) | - | pending | | 008 | Roll out: cache | 001, 005 | pending | | 009 | Roll out: errors | 001, 005 | pending | | 010 | Roll out: filesystem | 001, 005 | pending | -| 011 | Roll out: inertia (creates new NoDriverException + 3 conflict blocks) | 001, 005 | pending | +| 011 | Roll out: inertia (creates new NoDriverException) | 001, 005 | pending | | 012 | Roll out: mail | 001, 005 | pending | | 013 | Roll out: media | 001, 005 | pending | | 014 | Roll out: pubsub | 001, 005 | pending | @@ -181,7 +180,6 @@ namespace Marko\Testing\KnownDrivers; class KnownDriversValidator { - public static function assertConflictBlocksMatch(string $knownDriversPath, string $packagesDir): void; public static function assertSkeletonSuggestContainsAll(string $knownDriversPath, string $skeletonComposerPath): void; public static function assertDocsUrlsResolveToValidPattern(string $knownDriversPath): void; } @@ -189,9 +187,9 @@ class KnownDriversValidator Each assertion: - Reads `known-drivers.php` -- Locates the referenced sibling/skeleton packages on disk via `$packagesDir` +- For `assertSkeletonSuggestContainsAll`: locates skeleton's composer.json, compares its `suggest` block to known-drivers.php entries - **Skips gracefully** (not fails) when files missing — since static methods cannot call `markTestSkipped()` directly, throw `\PHPUnit\Framework\SkippedWithMessageException` which Pest treats as a skip -- For `assertSkeletonSuggestContainsAll` specifically, also skip when skeleton.composer.json exists but has no `suggest` key yet — this is required because per-interface validation tests (005, 008-024) run BEFORE skeleton consolidation (025) in topological order +- Also skip when skeleton.composer.json exists but has no `suggest` key yet — required because per-interface validation tests (005, 008-024) run BEFORE skeleton consolidation (025) in topological order - Asserts the expected sync invariant when files are present and populated **errors-advanced URL handling:** @@ -200,13 +198,10 @@ The `PrettyHtmlFormatter::formatDevelopment` currently only renders `$report->me ## Risks & Mitigations - **Risk:** Refactoring 18 NoDriverException classes is touch-heavy; subtle bugs (typos in interface names, wrong docs URL pattern) could slip through. - **Mitigation:** The validation tests (task 005's pattern, replicated per interface in tasks 008-024) mechanically catch drift between known-drivers.php and the composer.json metadata. The docs URL derivation is centralized in one place (one helper per NoDriverException, or pulled into a shared helper). - -- **Risk:** Tests run in CI where all packages are present (monorepo), but a user installing marko/view standalone would fail validation tests if they're written naively. - **Mitigation:** Skip-gracefully behavior in `KnownDriversValidator`. Validate by running `marko/view` tests with marko/view-latte uninstalled as part of task 007 acceptance criteria. + **Mitigation:** The validation tests (task 005's pattern, replicated per interface in tasks 008-024) mechanically catch drift between known-drivers.php and skeleton suggest entries. The docs URL derivation is centralized in one place (one helper per NoDriverException). -- **Risk:** Adding `conflict` declarations to ~25+ driver packages may break existing applications that intentionally co-install incompatible drivers (unlikely but possible during development). - **Mitigation:** This is the correct fail-loud behavior per Marko principles. Any app that worked accidentally with two drivers installed would have hit `BindingConflictException` at runtime anyway — failing at install time is strictly better. Document the change in the PR description. +- **Risk:** Tests run in CI where all packages are present (monorepo), but a user installing marko/view standalone could fail validation tests if they're written naively. + **Mitigation:** Skip-gracefully behavior in `KnownDriversValidator` (skips when skeleton.composer.json is missing). Validate by running `marko/view` tests with marko/view-latte uninstalled as part of task 007 acceptance criteria. - **Risk:** `errors-advanced` URL linkification could break existing output formatting if the regex over-matches (e.g., catching text that looks URL-ish but isn't). **Mitigation:** Conservative regex pattern (require `http://` or `https://` prefix; stop at whitespace or `<`). Add regression tests for non-URL text passing through unchanged. Tests run before merge. @@ -214,8 +209,5 @@ The `PrettyHtmlFormatter::formatDevelopment` currently only renders `$report->me - **Risk:** Skeleton consolidation task (025) creates a long, hard-to-read suggest block in composer.json. **Mitigation:** Composer suggest is read at install time only, never at runtime. Length is a one-time cost during scaffolding. The recommended-first ordering with descriptions makes it navigable. -- **Risk:** Single-driver interface validation tests are vacuous (no siblings to compare conflict blocks against). The test may pass even if real misconfiguration exists. - **Mitigation:** Vacuous-but-correct is acceptable for v1. When a second driver is added to any of these interfaces, the validation test starts doing real work without code changes — that's a feature. Documented in task 018-024. - - **Risk:** The `admin/NoDriverException` excluded from this plan could confuse maintainers who see other packages refactored but admin left behind. **Mitigation:** Add a one-line code comment in `admin/NoDriverException` flagging it as vestigial pending investigation. Document in PR. From fc0ac7e3021e316f9be5fa1b47050cf563c2f08f Mon Sep 17 00:00:00 2001 From: Mark Shust Date: Wed, 27 May 2026 09:59:40 -0400 Subject: [PATCH 3/5] docs(known-drivers-registry): renumber tasks after task 004 removal; mark ready Closes the gap left by deleting the conflict-blocks pilot task in the previous commit. Tasks 005-025 each decrement by one (005 -> 004, 006 -> 005, ..., 025 -> 024). All cross-references updated: - File names - Task headers - Depends-on fields - _plan.md task table - Prose references Also updates two stale references to view-twig in Discovery Notes that predated PR #88 / #92 (both drivers are now in monorepo, view rollout is no longer a single-driver case). Status: planning -> ready (plan-orchestrate expects this). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../001-known-drivers-validator-helper.md | 2 +- .../002-pilot-database-known-drivers.md | 4 +- ...t-refactor-database-no-driver-exception.md | 2 +- ... => 004-pilot-database-validation-test.md} | 6 +- ... 005-errors-advanced-url-linkification.md} | 4 +- ...st-cleanup.md => 006-view-test-cleanup.md} | 2 +- ...-rollout-cache.md => 007-rollout-cache.md} | 6 +- ...ollout-errors.md => 008-rollout-errors.md} | 4 +- ...ilesystem.md => 009-rollout-filesystem.md} | 4 +- ...lout-inertia.md => 010-rollout-inertia.md} | 4 +- ...12-rollout-mail.md => 011-rollout-mail.md} | 4 +- ...-rollout-media.md => 012-rollout-media.md} | 4 +- ...ollout-pubsub.md => 013-rollout-pubsub.md} | 4 +- ...-rollout-queue.md => 014-rollout-queue.md} | 4 +- ...lout-session.md => 015-rollout-session.md} | 4 +- ...17-rollout-view.md => 016-rollout-view.md} | 12 ++-- ...ation.md => 017-rollout-authentication.md} | 4 +- ...ncryption.md => 018-rollout-encryption.md} | 4 +- ...20-rollout-http.md => 019-rollout-http.md} | 4 +- ...{021-rollout-log.md => 020-rollout-log.md} | 4 +- ...ication.md => 021-rollout-notification.md} | 4 +- ...nslation.md => 022-rollout-translation.md} | 4 +- ...age-cache.md => 023-rollout-page-cache.md} | 6 +- ... => 024-skeleton-suggest-consolidation.md} | 6 +- .claude/plans/known-drivers-registry/_plan.md | 64 +++++++++---------- 25 files changed, 85 insertions(+), 85 deletions(-) rename .claude/plans/known-drivers-registry/{005-pilot-database-validation-test.md => 004-pilot-database-validation-test.md} (92%) rename .claude/plans/known-drivers-registry/{006-errors-advanced-url-linkification.md => 005-errors-advanced-url-linkification.md} (97%) rename .claude/plans/known-drivers-registry/{007-view-test-cleanup.md => 006-view-test-cleanup.md} (97%) rename .claude/plans/known-drivers-registry/{008-rollout-cache.md => 007-rollout-cache.md} (93%) rename .claude/plans/known-drivers-registry/{009-rollout-errors.md => 008-rollout-errors.md} (95%) rename .claude/plans/known-drivers-registry/{010-rollout-filesystem.md => 009-rollout-filesystem.md} (94%) rename .claude/plans/known-drivers-registry/{011-rollout-inertia.md => 010-rollout-inertia.md} (96%) rename .claude/plans/known-drivers-registry/{012-rollout-mail.md => 011-rollout-mail.md} (94%) rename .claude/plans/known-drivers-registry/{013-rollout-media.md => 012-rollout-media.md} (95%) rename .claude/plans/known-drivers-registry/{014-rollout-pubsub.md => 013-rollout-pubsub.md} (95%) rename .claude/plans/known-drivers-registry/{015-rollout-queue.md => 014-rollout-queue.md} (96%) rename .claude/plans/known-drivers-registry/{016-rollout-session.md => 015-rollout-session.md} (95%) rename .claude/plans/known-drivers-registry/{017-rollout-view.md => 016-rollout-view.md} (87%) rename .claude/plans/known-drivers-registry/{018-rollout-authentication.md => 017-rollout-authentication.md} (94%) rename .claude/plans/known-drivers-registry/{019-rollout-encryption.md => 018-rollout-encryption.md} (93%) rename .claude/plans/known-drivers-registry/{020-rollout-http.md => 019-rollout-http.md} (92%) rename .claude/plans/known-drivers-registry/{021-rollout-log.md => 020-rollout-log.md} (92%) rename .claude/plans/known-drivers-registry/{022-rollout-notification.md => 021-rollout-notification.md} (93%) rename .claude/plans/known-drivers-registry/{023-rollout-translation.md => 022-rollout-translation.md} (93%) rename .claude/plans/known-drivers-registry/{024-rollout-page-cache.md => 023-rollout-page-cache.md} (93%) rename .claude/plans/known-drivers-registry/{025-skeleton-suggest-consolidation.md => 024-skeleton-suggest-consolidation.md} (97%) diff --git a/.claude/plans/known-drivers-registry/001-known-drivers-validator-helper.md b/.claude/plans/known-drivers-registry/001-known-drivers-validator-helper.md index 38f27781..6c706602 100644 --- a/.claude/plans/known-drivers-registry/001-known-drivers-validator-helper.md +++ b/.claude/plans/known-drivers-registry/001-known-drivers-validator-helper.md @@ -15,7 +15,7 @@ Create `KnownDriversValidator` in `marko/testing` providing shared assertion met The class provides two static methods (each must skip-gracefully on missing files): 1. **`assertSkeletonSuggestContainsAll(string $knownDriversPath, string $skeletonComposerPath): void`** — reads known-drivers.php, locates skeleton's composer.json, asserts every entry from known-drivers.php is present in skeleton's `suggest` block (descriptions must match verbatim). Skeleton's suggest MAY contain additional entries (add-ons, etc.). - **Skip behavior:** skip if (a) skeleton's composer.json is not on disk, OR (b) skeleton's composer.json exists but has no `suggest` key (still being built — task 025 populates it). Once skeleton has a `suggest` key, missing entries become hard failures. This three-state skip is REQUIRED so that per-interface validation tests in tasks 005, 008-024 can pass BEFORE task 025 runs. After task 025 lands, the skip falls through and real assertions fire. + **Skip behavior:** skip if (a) skeleton's composer.json is not on disk, OR (b) skeleton's composer.json exists but has no `suggest` key (still being built — task 024 populates it). Once skeleton has a `suggest` key, missing entries become hard failures. This three-state skip is REQUIRED so that per-interface validation tests in tasks 004, 007-023 can pass BEFORE task 024 runs. After task 024 lands, the skip falls through and real assertions fire. 2. **`assertDocsUrlsResolveToValidPattern(string $knownDriversPath): void`** — reads known-drivers.php and asserts every key matches the `marko/*` prefix pattern (URLs are derived from package names; entries that don't follow the pattern can't generate valid URLs). diff --git a/.claude/plans/known-drivers-registry/002-pilot-database-known-drivers.md b/.claude/plans/known-drivers-registry/002-pilot-database-known-drivers.md index 38a09ddf..22435b66 100644 --- a/.claude/plans/known-drivers-registry/002-pilot-database-known-drivers.md +++ b/.claude/plans/known-drivers-registry/002-pilot-database-known-drivers.md @@ -25,7 +25,7 @@ return [ ]; ``` -**Description-string contract:** these exact description strings (including the em-dash `—` character, NOT a hyphen) are the canonical strings. Task 025 must write them verbatim into skeleton's `suggest` block; any divergence (typo, ASCII hyphen vs em-dash) will fail the `assertSkeletonSuggestContainsAll` test. The `_plan.md` Architecture Notes section and task 025 already reference these exact strings — DO NOT alter them when implementing. +**Description-string contract:** these exact description strings (including the em-dash `—` character, NOT a hyphen) are the canonical strings. Task 024 must write them verbatim into skeleton's `suggest` block; any divergence (typo, ASCII hyphen vs em-dash) will fail the `assertSkeletonSuggestContainsAll` test. The `_plan.md` Architecture Notes section and task 024 already reference these exact strings — DO NOT alter them when implementing. ## Requirements (Test Descriptions) - [ ] `it ships a known-drivers.php file in the database package` @@ -39,5 +39,5 @@ return [ - `packages/database/known-drivers.php` exists with the specified contents - File returns an array (no nested keys, no objects) - pgsql is the first entry -- `packages/database/tests/KnownDriversTest.php` verifies all requirements (note: this is distinct from the larger `KnownDriversValidationTest.php` created in task 005 — this test verifies file shape; that test verifies cross-package sync) +- `packages/database/tests/KnownDriversTest.php` verifies all requirements (note: this is distinct from the larger `KnownDriversValidationTest.php` created in task 004 — this test verifies file shape; that test verifies cross-package sync) - Code follows code standards diff --git a/.claude/plans/known-drivers-registry/003-pilot-refactor-database-no-driver-exception.md b/.claude/plans/known-drivers-registry/003-pilot-refactor-database-no-driver-exception.md index 0a913c60..118dabd7 100644 --- a/.claude/plans/known-drivers-registry/003-pilot-refactor-database-no-driver-exception.md +++ b/.claude/plans/known-drivers-registry/003-pilot-refactor-database-no-driver-exception.md @@ -11,7 +11,7 @@ Refactor `Marko\Database\Exceptions\NoDriverException` to read its driver list f - Files to modify: - `packages/database/src/Exceptions/NoDriverException.php` (remove `DRIVER_PACKAGES` const; load known-drivers.php at exception-construction time) - `packages/database/tests/NoDriverExceptionTest.php` — has an existing test asserting `DRIVER_PACKAGES` const exists (lines 9-16 at plan creation). Remove that test or replace it with a "no longer exposes DRIVER_PACKAGES" assertion. Update the suggestion-text assertions to match the new format. -- Reference: previous implementation in `packages/view/src/Exceptions/NoDriverException.php` (which still has the hardcoded const — this task supersedes that pattern for database; view gets refactored in task 017) +- Reference: previous implementation in `packages/view/src/Exceptions/NoDriverException.php` (which still has the hardcoded const — this task supersedes that pattern for database; view gets refactored in task 016) **New suggestion text format:** ``` diff --git a/.claude/plans/known-drivers-registry/005-pilot-database-validation-test.md b/.claude/plans/known-drivers-registry/004-pilot-database-validation-test.md similarity index 92% rename from .claude/plans/known-drivers-registry/005-pilot-database-validation-test.md rename to .claude/plans/known-drivers-registry/004-pilot-database-validation-test.md index 8d655eb4..39929b44 100644 --- a/.claude/plans/known-drivers-registry/005-pilot-database-validation-test.md +++ b/.claude/plans/known-drivers-registry/004-pilot-database-validation-test.md @@ -1,11 +1,11 @@ -# Task 005: Pilot — add validation test in marko/database +# Task 004: Pilot — add validation test in marko/database **Status**: pending **Depends on**: 001, 002, 003 **Retry count**: 0 ## Description -Add a validation test in `marko/database` that uses the `KnownDriversValidator` helper (from task 001) to mechanically enforce sync between `database/known-drivers.php` and (when present) skeleton's `suggest` block. This is the canonical pattern that tasks 008-024 will replicate for every other interface package. +Add a validation test in `marko/database` that uses the `KnownDriversValidator` helper (from task 001) to mechanically enforce sync between `database/known-drivers.php` and (when present) skeleton's `suggest` block. This is the canonical pattern that tasks 007-023 will replicate for every other interface package. ## Context - New file: `packages/database/tests/KnownDriversValidationTest.php` @@ -41,7 +41,7 @@ test('every database driver follows marko slash prefix pattern', function () use ## Acceptance Criteria - Test file exists at `packages/database/tests/KnownDriversValidationTest.php` -- Test passes in the monorepo (where skeleton is present after task 025 runs; skip behavior kicks in before) +- Test passes in the monorepo (where skeleton is present after task 024 runs; skip behavior kicks in before) - Test would also pass in a standalone `marko/database` install (where skeleton may not be present) — verify by mentally walking through the skip logic - **`marko/testing` added to `packages/database/composer.json` `require-dev`** (verified at plan creation: NOT currently a dev dependency). Use `"marko/testing": "self.version"` to match monorepo conventions. - Code follows code standards diff --git a/.claude/plans/known-drivers-registry/006-errors-advanced-url-linkification.md b/.claude/plans/known-drivers-registry/005-errors-advanced-url-linkification.md similarity index 97% rename from .claude/plans/known-drivers-registry/006-errors-advanced-url-linkification.md rename to .claude/plans/known-drivers-registry/005-errors-advanced-url-linkification.md index bd24884d..816df8d2 100644 --- a/.claude/plans/known-drivers-registry/006-errors-advanced-url-linkification.md +++ b/.claude/plans/known-drivers-registry/005-errors-advanced-url-linkification.md @@ -1,4 +1,4 @@ -# Task 006: Render context/suggestion + URL linkification in marko/errors-advanced +# Task 005: Render context/suggestion + URL linkification in marko/errors-advanced **Status**: pending **Depends on**: none @@ -7,7 +7,7 @@ ## Description `marko/errors-advanced` renders exceptions as a pretty HTML page. Two gaps must be addressed: -1. **`PrettyHtmlFormatter::formatDevelopment` does NOT currently render `$report->context` or `$report->suggestion`** — only `$report->message` is rendered. This means all the carefully-crafted context and suggestion text in `MarkoException` subclasses (including the new docs URLs added by the known-drivers refactor in tasks 003 and 008-024) is silently dropped from the HTML output. Confirm by reading `packages/errors-advanced/src/PrettyHtmlFormatter.php` lines 58-89. +1. **`PrettyHtmlFormatter::formatDevelopment` does NOT currently render `$report->context` or `$report->suggestion`** — only `$report->message` is rendered. This means all the carefully-crafted context and suggestion text in `MarkoException` subclasses (including the new docs URLs added by the known-drivers refactor in tasks 003 and 007-023) is silently dropped from the HTML output. Confirm by reading `packages/errors-advanced/src/PrettyHtmlFormatter.php` lines 58-89. 2. URLs in exception text render as plain text (not clickable) because the rendering uses a generic `htmlspecialchars` escape via the private `escape()` method. After this task: `context` and `suggestion` are rendered in the HTML output (each as its own paragraph block, positioned after the message), AND URLs in message/context/suggestion are auto-detected and rendered as `` links. Applied uniformly — this is a generic rendering improvement, not NoDriverException-specific. diff --git a/.claude/plans/known-drivers-registry/007-view-test-cleanup.md b/.claude/plans/known-drivers-registry/006-view-test-cleanup.md similarity index 97% rename from .claude/plans/known-drivers-registry/007-view-test-cleanup.md rename to .claude/plans/known-drivers-registry/006-view-test-cleanup.md index 09aa451e..57607db6 100644 --- a/.claude/plans/known-drivers-registry/007-view-test-cleanup.md +++ b/.claude/plans/known-drivers-registry/006-view-test-cleanup.md @@ -1,4 +1,4 @@ -# Task 007: Clean up marko/view test suite — zero dependency on marko/view-latte +# Task 006: Clean up marko/view test suite — zero dependency on marko/view-latte **Status**: pending **Depends on**: none diff --git a/.claude/plans/known-drivers-registry/008-rollout-cache.md b/.claude/plans/known-drivers-registry/007-rollout-cache.md similarity index 93% rename from .claude/plans/known-drivers-registry/008-rollout-cache.md rename to .claude/plans/known-drivers-registry/007-rollout-cache.md index e4dc0fea..f549d03e 100644 --- a/.claude/plans/known-drivers-registry/008-rollout-cache.md +++ b/.claude/plans/known-drivers-registry/007-rollout-cache.md @@ -1,11 +1,11 @@ -# Task 008: Roll out known-drivers pattern — marko/cache +# Task 007: Roll out known-drivers pattern — marko/cache **Status**: pending -**Depends on**: 001, 005 +**Depends on**: 001, 004 **Retry count**: 0 ## Description -Apply the pilot pattern (tasks 002–005) to `marko/cache`. Three drivers: `cache-array`, `cache-file`, `cache-redis`. All bind `CacheInterface` and are mutually exclusive. +Apply the pilot pattern (tasks 002–004) to `marko/cache`. Three drivers: `cache-array`, `cache-file`, `cache-redis`. All bind `CacheInterface` and are mutually exclusive. ## Context - Interface: `Marko\Cache\CacheInterface` diff --git a/.claude/plans/known-drivers-registry/009-rollout-errors.md b/.claude/plans/known-drivers-registry/008-rollout-errors.md similarity index 95% rename from .claude/plans/known-drivers-registry/009-rollout-errors.md rename to .claude/plans/known-drivers-registry/008-rollout-errors.md index be59d60e..dc0b9e53 100644 --- a/.claude/plans/known-drivers-registry/009-rollout-errors.md +++ b/.claude/plans/known-drivers-registry/008-rollout-errors.md @@ -1,7 +1,7 @@ -# Task 009: Roll out known-drivers pattern — marko/errors +# Task 008: Roll out known-drivers pattern — marko/errors **Status**: pending -**Depends on**: 001, 005 +**Depends on**: 001, 004 **Retry count**: 0 ## Description diff --git a/.claude/plans/known-drivers-registry/010-rollout-filesystem.md b/.claude/plans/known-drivers-registry/009-rollout-filesystem.md similarity index 94% rename from .claude/plans/known-drivers-registry/010-rollout-filesystem.md rename to .claude/plans/known-drivers-registry/009-rollout-filesystem.md index 21b25346..ccbbc512 100644 --- a/.claude/plans/known-drivers-registry/010-rollout-filesystem.md +++ b/.claude/plans/known-drivers-registry/009-rollout-filesystem.md @@ -1,7 +1,7 @@ -# Task 010: Roll out known-drivers pattern — marko/filesystem +# Task 009: Roll out known-drivers pattern — marko/filesystem **Status**: pending -**Depends on**: 001, 005 +**Depends on**: 001, 004 **Retry count**: 0 ## Description diff --git a/.claude/plans/known-drivers-registry/011-rollout-inertia.md b/.claude/plans/known-drivers-registry/010-rollout-inertia.md similarity index 96% rename from .claude/plans/known-drivers-registry/011-rollout-inertia.md rename to .claude/plans/known-drivers-registry/010-rollout-inertia.md index 6abb8699..4ad1fbf8 100644 --- a/.claude/plans/known-drivers-registry/011-rollout-inertia.md +++ b/.claude/plans/known-drivers-registry/010-rollout-inertia.md @@ -1,7 +1,7 @@ -# Task 011: Roll out known-drivers pattern — marko/inertia +# Task 010: Roll out known-drivers pattern — marko/inertia **Status**: pending -**Depends on**: 001, 005 +**Depends on**: 001, 004 **Retry count**: 0 ## Description diff --git a/.claude/plans/known-drivers-registry/012-rollout-mail.md b/.claude/plans/known-drivers-registry/011-rollout-mail.md similarity index 94% rename from .claude/plans/known-drivers-registry/012-rollout-mail.md rename to .claude/plans/known-drivers-registry/011-rollout-mail.md index bebb5eda..69ab99d7 100644 --- a/.claude/plans/known-drivers-registry/012-rollout-mail.md +++ b/.claude/plans/known-drivers-registry/011-rollout-mail.md @@ -1,7 +1,7 @@ -# Task 012: Roll out known-drivers pattern — marko/mail +# Task 011: Roll out known-drivers pattern — marko/mail **Status**: pending -**Depends on**: 001, 005 +**Depends on**: 001, 004 **Retry count**: 0 ## Description diff --git a/.claude/plans/known-drivers-registry/013-rollout-media.md b/.claude/plans/known-drivers-registry/012-rollout-media.md similarity index 95% rename from .claude/plans/known-drivers-registry/013-rollout-media.md rename to .claude/plans/known-drivers-registry/012-rollout-media.md index c1ce0b09..da0077b3 100644 --- a/.claude/plans/known-drivers-registry/013-rollout-media.md +++ b/.claude/plans/known-drivers-registry/012-rollout-media.md @@ -1,7 +1,7 @@ -# Task 013: Roll out known-drivers pattern — marko/media +# Task 012: Roll out known-drivers pattern — marko/media **Status**: pending -**Depends on**: 001, 005 +**Depends on**: 001, 004 **Retry count**: 0 ## Description diff --git a/.claude/plans/known-drivers-registry/014-rollout-pubsub.md b/.claude/plans/known-drivers-registry/013-rollout-pubsub.md similarity index 95% rename from .claude/plans/known-drivers-registry/014-rollout-pubsub.md rename to .claude/plans/known-drivers-registry/013-rollout-pubsub.md index d7b7a25b..6931a4a2 100644 --- a/.claude/plans/known-drivers-registry/014-rollout-pubsub.md +++ b/.claude/plans/known-drivers-registry/013-rollout-pubsub.md @@ -1,7 +1,7 @@ -# Task 014: Roll out known-drivers pattern — marko/pubsub +# Task 013: Roll out known-drivers pattern — marko/pubsub **Status**: pending -**Depends on**: 001, 005 +**Depends on**: 001, 004 **Retry count**: 0 ## Description diff --git a/.claude/plans/known-drivers-registry/015-rollout-queue.md b/.claude/plans/known-drivers-registry/014-rollout-queue.md similarity index 96% rename from .claude/plans/known-drivers-registry/015-rollout-queue.md rename to .claude/plans/known-drivers-registry/014-rollout-queue.md index 2a0cb295..39e44527 100644 --- a/.claude/plans/known-drivers-registry/015-rollout-queue.md +++ b/.claude/plans/known-drivers-registry/014-rollout-queue.md @@ -1,7 +1,7 @@ -# Task 015: Roll out known-drivers pattern — marko/queue +# Task 014: Roll out known-drivers pattern — marko/queue **Status**: pending -**Depends on**: 001, 005 +**Depends on**: 001, 004 **Retry count**: 0 ## Description diff --git a/.claude/plans/known-drivers-registry/016-rollout-session.md b/.claude/plans/known-drivers-registry/015-rollout-session.md similarity index 95% rename from .claude/plans/known-drivers-registry/016-rollout-session.md rename to .claude/plans/known-drivers-registry/015-rollout-session.md index 4c09478b..e88eccb2 100644 --- a/.claude/plans/known-drivers-registry/016-rollout-session.md +++ b/.claude/plans/known-drivers-registry/015-rollout-session.md @@ -1,7 +1,7 @@ -# Task 016: Roll out known-drivers pattern — marko/session +# Task 015: Roll out known-drivers pattern — marko/session **Status**: pending -**Depends on**: 001, 005 +**Depends on**: 001, 004 **Retry count**: 0 ## Description diff --git a/.claude/plans/known-drivers-registry/017-rollout-view.md b/.claude/plans/known-drivers-registry/016-rollout-view.md similarity index 87% rename from .claude/plans/known-drivers-registry/017-rollout-view.md rename to .claude/plans/known-drivers-registry/016-rollout-view.md index ba092bb3..863428f1 100644 --- a/.claude/plans/known-drivers-registry/017-rollout-view.md +++ b/.claude/plans/known-drivers-registry/016-rollout-view.md @@ -1,7 +1,7 @@ -# Task 017: Roll out known-drivers pattern — marko/view +# Task 016: Roll out known-drivers pattern — marko/view **Status**: pending -**Depends on**: 001, 005, 007 +**Depends on**: 001, 004, 006 **Retry count**: 0 ## Description @@ -17,13 +17,13 @@ This task refactors the existing `NoDriverException` (currently has a `DRIVER_PA - Neither driver has a `conflict` block (PR #92 removed them) - `view/src/Exceptions/NoDriverException.php` has `private const array DRIVER_PACKAGES = ['marko/view-latte', 'marko/view-twig']` - Existing test `packages/view/tests/Exceptions/NoDriverExceptionTest.php` asserts against the const (must be updated) -- Skeleton's composer.json — task 025 populates the `suggest` block; this task ships known-drivers.php content that task 025 must mirror verbatim +- Skeleton's composer.json — task 024 populates the `suggest` block; this task ships known-drivers.php content that task 024 must mirror verbatim **Description text for known-drivers.php:** - `marko/view-twig` → `'Twig template engine driver (recommended for broader ecosystem familiarity)'` - `marko/view-latte` → `'Latte template engine driver (compile-time safety, n:attribute syntax)'` -These descriptions must match what task 025 writes into skeleton's `suggest` block exactly (literal-equality CI check). +These descriptions must match what task 024 writes into skeleton's `suggest` block exactly (literal-equality CI check). ## Sub-steps 1. Create `packages/view/known-drivers.php` with both entries (Twig first) @@ -37,7 +37,7 @@ These descriptions must match what task 025 writes into skeleton's `suggest` blo - [ ] `it lists marko/view-twig first as the recommended driver` - [ ] `view NoDriverException reads from known-drivers.php and includes docs URLs` - [ ] `view NoDriverException no longer exposes a DRIVER_PACKAGES const` -- [ ] `validation test confirms skeleton suggest matches known-drivers.php (after task 025 runs; skip behavior holds before)` +- [ ] `validation test confirms skeleton suggest matches known-drivers.php (after task 024 runs; skip behavior holds before)` - [ ] `existing NoDriverExceptionTest is updated to match new shape` ## Acceptance Criteria @@ -46,6 +46,6 @@ These descriptions must match what task 025 writes into skeleton's `suggest` blo - Existing `NoDriverExceptionTest` updated; all assertions match new output format - New validation test passes - All existing view, view-latte, view-twig tests still pass -- Description text in known-drivers.php matches task 025's skeleton suggest entries verbatim +- Description text in known-drivers.php matches task 024's skeleton suggest entries verbatim - `marko/testing` in `packages/view/composer.json` `require-dev` - Code follows code standards diff --git a/.claude/plans/known-drivers-registry/018-rollout-authentication.md b/.claude/plans/known-drivers-registry/017-rollout-authentication.md similarity index 94% rename from .claude/plans/known-drivers-registry/018-rollout-authentication.md rename to .claude/plans/known-drivers-registry/017-rollout-authentication.md index b4a66dc4..cf74a95f 100644 --- a/.claude/plans/known-drivers-registry/018-rollout-authentication.md +++ b/.claude/plans/known-drivers-registry/017-rollout-authentication.md @@ -1,7 +1,7 @@ -# Task 018: Roll out known-drivers pattern — marko/authentication (single-driver) +# Task 017: Roll out known-drivers pattern — marko/authentication (single-driver) **Status**: pending -**Depends on**: 001, 005 +**Depends on**: 001, 004 **Retry count**: 0 ## Description diff --git a/.claude/plans/known-drivers-registry/019-rollout-encryption.md b/.claude/plans/known-drivers-registry/018-rollout-encryption.md similarity index 93% rename from .claude/plans/known-drivers-registry/019-rollout-encryption.md rename to .claude/plans/known-drivers-registry/018-rollout-encryption.md index cdbfa0fb..f5072b20 100644 --- a/.claude/plans/known-drivers-registry/019-rollout-encryption.md +++ b/.claude/plans/known-drivers-registry/018-rollout-encryption.md @@ -1,7 +1,7 @@ -# Task 019: Roll out known-drivers pattern — marko/encryption (single-driver) +# Task 018: Roll out known-drivers pattern — marko/encryption (single-driver) **Status**: pending -**Depends on**: 001, 005 +**Depends on**: 001, 004 **Retry count**: 0 ## Description diff --git a/.claude/plans/known-drivers-registry/020-rollout-http.md b/.claude/plans/known-drivers-registry/019-rollout-http.md similarity index 92% rename from .claude/plans/known-drivers-registry/020-rollout-http.md rename to .claude/plans/known-drivers-registry/019-rollout-http.md index b376833f..43a4d01a 100644 --- a/.claude/plans/known-drivers-registry/020-rollout-http.md +++ b/.claude/plans/known-drivers-registry/019-rollout-http.md @@ -1,7 +1,7 @@ -# Task 020: Roll out known-drivers pattern — marko/http (single-driver) +# Task 019: Roll out known-drivers pattern — marko/http (single-driver) **Status**: pending -**Depends on**: 001, 005 +**Depends on**: 001, 004 **Retry count**: 0 ## Description diff --git a/.claude/plans/known-drivers-registry/021-rollout-log.md b/.claude/plans/known-drivers-registry/020-rollout-log.md similarity index 92% rename from .claude/plans/known-drivers-registry/021-rollout-log.md rename to .claude/plans/known-drivers-registry/020-rollout-log.md index 8b7292ae..fd2dfd37 100644 --- a/.claude/plans/known-drivers-registry/021-rollout-log.md +++ b/.claude/plans/known-drivers-registry/020-rollout-log.md @@ -1,7 +1,7 @@ -# Task 021: Roll out known-drivers pattern — marko/log (single-driver) +# Task 020: Roll out known-drivers pattern — marko/log (single-driver) **Status**: pending -**Depends on**: 001, 005 +**Depends on**: 001, 004 **Retry count**: 0 ## Description diff --git a/.claude/plans/known-drivers-registry/022-rollout-notification.md b/.claude/plans/known-drivers-registry/021-rollout-notification.md similarity index 93% rename from .claude/plans/known-drivers-registry/022-rollout-notification.md rename to .claude/plans/known-drivers-registry/021-rollout-notification.md index d50b87a4..eb508d60 100644 --- a/.claude/plans/known-drivers-registry/022-rollout-notification.md +++ b/.claude/plans/known-drivers-registry/021-rollout-notification.md @@ -1,7 +1,7 @@ -# Task 022: Roll out known-drivers pattern — marko/notification (single-driver) +# Task 021: Roll out known-drivers pattern — marko/notification (single-driver) **Status**: pending -**Depends on**: 001, 005 +**Depends on**: 001, 004 **Retry count**: 0 ## Description diff --git a/.claude/plans/known-drivers-registry/023-rollout-translation.md b/.claude/plans/known-drivers-registry/022-rollout-translation.md similarity index 93% rename from .claude/plans/known-drivers-registry/023-rollout-translation.md rename to .claude/plans/known-drivers-registry/022-rollout-translation.md index a0f7bf46..bfce455d 100644 --- a/.claude/plans/known-drivers-registry/023-rollout-translation.md +++ b/.claude/plans/known-drivers-registry/022-rollout-translation.md @@ -1,7 +1,7 @@ -# Task 023: Roll out known-drivers pattern — marko/translation (single-driver) +# Task 022: Roll out known-drivers pattern — marko/translation (single-driver) **Status**: pending -**Depends on**: 001, 005 +**Depends on**: 001, 004 **Retry count**: 0 ## Description diff --git a/.claude/plans/known-drivers-registry/024-rollout-page-cache.md b/.claude/plans/known-drivers-registry/023-rollout-page-cache.md similarity index 93% rename from .claude/plans/known-drivers-registry/024-rollout-page-cache.md rename to .claude/plans/known-drivers-registry/023-rollout-page-cache.md index 8a5c1be8..08f66d02 100644 --- a/.claude/plans/known-drivers-registry/024-rollout-page-cache.md +++ b/.claude/plans/known-drivers-registry/023-rollout-page-cache.md @@ -1,7 +1,7 @@ -# Task 024: Roll out known-drivers pattern — marko/page-cache (single-driver; entity is add-on) +# Task 023: Roll out known-drivers pattern — marko/page-cache (single-driver; entity is add-on) **Status**: pending -**Depends on**: 001, 005 +**Depends on**: 001, 004 **Retry count**: 0 ## Description @@ -10,7 +10,7 @@ Apply the single-driver variant to `marko/page-cache`. The page-cache family has ## Context - Driver: `marko/page-cache-file` - Add-on (NOT enrolled in known-drivers.php): `marko/page-cache-entity` -- `marko/page-cache-entity` will appear in skeleton's suggest block (added in task 025) but is NOT enrolled in known-drivers.php (add-on, not a driver). +- `marko/page-cache-entity` will appear in skeleton's suggest block (added in task 024) but is NOT enrolled in known-drivers.php (add-on, not a driver). **Description text for known-drivers.php:** - `marko/page-cache-file` → `'File-based page cache driver'` diff --git a/.claude/plans/known-drivers-registry/025-skeleton-suggest-consolidation.md b/.claude/plans/known-drivers-registry/024-skeleton-suggest-consolidation.md similarity index 97% rename from .claude/plans/known-drivers-registry/025-skeleton-suggest-consolidation.md rename to .claude/plans/known-drivers-registry/024-skeleton-suggest-consolidation.md index d0ca4e08..6586abc3 100644 --- a/.claude/plans/known-drivers-registry/025-skeleton-suggest-consolidation.md +++ b/.claude/plans/known-drivers-registry/024-skeleton-suggest-consolidation.md @@ -1,7 +1,7 @@ -# Task 025: Consolidate skeleton's composer suggest block +# Task 024: Consolidate skeleton's composer suggest block **Status**: pending -**Depends on**: 003, 008, 009, 010, 011, 012, 013, 014, 015, 016, 017, 018, 019, 020, 021, 022, 023, 024 +**Depends on**: 003, 007, 008, 009, 010, 011, 012, 013, 014, 015, 016, 017, 018, 019, 020, 021, 022, 023 **Retry count**: 0 ## Description @@ -11,7 +11,7 @@ Update `packages/skeleton/composer.json`'s `suggest` block to include every driv - File to modify: `packages/skeleton/composer.json` - New test file: `packages/skeleton/tests/KnownDriversSuggestParityTest.php` -**CRITICAL: description-string source of truth.** The exact description string for each driver is defined in its interface's `known-drivers.php` (created in tasks 002, 008-017, 018-024). Skeleton's `suggest` block MUST use those exact strings (literal equality, including em-dashes, parenthetical clauses, and trailing punctuation). The `assertSkeletonSuggestContainsAll` validation test in each interface's KnownDriversValidationTest will fail if any string diverges by even a single character. +**CRITICAL: description-string source of truth.** The exact description string for each driver is defined in its interface's `known-drivers.php` (created in tasks 002, 007-016, 017-023). Skeleton's `suggest` block MUST use those exact strings (literal equality, including em-dashes, parenthetical clauses, and trailing punctuation). The `assertSkeletonSuggestContainsAll` validation test in each interface's KnownDriversValidationTest will fail if any string diverges by even a single character. **Implementation procedure (do NOT copy from the sample below):** 1. For each driver entry to add to skeleton's suggest, `require` the corresponding `packages/{interface}/known-drivers.php` file diff --git a/.claude/plans/known-drivers-registry/_plan.md b/.claude/plans/known-drivers-registry/_plan.md index d3891b3c..d227bd58 100644 --- a/.claude/plans/known-drivers-registry/_plan.md +++ b/.claude/plans/known-drivers-registry/_plan.md @@ -4,7 +4,7 @@ 2026-05-27 ## Status -planning +ready ## Objective Establish `known-drivers.php` as the single curated source of truth for each interface package's drivers. Eliminate hardcoded driver lists scattered across `NoDriverException` classes and skeleton's `suggest` block — mechanically enforce sync via CI tests. Roll out to every interface package with ≥1 driver. @@ -17,11 +17,11 @@ Closes #89 ## Discovery Notes **Existing state:** -- 18 packages already define a `NoDriverException` class. 17 use a `noDriverInstalled()` static factory; **`marko/page-cache` is the lone outlier — it uses `noBinding()`** (will be standardized to `noDriverInstalled()` in task 024). Most have a hardcoded `private const array DRIVER_PACKAGES = [...]`; a few have just a single hardcoded package in the suggestion text. Pattern is established but lists drift independently. -- `marko/view`'s `NoDriverException` currently lists only `marko/view-latte` (no `marko/view-twig` package exists on disk in this monorepo). This plan formalizes the registry pattern; **the actual creation of `marko/view-twig` is OUT OF SCOPE** — it's a separate plan. For now, `view/known-drivers.php` will list only `marko/view-latte`. -- **`marko/inertia` does NOT currently have a `NoDriverException`** — it must be created as part of task 011 following the established pattern. +- 18 packages already define a `NoDriverException` class. 17 use a `noDriverInstalled()` static factory; **`marko/page-cache` is the lone outlier — it uses `noBinding()`** (will be standardized to `noDriverInstalled()` in task 023). Most have a hardcoded `private const array DRIVER_PACKAGES = [...]`; a few have just a single hardcoded package in the suggestion text. Pattern is established but lists drift independently. +- `marko/view`'s `NoDriverException` currently lists both `marko/view-latte` and `marko/view-twig` (both packages now in monorepo after PR #88 and #92). This plan formalizes the registry pattern; the view rollout (task 016) refactors that exception to read from `known-drivers.php`. +- **`marko/inertia` does NOT currently have a `NoDriverException`** — it must be created as part of task 010 following the established pattern. - The `admin` package has a `NoDriverException` but no drivers (admin-api/admin-auth/admin-panel are sub-modules of an admin system, not drivers). Excluded as a separate concern. -- **Several existing tests assert against the soon-to-be-removed `DRIVER_PACKAGES` const** (e.g., `packages/database/tests/NoDriverExceptionTest.php` line 9-16). The refactor tasks (003, 008-017) must update or remove those assertions in addition to the source-file changes. +- **Several existing tests assert against the soon-to-be-removed `DRIVER_PACKAGES` const** (e.g., `packages/database/tests/NoDriverExceptionTest.php` line 9-16). The refactor tasks (003, 007-016) must update or remove those assertions in addition to the source-file changes. **Driver classification (multi-driver, mutually-exclusive — bind same interface):** | Interface | Drivers | Recommended (listed first) | @@ -36,7 +36,7 @@ Closes #89 | pubsub | pgsql, redis | redis (purpose-built) | | queue | sync, database, rabbitmq | sync (zero-infrastructure default) | | session | file, database | file | -| view | latte (only driver currently — view-twig not yet built) | latte | +| view | twig, latte | twig (broader ecosystem familiarity) | **Single-driver interfaces (one driver currently — known-drivers.php still applies):** authentication (token), encryption (openssl), http (guzzle), log (file), notification (database), translation (file), page-cache (file) @@ -47,7 +47,7 @@ authentication (token), encryption (openssl), http (guzzle), log (file), notific **Mechanical rule:** a package is a driver iff its `module.php` `bindings` array contains the interface's defining contract. Add-ons have empty `bindings: []` (database-readwrite, page-cache-entity confirmed). For v1, `known-drivers.php` is the curated source — interface-package maintainer decides. Marker interfaces or `extra.marko.driver_for` declarations are out of scope but viable follow-ups if drift becomes a problem. -**errors-advanced URL rendering:** currently `PrettyHtmlFormatter::formatDevelopment` only renders `$report->message` through `escape()` — it does NOT currently render `$report->context` or `$report->suggestion` at all (verified in `packages/errors-advanced/src/PrettyHtmlFormatter.php` line 58-89). Task 006 in this plan does TWO things: (1) adds rendering of `context` and `suggestion` to the HTML output (so users actually see the NoDriverException's installation guidance), and (2) adds URL detection + `target="_blank" rel="noopener noreferrer"` linkification, applied uniformly to message, context, and suggestion fields. Without (1), the docs URLs we're adding to NoDriverException won't be visible in errors-advanced output at all. +**errors-advanced URL rendering:** currently `PrettyHtmlFormatter::formatDevelopment` only renders `$report->message` through `escape()` — it does NOT currently render `$report->context` or `$report->suggestion` at all (verified in `packages/errors-advanced/src/PrettyHtmlFormatter.php` line 58-89). Task 005 in this plan does TWO things: (1) adds rendering of `context` and `suggestion` to the HTML output (so users actually see the NoDriverException's installation guidance), and (2) adds URL detection + `target="_blank" rel="noopener noreferrer"` linkification, applied uniformly to message, context, and suggestion fields. Without (1), the docs URLs we're adding to NoDriverException won't be visible in errors-advanced output at all. **marko/view test leak:** `packages/view/tests/Feature/IntegrationTest.php` uses `Marko\View\Latte\LatteEngineFactory` — a hard dependency on view-latte from inside the interface package's test suite. Cleanup task moves it to view-latte (where the integration test logically belongs). @@ -105,27 +105,27 @@ authentication (token), encryption (openssl), http (guzzle), log (file), notific | 001 | Add `KnownDriversValidator` to marko/testing | - | pending | | 002 | Pilot: database known-drivers.php | - | pending | | 003 | Pilot: refactor database `NoDriverException` (read from file + docs URLs) | 002 | pending | -| 005 | Pilot: database validation test | 001, 002, 003 | pending | -| 006 | Render context/suggestion + URL linkification in errors-advanced | - | pending | -| 007 | Clean up marko/view test suite (move IntegrationTest) | - | pending | -| 008 | Roll out: cache | 001, 005 | pending | -| 009 | Roll out: errors | 001, 005 | pending | -| 010 | Roll out: filesystem | 001, 005 | pending | -| 011 | Roll out: inertia (creates new NoDriverException) | 001, 005 | pending | -| 012 | Roll out: mail | 001, 005 | pending | -| 013 | Roll out: media | 001, 005 | pending | -| 014 | Roll out: pubsub | 001, 005 | pending | -| 015 | Roll out: queue | 001, 005 | pending | -| 016 | Roll out: session | 001, 005 | pending | -| 017 | Roll out: view (single-driver: only view-latte; view-twig out of scope) | 001, 005, 007 | pending | -| 018 | Roll out: authentication (single-driver) | 001, 005 | pending | -| 019 | Roll out: encryption (single-driver) | 001, 005 | pending | -| 020 | Roll out: http (single-driver) | 001, 005 | pending | -| 021 | Roll out: log (single-driver) | 001, 005 | pending | -| 022 | Roll out: notification (single-driver) | 001, 005 | pending | -| 023 | Roll out: translation (single-driver) | 001, 005 | pending | -| 024 | Roll out: page-cache (single-driver; entity is add-on; renames noBinding to noDriverInstalled) | 001, 005 | pending | -| 025 | Skeleton consolidation (suggest block with all drivers + add-ons) | 003, 008–024 | pending | +| 004 | Pilot: database validation test | 001, 002, 003 | pending | +| 005 | Render context/suggestion + URL linkification in errors-advanced | - | pending | +| 006 | Clean up marko/view test suite (move IntegrationTest) | - | pending | +| 007 | Roll out: cache | 001, 004 | pending | +| 008 | Roll out: errors | 001, 004 | pending | +| 009 | Roll out: filesystem | 001, 004 | pending | +| 010 | Roll out: inertia (creates new NoDriverException) | 001, 004 | pending | +| 011 | Roll out: mail | 001, 004 | pending | +| 012 | Roll out: media | 001, 004 | pending | +| 013 | Roll out: pubsub | 001, 004 | pending | +| 014 | Roll out: queue | 001, 004 | pending | +| 015 | Roll out: session | 001, 004 | pending | +| 016 | Roll out: view (both view-latte and view-twig) | 001, 004, 006 | pending | +| 017 | Roll out: authentication (single-driver) | 001, 004 | pending | +| 018 | Roll out: encryption (single-driver) | 001, 004 | pending | +| 019 | Roll out: http (single-driver) | 001, 004 | pending | +| 020 | Roll out: log (single-driver) | 001, 004 | pending | +| 021 | Roll out: notification (single-driver) | 001, 004 | pending | +| 022 | Roll out: translation (single-driver) | 001, 004 | pending | +| 023 | Roll out: page-cache (single-driver; entity is add-on; renames noBinding to noDriverInstalled) | 001, 004 | pending | +| 024 | Skeleton consolidation (suggest block with all drivers + add-ons) | 003, 007–023 | pending | ## Architecture Notes @@ -189,7 +189,7 @@ Each assertion: - Reads `known-drivers.php` - For `assertSkeletonSuggestContainsAll`: locates skeleton's composer.json, compares its `suggest` block to known-drivers.php entries - **Skips gracefully** (not fails) when files missing — since static methods cannot call `markTestSkipped()` directly, throw `\PHPUnit\Framework\SkippedWithMessageException` which Pest treats as a skip -- Also skip when skeleton.composer.json exists but has no `suggest` key yet — required because per-interface validation tests (005, 008-024) run BEFORE skeleton consolidation (025) in topological order +- Also skip when skeleton.composer.json exists but has no `suggest` key yet — required because per-interface validation tests (004, 007-023) run BEFORE skeleton consolidation (024) in topological order - Asserts the expected sync invariant when files are present and populated **errors-advanced URL handling:** @@ -198,15 +198,15 @@ The `PrettyHtmlFormatter::formatDevelopment` currently only renders `$report->me ## Risks & Mitigations - **Risk:** Refactoring 18 NoDriverException classes is touch-heavy; subtle bugs (typos in interface names, wrong docs URL pattern) could slip through. - **Mitigation:** The validation tests (task 005's pattern, replicated per interface in tasks 008-024) mechanically catch drift between known-drivers.php and skeleton suggest entries. The docs URL derivation is centralized in one place (one helper per NoDriverException). + **Mitigation:** The validation tests (task 004's pattern, replicated per interface in tasks 007-023) mechanically catch drift between known-drivers.php and skeleton suggest entries. The docs URL derivation is centralized in one place (one helper per NoDriverException). - **Risk:** Tests run in CI where all packages are present (monorepo), but a user installing marko/view standalone could fail validation tests if they're written naively. - **Mitigation:** Skip-gracefully behavior in `KnownDriversValidator` (skips when skeleton.composer.json is missing). Validate by running `marko/view` tests with marko/view-latte uninstalled as part of task 007 acceptance criteria. + **Mitigation:** Skip-gracefully behavior in `KnownDriversValidator` (skips when skeleton.composer.json is missing). Validate by running `marko/view` tests with marko/view-latte uninstalled as part of task 006 acceptance criteria. - **Risk:** `errors-advanced` URL linkification could break existing output formatting if the regex over-matches (e.g., catching text that looks URL-ish but isn't). **Mitigation:** Conservative regex pattern (require `http://` or `https://` prefix; stop at whitespace or `<`). Add regression tests for non-URL text passing through unchanged. Tests run before merge. -- **Risk:** Skeleton consolidation task (025) creates a long, hard-to-read suggest block in composer.json. +- **Risk:** Skeleton consolidation task (024) creates a long, hard-to-read suggest block in composer.json. **Mitigation:** Composer suggest is read at install time only, never at runtime. Length is a one-time cost during scaffolding. The recommended-first ordering with descriptions makes it navigable. - **Risk:** The `admin/NoDriverException` excluded from this plan could confuse maintainers who see other packages refactored but admin left behind. From d7a6db5477c50348395cc2a751dd19229b82539e Mon Sep 17 00:00:00 2001 From: Mark Shust Date: Wed, 27 May 2026 11:06:09 -0400 Subject: [PATCH 4/5] feat(known-drivers-registry): establish known-drivers.php as single source of truth for driver discovery - Add KnownDriversValidator to marko/testing with assertSkeletonSuggestContainsAll and assertDocsUrlsResolveToValidPattern - Create known-drivers.php in all 18 interface packages (authentication, cache, database, encryption, errors, filesystem, http, inertia, log, mail, media, notification, page-cache, pubsub, queue, session, translation, view) - Refactor all 18 NoDriverException classes to read from known-drivers.php with descriptions and derived docs URLs - Create inertia NoDriverException (previously missing) - Rename page-cache NoDriverException factory from noBinding to noDriverInstalled for consistency - Add KnownDriversValidationTest to each interface package for CI skeleton-suggest parity enforcement - Extend errors-advanced PrettyHtmlFormatter to render context/suggestion fields and linkify URLs - Move view IntegrationTest from marko/view to marko/view-latte (zero-dependency cleanup) - Consolidate skeleton composer.json suggest block with all drivers and optional add-ons - Add cross-cutting KnownDriversSuggestParityTest to skeleton Closes #89 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../001-known-drivers-validator-helper.md | 2 +- .../002-pilot-database-known-drivers.md | 2 +- ...t-refactor-database-no-driver-exception.md | 2 +- .../004-pilot-database-validation-test.md | 2 +- .../005-errors-advanced-url-linkification.md | 2 +- .../006-view-test-cleanup.md | 2 +- .../007-rollout-cache.md | 2 +- .../008-rollout-errors.md | 2 +- .../009-rollout-filesystem.md | 2 +- .../010-rollout-inertia.md | 2 +- .../011-rollout-mail.md | 2 +- .../012-rollout-media.md | 2 +- .../013-rollout-pubsub.md | 2 +- .../014-rollout-queue.md | 2 +- .../015-rollout-session.md | 2 +- .../016-rollout-view.md | 2 +- .../017-rollout-authentication.md | 2 +- .../018-rollout-encryption.md | 2 +- .../019-rollout-http.md | 2 +- .../known-drivers-registry/020-rollout-log.md | 2 +- .../021-rollout-notification.md | 2 +- .../022-rollout-translation.md | 2 +- .../023-rollout-page-cache.md | 2 +- .../024-skeleton-suggest-consolidation.md | 2 +- .claude/plans/known-drivers-registry/_plan.md | 50 ++-- docs/src/content/docs/packages/testing.md | 26 ++ packages/authentication/known-drivers.php | 7 + .../src/Exceptions/NoDriverException.php | 33 ++- .../Exceptions/NoDriverExceptionTest.php | 23 +- .../authentication/tests/KnownDriversTest.php | 15 ++ .../tests/KnownDriversValidationTest.php | 16 ++ packages/cache/known-drivers.php | 9 + .../src/Exceptions/NoDriverException.php | 37 ++- .../Exceptions/NoDriverExceptionTest.php | 19 +- packages/cache/tests/KnownDriversTest.php | 25 ++ .../tests/KnownDriversValidationTest.php | 34 +++ packages/database/composer.json | 1 + packages/database/known-drivers.php | 8 + .../src/Exceptions/NoDriverException.php | 36 ++- packages/database/tests/KnownDriversTest.php | 48 ++++ .../tests/KnownDriversValidationTest.php | 16 ++ .../database/tests/NoDriverExceptionTest.php | 60 ++++- packages/encryption/known-drivers.php | 7 + .../src/Exceptions/NoDriverException.php | 35 ++- .../tests/KnownDriversValidationTest.php | 16 ++ .../encryption/tests/PackageStructureTest.php | 12 + .../Unit/Exceptions/NoDriverExceptionTest.php | 11 +- .../src/PrettyHtmlFormatter.php | 39 ++- .../tests/Unit/UrlLinkificationTest.php | 233 ++++++++++++++++++ packages/errors/composer.json | 1 + packages/errors/known-drivers.php | 8 + .../src/Exceptions/NoDriverException.php | 36 ++- .../tests/KnownDriversValidationTest.php | 32 +++ .../Unit/Exceptions/NoDriverExceptionTest.php | 13 +- packages/filesystem/known-drivers.php | 8 + .../src/Exceptions/NoDriverException.php | 36 ++- .../filesystem/tests/KnownDriversTest.php | 23 ++ .../tests/KnownDriversValidationTest.php | 16 ++ .../Unit/Exceptions/NoDriverExceptionTest.php | 71 +++++- packages/http/composer.json | 3 +- packages/http/known-drivers.php | 7 + .../http/src/Exceptions/NoDriverException.php | 33 ++- .../Exceptions/NoDriverExceptionTest.php | 10 +- packages/http/tests/KnownDriversTest.php | 10 + .../http/tests/KnownDriversValidationTest.php | 11 + packages/inertia/known-drivers.php | 9 + .../src/Exceptions/NoDriverException.php | 45 ++++ .../Exceptions/NoDriverExceptionTest.php | 45 ++++ packages/inertia/tests/KnownDriversTest.php | 22 ++ .../tests/KnownDriversValidationTest.php | 16 ++ packages/log/known-drivers.php | 7 + .../log/src/Exceptions/NoDriverException.php | 35 ++- .../log/tests/KnownDriversValidationTest.php | 16 ++ packages/log/tests/PackageStructureTest.php | 12 + .../Unit/Exceptions/NoDriverExceptionTest.php | 10 +- packages/mail/known-drivers.php | 8 + .../mail/src/Exceptions/NoDriverException.php | 36 ++- packages/mail/tests/KnownDriversTest.php | 23 ++ .../mail/tests/KnownDriversValidationTest.php | 16 ++ .../Unit/Exceptions/NoDriverExceptionTest.php | 79 ++++-- packages/media/known-drivers.php | 8 + .../src/Exceptions/NoDriverException.php | 36 ++- .../Exceptions/NoDriverExceptionTest.php | 14 +- packages/media/tests/KnownDriversTest.php | 19 ++ .../tests/KnownDriversValidationTest.php | 16 ++ packages/notification/composer.json | 1 + packages/notification/known-drivers.php | 7 + .../src/Exceptions/NoDriverException.php | 35 ++- .../tests/KnownDriversValidationTest.php | 16 ++ .../tests/PackageScaffoldingTest.php | 12 + .../Unit/Exceptions/NoDriverExceptionTest.php | 11 +- packages/page-cache/known-drivers.php | 7 + .../src/Exceptions/NoDriverException.php | 30 ++- .../page-cache/tests/KnownDriversTest.php | 21 ++ .../tests/KnownDriversValidationTest.php | 11 + .../Unit/Exceptions/NoDriverExceptionTest.php | 13 +- packages/pubsub/known-drivers.php | 8 + .../src/Exceptions/NoDriverException.php | 36 ++- .../Exceptions/NoDriverExceptionTest.php | 16 +- packages/pubsub/tests/KnownDriversTest.php | 22 ++ .../tests/KnownDriversValidationTest.php | 11 + packages/queue/known-drivers.php | 9 + .../src/Exceptions/NoDriverException.php | 37 ++- .../tests/KnownDriversValidationTest.php | 30 +++ .../queue/tests/NoDriverExceptionTest.php | 71 +++++- packages/session/composer.json | 4 + packages/session/known-drivers.php | 8 + .../src/Exceptions/NoDriverException.php | 36 ++- packages/session/tests/KnownDriversTest.php | 18 ++ .../tests/KnownDriversValidationTest.php | 16 ++ .../Unit/Exceptions/NoDriverExceptionTest.php | 79 +++--- packages/skeleton/composer.json | 34 ++- .../tests/KnownDriversSuggestParityTest.php | 84 +++++++ packages/testing/README.md | 21 ++ .../KnownDrivers/KnownDriversValidator.php | 88 +++++++ .../KnownDriversValidatorTest.php | 80 ++++++ .../fixtures/known-drivers-invalid-prefix.php | 8 + .../KnownDrivers/fixtures/known-drivers.php | 8 + .../skeleton-missing-driver-composer.json | 7 + .../skeleton-with-extra-suggest-composer.json | 9 + .../skeleton-with-suggest-composer.json | 8 + .../skeleton-without-suggest-composer.json | 4 + .../skeleton-wrong-description-composer.json | 8 + packages/translation/known-drivers.php | 7 + .../src/Exceptions/NoDriverException.php | 35 ++- .../Exceptions/NoDriverExceptionTest.php | 79 ++++-- .../tests/KnownDriversValidationTest.php | 11 + .../tests/PackageStructureTest.php | 12 + .../tests/Feature/IntegrationTest.php | 0 packages/view/known-drivers.php | 8 + .../view/src/Exceptions/NoDriverException.php | 34 ++- .../Exceptions/NoDriverExceptionTest.php | 105 +++++--- packages/view/tests/KnownDriversTest.php | 22 ++ .../view/tests/KnownDriversValidationTest.php | 16 ++ packages/view/tests/PackageStructureTest.php | 24 +- 135 files changed, 2535 insertions(+), 396 deletions(-) create mode 100644 packages/authentication/known-drivers.php create mode 100644 packages/authentication/tests/KnownDriversTest.php create mode 100644 packages/authentication/tests/KnownDriversValidationTest.php create mode 100644 packages/cache/known-drivers.php create mode 100644 packages/cache/tests/KnownDriversTest.php create mode 100644 packages/cache/tests/KnownDriversValidationTest.php create mode 100644 packages/database/known-drivers.php create mode 100644 packages/database/tests/KnownDriversTest.php create mode 100644 packages/database/tests/KnownDriversValidationTest.php create mode 100644 packages/encryption/known-drivers.php create mode 100644 packages/encryption/tests/KnownDriversValidationTest.php create mode 100644 packages/errors-advanced/tests/Unit/UrlLinkificationTest.php create mode 100644 packages/errors/known-drivers.php create mode 100644 packages/errors/tests/KnownDriversValidationTest.php create mode 100644 packages/filesystem/known-drivers.php create mode 100644 packages/filesystem/tests/KnownDriversTest.php create mode 100644 packages/filesystem/tests/KnownDriversValidationTest.php create mode 100644 packages/http/known-drivers.php create mode 100644 packages/http/tests/KnownDriversTest.php create mode 100644 packages/http/tests/KnownDriversValidationTest.php create mode 100644 packages/inertia/known-drivers.php create mode 100644 packages/inertia/src/Exceptions/NoDriverException.php create mode 100644 packages/inertia/tests/Exceptions/NoDriverExceptionTest.php create mode 100644 packages/inertia/tests/KnownDriversTest.php create mode 100644 packages/inertia/tests/KnownDriversValidationTest.php create mode 100644 packages/log/known-drivers.php create mode 100644 packages/log/tests/KnownDriversValidationTest.php create mode 100644 packages/mail/known-drivers.php create mode 100644 packages/mail/tests/KnownDriversTest.php create mode 100644 packages/mail/tests/KnownDriversValidationTest.php create mode 100644 packages/media/known-drivers.php create mode 100644 packages/media/tests/KnownDriversTest.php create mode 100644 packages/media/tests/KnownDriversValidationTest.php create mode 100644 packages/notification/known-drivers.php create mode 100644 packages/notification/tests/KnownDriversValidationTest.php create mode 100644 packages/page-cache/known-drivers.php create mode 100644 packages/page-cache/tests/KnownDriversTest.php create mode 100644 packages/page-cache/tests/KnownDriversValidationTest.php create mode 100644 packages/pubsub/known-drivers.php create mode 100644 packages/pubsub/tests/KnownDriversTest.php create mode 100644 packages/pubsub/tests/KnownDriversValidationTest.php create mode 100644 packages/queue/known-drivers.php create mode 100644 packages/queue/tests/KnownDriversValidationTest.php create mode 100644 packages/session/known-drivers.php create mode 100644 packages/session/tests/KnownDriversTest.php create mode 100644 packages/session/tests/KnownDriversValidationTest.php create mode 100644 packages/skeleton/tests/KnownDriversSuggestParityTest.php create mode 100644 packages/testing/src/KnownDrivers/KnownDriversValidator.php create mode 100644 packages/testing/tests/KnownDrivers/KnownDriversValidatorTest.php create mode 100644 packages/testing/tests/KnownDrivers/fixtures/known-drivers-invalid-prefix.php create mode 100644 packages/testing/tests/KnownDrivers/fixtures/known-drivers.php create mode 100644 packages/testing/tests/KnownDrivers/fixtures/skeleton-missing-driver-composer.json create mode 100644 packages/testing/tests/KnownDrivers/fixtures/skeleton-with-extra-suggest-composer.json create mode 100644 packages/testing/tests/KnownDrivers/fixtures/skeleton-with-suggest-composer.json create mode 100644 packages/testing/tests/KnownDrivers/fixtures/skeleton-without-suggest-composer.json create mode 100644 packages/testing/tests/KnownDrivers/fixtures/skeleton-wrong-description-composer.json create mode 100644 packages/translation/known-drivers.php create mode 100644 packages/translation/tests/KnownDriversValidationTest.php rename packages/{view => view-latte}/tests/Feature/IntegrationTest.php (100%) create mode 100644 packages/view/known-drivers.php create mode 100644 packages/view/tests/KnownDriversTest.php create mode 100644 packages/view/tests/KnownDriversValidationTest.php diff --git a/.claude/plans/known-drivers-registry/001-known-drivers-validator-helper.md b/.claude/plans/known-drivers-registry/001-known-drivers-validator-helper.md index 6c706602..77de462f 100644 --- a/.claude/plans/known-drivers-registry/001-known-drivers-validator-helper.md +++ b/.claude/plans/known-drivers-registry/001-known-drivers-validator-helper.md @@ -1,6 +1,6 @@ # Task 001: Add KnownDriversValidator helper to marko/testing -**Status**: pending +**Status**: completed **Depends on**: none **Retry count**: 0 diff --git a/.claude/plans/known-drivers-registry/002-pilot-database-known-drivers.md b/.claude/plans/known-drivers-registry/002-pilot-database-known-drivers.md index 22435b66..b3f451bb 100644 --- a/.claude/plans/known-drivers-registry/002-pilot-database-known-drivers.md +++ b/.claude/plans/known-drivers-registry/002-pilot-database-known-drivers.md @@ -1,6 +1,6 @@ # Task 002: Pilot — create database known-drivers.php -**Status**: pending +**Status**: completed **Depends on**: none **Retry count**: 0 diff --git a/.claude/plans/known-drivers-registry/003-pilot-refactor-database-no-driver-exception.md b/.claude/plans/known-drivers-registry/003-pilot-refactor-database-no-driver-exception.md index 118dabd7..980a151d 100644 --- a/.claude/plans/known-drivers-registry/003-pilot-refactor-database-no-driver-exception.md +++ b/.claude/plans/known-drivers-registry/003-pilot-refactor-database-no-driver-exception.md @@ -1,6 +1,6 @@ # Task 003: Pilot — refactor database NoDriverException to read known-drivers.php -**Status**: pending +**Status**: completed **Depends on**: 002 **Retry count**: 0 diff --git a/.claude/plans/known-drivers-registry/004-pilot-database-validation-test.md b/.claude/plans/known-drivers-registry/004-pilot-database-validation-test.md index 39929b44..6d05e3e5 100644 --- a/.claude/plans/known-drivers-registry/004-pilot-database-validation-test.md +++ b/.claude/plans/known-drivers-registry/004-pilot-database-validation-test.md @@ -1,6 +1,6 @@ # Task 004: Pilot — add validation test in marko/database -**Status**: pending +**Status**: completed **Depends on**: 001, 002, 003 **Retry count**: 0 diff --git a/.claude/plans/known-drivers-registry/005-errors-advanced-url-linkification.md b/.claude/plans/known-drivers-registry/005-errors-advanced-url-linkification.md index 816df8d2..1a58affe 100644 --- a/.claude/plans/known-drivers-registry/005-errors-advanced-url-linkification.md +++ b/.claude/plans/known-drivers-registry/005-errors-advanced-url-linkification.md @@ -1,6 +1,6 @@ # Task 005: Render context/suggestion + URL linkification in marko/errors-advanced -**Status**: pending +**Status**: completed **Depends on**: none **Retry count**: 0 diff --git a/.claude/plans/known-drivers-registry/006-view-test-cleanup.md b/.claude/plans/known-drivers-registry/006-view-test-cleanup.md index 57607db6..c8523932 100644 --- a/.claude/plans/known-drivers-registry/006-view-test-cleanup.md +++ b/.claude/plans/known-drivers-registry/006-view-test-cleanup.md @@ -1,6 +1,6 @@ # Task 006: Clean up marko/view test suite — zero dependency on marko/view-latte -**Status**: pending +**Status**: completed **Depends on**: none **Retry count**: 0 diff --git a/.claude/plans/known-drivers-registry/007-rollout-cache.md b/.claude/plans/known-drivers-registry/007-rollout-cache.md index f549d03e..72d65794 100644 --- a/.claude/plans/known-drivers-registry/007-rollout-cache.md +++ b/.claude/plans/known-drivers-registry/007-rollout-cache.md @@ -1,6 +1,6 @@ # Task 007: Roll out known-drivers pattern — marko/cache -**Status**: pending +**Status**: completed **Depends on**: 001, 004 **Retry count**: 0 diff --git a/.claude/plans/known-drivers-registry/008-rollout-errors.md b/.claude/plans/known-drivers-registry/008-rollout-errors.md index dc0b9e53..95328e10 100644 --- a/.claude/plans/known-drivers-registry/008-rollout-errors.md +++ b/.claude/plans/known-drivers-registry/008-rollout-errors.md @@ -1,6 +1,6 @@ # Task 008: Roll out known-drivers pattern — marko/errors -**Status**: pending +**Status**: completed **Depends on**: 001, 004 **Retry count**: 0 diff --git a/.claude/plans/known-drivers-registry/009-rollout-filesystem.md b/.claude/plans/known-drivers-registry/009-rollout-filesystem.md index ccbbc512..71d3326d 100644 --- a/.claude/plans/known-drivers-registry/009-rollout-filesystem.md +++ b/.claude/plans/known-drivers-registry/009-rollout-filesystem.md @@ -1,6 +1,6 @@ # Task 009: Roll out known-drivers pattern — marko/filesystem -**Status**: pending +**Status**: completed **Depends on**: 001, 004 **Retry count**: 0 diff --git a/.claude/plans/known-drivers-registry/010-rollout-inertia.md b/.claude/plans/known-drivers-registry/010-rollout-inertia.md index 4ad1fbf8..f858bb02 100644 --- a/.claude/plans/known-drivers-registry/010-rollout-inertia.md +++ b/.claude/plans/known-drivers-registry/010-rollout-inertia.md @@ -1,6 +1,6 @@ # Task 010: Roll out known-drivers pattern — marko/inertia -**Status**: pending +**Status**: completed **Depends on**: 001, 004 **Retry count**: 0 diff --git a/.claude/plans/known-drivers-registry/011-rollout-mail.md b/.claude/plans/known-drivers-registry/011-rollout-mail.md index 69ab99d7..c723aa08 100644 --- a/.claude/plans/known-drivers-registry/011-rollout-mail.md +++ b/.claude/plans/known-drivers-registry/011-rollout-mail.md @@ -1,6 +1,6 @@ # Task 011: Roll out known-drivers pattern — marko/mail -**Status**: pending +**Status**: completed **Depends on**: 001, 004 **Retry count**: 0 diff --git a/.claude/plans/known-drivers-registry/012-rollout-media.md b/.claude/plans/known-drivers-registry/012-rollout-media.md index da0077b3..1d836447 100644 --- a/.claude/plans/known-drivers-registry/012-rollout-media.md +++ b/.claude/plans/known-drivers-registry/012-rollout-media.md @@ -1,6 +1,6 @@ # Task 012: Roll out known-drivers pattern — marko/media -**Status**: pending +**Status**: completed **Depends on**: 001, 004 **Retry count**: 0 diff --git a/.claude/plans/known-drivers-registry/013-rollout-pubsub.md b/.claude/plans/known-drivers-registry/013-rollout-pubsub.md index 6931a4a2..58716b42 100644 --- a/.claude/plans/known-drivers-registry/013-rollout-pubsub.md +++ b/.claude/plans/known-drivers-registry/013-rollout-pubsub.md @@ -1,6 +1,6 @@ # Task 013: Roll out known-drivers pattern — marko/pubsub -**Status**: pending +**Status**: completed **Depends on**: 001, 004 **Retry count**: 0 diff --git a/.claude/plans/known-drivers-registry/014-rollout-queue.md b/.claude/plans/known-drivers-registry/014-rollout-queue.md index 39e44527..f92aa05b 100644 --- a/.claude/plans/known-drivers-registry/014-rollout-queue.md +++ b/.claude/plans/known-drivers-registry/014-rollout-queue.md @@ -1,6 +1,6 @@ # Task 014: Roll out known-drivers pattern — marko/queue -**Status**: pending +**Status**: completed **Depends on**: 001, 004 **Retry count**: 0 diff --git a/.claude/plans/known-drivers-registry/015-rollout-session.md b/.claude/plans/known-drivers-registry/015-rollout-session.md index e88eccb2..b703417a 100644 --- a/.claude/plans/known-drivers-registry/015-rollout-session.md +++ b/.claude/plans/known-drivers-registry/015-rollout-session.md @@ -1,6 +1,6 @@ # Task 015: Roll out known-drivers pattern — marko/session -**Status**: pending +**Status**: completed **Depends on**: 001, 004 **Retry count**: 0 diff --git a/.claude/plans/known-drivers-registry/016-rollout-view.md b/.claude/plans/known-drivers-registry/016-rollout-view.md index 863428f1..c944ce9d 100644 --- a/.claude/plans/known-drivers-registry/016-rollout-view.md +++ b/.claude/plans/known-drivers-registry/016-rollout-view.md @@ -1,6 +1,6 @@ # Task 016: Roll out known-drivers pattern — marko/view -**Status**: pending +**Status**: completed **Depends on**: 001, 004, 006 **Retry count**: 0 diff --git a/.claude/plans/known-drivers-registry/017-rollout-authentication.md b/.claude/plans/known-drivers-registry/017-rollout-authentication.md index cf74a95f..bbab0d6e 100644 --- a/.claude/plans/known-drivers-registry/017-rollout-authentication.md +++ b/.claude/plans/known-drivers-registry/017-rollout-authentication.md @@ -1,6 +1,6 @@ # Task 017: Roll out known-drivers pattern — marko/authentication (single-driver) -**Status**: pending +**Status**: completed **Depends on**: 001, 004 **Retry count**: 0 diff --git a/.claude/plans/known-drivers-registry/018-rollout-encryption.md b/.claude/plans/known-drivers-registry/018-rollout-encryption.md index f5072b20..258b2123 100644 --- a/.claude/plans/known-drivers-registry/018-rollout-encryption.md +++ b/.claude/plans/known-drivers-registry/018-rollout-encryption.md @@ -1,6 +1,6 @@ # Task 018: Roll out known-drivers pattern — marko/encryption (single-driver) -**Status**: pending +**Status**: completed **Depends on**: 001, 004 **Retry count**: 0 diff --git a/.claude/plans/known-drivers-registry/019-rollout-http.md b/.claude/plans/known-drivers-registry/019-rollout-http.md index 43a4d01a..c442fee0 100644 --- a/.claude/plans/known-drivers-registry/019-rollout-http.md +++ b/.claude/plans/known-drivers-registry/019-rollout-http.md @@ -1,6 +1,6 @@ # Task 019: Roll out known-drivers pattern — marko/http (single-driver) -**Status**: pending +**Status**: completed **Depends on**: 001, 004 **Retry count**: 0 diff --git a/.claude/plans/known-drivers-registry/020-rollout-log.md b/.claude/plans/known-drivers-registry/020-rollout-log.md index fd2dfd37..fdfd3419 100644 --- a/.claude/plans/known-drivers-registry/020-rollout-log.md +++ b/.claude/plans/known-drivers-registry/020-rollout-log.md @@ -1,6 +1,6 @@ # Task 020: Roll out known-drivers pattern — marko/log (single-driver) -**Status**: pending +**Status**: completed **Depends on**: 001, 004 **Retry count**: 0 diff --git a/.claude/plans/known-drivers-registry/021-rollout-notification.md b/.claude/plans/known-drivers-registry/021-rollout-notification.md index eb508d60..a7de9dd7 100644 --- a/.claude/plans/known-drivers-registry/021-rollout-notification.md +++ b/.claude/plans/known-drivers-registry/021-rollout-notification.md @@ -1,6 +1,6 @@ # Task 021: Roll out known-drivers pattern — marko/notification (single-driver) -**Status**: pending +**Status**: completed **Depends on**: 001, 004 **Retry count**: 0 diff --git a/.claude/plans/known-drivers-registry/022-rollout-translation.md b/.claude/plans/known-drivers-registry/022-rollout-translation.md index bfce455d..f350c30b 100644 --- a/.claude/plans/known-drivers-registry/022-rollout-translation.md +++ b/.claude/plans/known-drivers-registry/022-rollout-translation.md @@ -1,6 +1,6 @@ # Task 022: Roll out known-drivers pattern — marko/translation (single-driver) -**Status**: pending +**Status**: completed **Depends on**: 001, 004 **Retry count**: 0 diff --git a/.claude/plans/known-drivers-registry/023-rollout-page-cache.md b/.claude/plans/known-drivers-registry/023-rollout-page-cache.md index 08f66d02..25bc1644 100644 --- a/.claude/plans/known-drivers-registry/023-rollout-page-cache.md +++ b/.claude/plans/known-drivers-registry/023-rollout-page-cache.md @@ -1,6 +1,6 @@ # Task 023: Roll out known-drivers pattern — marko/page-cache (single-driver; entity is add-on) -**Status**: pending +**Status**: completed **Depends on**: 001, 004 **Retry count**: 0 diff --git a/.claude/plans/known-drivers-registry/024-skeleton-suggest-consolidation.md b/.claude/plans/known-drivers-registry/024-skeleton-suggest-consolidation.md index 6586abc3..d09f38a9 100644 --- a/.claude/plans/known-drivers-registry/024-skeleton-suggest-consolidation.md +++ b/.claude/plans/known-drivers-registry/024-skeleton-suggest-consolidation.md @@ -1,6 +1,6 @@ # Task 024: Consolidate skeleton's composer suggest block -**Status**: pending +**Status**: completed **Depends on**: 003, 007, 008, 009, 010, 011, 012, 013, 014, 015, 016, 017, 018, 019, 020, 021, 022, 023 **Retry count**: 0 diff --git a/.claude/plans/known-drivers-registry/_plan.md b/.claude/plans/known-drivers-registry/_plan.md index d227bd58..6567e496 100644 --- a/.claude/plans/known-drivers-registry/_plan.md +++ b/.claude/plans/known-drivers-registry/_plan.md @@ -4,7 +4,7 @@ 2026-05-27 ## Status -ready +completed ## Objective Establish `known-drivers.php` as the single curated source of truth for each interface package's drivers. Eliminate hardcoded driver lists scattered across `NoDriverException` classes and skeleton's `suggest` block — mechanically enforce sync via CI tests. Roll out to every interface package with ≥1 driver. @@ -102,30 +102,30 @@ authentication (token), encryption (openssl), http (guzzle), log (file), notific | Task | Description | Depends On | Status | |------|-------------|------------|--------| -| 001 | Add `KnownDriversValidator` to marko/testing | - | pending | -| 002 | Pilot: database known-drivers.php | - | pending | -| 003 | Pilot: refactor database `NoDriverException` (read from file + docs URLs) | 002 | pending | -| 004 | Pilot: database validation test | 001, 002, 003 | pending | -| 005 | Render context/suggestion + URL linkification in errors-advanced | - | pending | -| 006 | Clean up marko/view test suite (move IntegrationTest) | - | pending | -| 007 | Roll out: cache | 001, 004 | pending | -| 008 | Roll out: errors | 001, 004 | pending | -| 009 | Roll out: filesystem | 001, 004 | pending | -| 010 | Roll out: inertia (creates new NoDriverException) | 001, 004 | pending | -| 011 | Roll out: mail | 001, 004 | pending | -| 012 | Roll out: media | 001, 004 | pending | -| 013 | Roll out: pubsub | 001, 004 | pending | -| 014 | Roll out: queue | 001, 004 | pending | -| 015 | Roll out: session | 001, 004 | pending | -| 016 | Roll out: view (both view-latte and view-twig) | 001, 004, 006 | pending | -| 017 | Roll out: authentication (single-driver) | 001, 004 | pending | -| 018 | Roll out: encryption (single-driver) | 001, 004 | pending | -| 019 | Roll out: http (single-driver) | 001, 004 | pending | -| 020 | Roll out: log (single-driver) | 001, 004 | pending | -| 021 | Roll out: notification (single-driver) | 001, 004 | pending | -| 022 | Roll out: translation (single-driver) | 001, 004 | pending | -| 023 | Roll out: page-cache (single-driver; entity is add-on; renames noBinding to noDriverInstalled) | 001, 004 | pending | -| 024 | Skeleton consolidation (suggest block with all drivers + add-ons) | 003, 007–023 | pending | +| 001 | Add `KnownDriversValidator` to marko/testing | - | completed | +| 002 | Pilot: database known-drivers.php | - | completed | +| 003 | Pilot: refactor database `NoDriverException` (read from file + docs URLs) | 002 | completed | +| 004 | Pilot: database validation test | 001, 002, 003 | completed | +| 005 | Render context/suggestion + URL linkification in errors-advanced | - | completed | +| 006 | Clean up marko/view test suite (move IntegrationTest) | - | completed | +| 007 | Roll out: cache | 001, 004 | completed | +| 008 | Roll out: errors | 001, 004 | completed | +| 009 | Roll out: filesystem | 001, 004 | completed | +| 010 | Roll out: inertia (creates new NoDriverException) | 001, 004 | completed | +| 011 | Roll out: mail | 001, 004 | completed | +| 012 | Roll out: media | 001, 004 | completed | +| 013 | Roll out: pubsub | 001, 004 | completed | +| 014 | Roll out: queue | 001, 004 | completed | +| 015 | Roll out: session | 001, 004 | completed | +| 016 | Roll out: view (both view-latte and view-twig) | 001, 004, 006 | completed | +| 017 | Roll out: authentication (single-driver) | 001, 004 | completed | +| 018 | Roll out: encryption (single-driver) | 001, 004 | completed | +| 019 | Roll out: http (single-driver) | 001, 004 | completed | +| 020 | Roll out: log (single-driver) | 001, 004 | completed | +| 021 | Roll out: notification (single-driver) | 001, 004 | completed | +| 022 | Roll out: translation (single-driver) | 001, 004 | completed | +| 023 | Roll out: page-cache (single-driver; entity is add-on; renames noBinding to noDriverInstalled) | 001, 004 | completed | +| 024 | Skeleton consolidation (suggest block with all drivers + add-ons) | 003, 007–023 | completed | ## Architecture Notes diff --git a/docs/src/content/docs/packages/testing.md b/docs/src/content/docs/packages/testing.md index ffadc14b..3fa0ae7b 100644 --- a/docs/src/content/docs/packages/testing.md +++ b/docs/src/content/docs/packages/testing.md @@ -171,6 +171,25 @@ $provider->updateRememberToken($user, 'new-token'); expect($provider->lastRememberTokenUpdate['token'])->toBe('new-token'); ``` +### KnownDriversValidator + +`KnownDriversValidator` is a static utility for package authors to assert that a package's `known-drivers.php` file is well-formed and stays in sync with the skeleton's `suggest` block. + +```php +use Marko\Testing\KnownDrivers\KnownDriversValidator; + +// Assert every key in known-drivers.php follows the 'marko/*' prefix pattern +KnownDriversValidator::assertDocsUrlsResolveToValidPattern( + __DIR__ . '/../known-drivers.php', +); + +// Assert skeleton composer.json suggest block contains every entry from known-drivers.php +KnownDriversValidator::assertSkeletonSuggestContainsAll( + __DIR__ . '/../known-drivers.php', + __DIR__ . '/../../skeleton/composer.json', +); +``` + ## Pest Expectations Load the expectations file in your `Pest.php` to enable fluent assertions: @@ -336,6 +355,13 @@ public function assertLoggedOut(): void; public function clear(): void; ``` +### KnownDriversValidator + +```php +public static function assertSkeletonSuggestContainsAll(string $knownDriversPath, string $skeletonComposerPath): void; +public static function assertDocsUrlsResolveToValidPattern(string $knownDriversPath): void; +``` + ### AssertionFailedException ```php diff --git a/packages/authentication/known-drivers.php b/packages/authentication/known-drivers.php new file mode 100644 index 00000000..c5be81f8 --- /dev/null +++ b/packages/authentication/known-drivers.php @@ -0,0 +1,7 @@ + 'Token-based authentication driver (signed token sessions)', +]; diff --git a/packages/authentication/src/Exceptions/NoDriverException.php b/packages/authentication/src/Exceptions/NoDriverException.php index 3aa35802..a4621ca6 100644 --- a/packages/authentication/src/Exceptions/NoDriverException.php +++ b/packages/authentication/src/Exceptions/NoDriverException.php @@ -8,16 +8,10 @@ class NoDriverException extends MarkoException { - private const array DRIVER_PACKAGES = [ - 'marko/authentication-token', - ]; - public static function noDriverInstalled(): self { - $packageList = implode("\n", array_map( - fn (string $pkg) => "- `composer require $pkg`", - self::DRIVER_PACKAGES, - )); + $drivers = require __DIR__ . '/../../known-drivers.php'; + $packageList = self::formatDriverList($drivers); return new self( message: 'No authentication driver installed.', @@ -25,4 +19,27 @@ public static function noDriverInstalled(): self suggestion: "Install an authentication driver:\n$packageList", ); } + + /** + * @param array $drivers + */ + private static function formatDriverList(array $drivers): string + { + $lines = []; + foreach ($drivers as $package => $description) { + $docsUrl = self::docsUrl($package); + $lines[] = "- $package: $description"; + $lines[] = " Install: composer require $package"; + $lines[] = " Docs: $docsUrl"; + } + + return implode("\n", $lines); + } + + private static function docsUrl(string $package): string + { + $basename = substr($package, strlen('marko/')); + + return "https://marko.build/docs/packages/$basename/"; + } } diff --git a/packages/authentication/tests/Exceptions/NoDriverExceptionTest.php b/packages/authentication/tests/Exceptions/NoDriverExceptionTest.php index 04e8613f..82ae65ec 100644 --- a/packages/authentication/tests/Exceptions/NoDriverExceptionTest.php +++ b/packages/authentication/tests/Exceptions/NoDriverExceptionTest.php @@ -6,18 +6,25 @@ use Marko\Core\Exceptions\MarkoException; describe('NoDriverException', function (): void { - it('has DRIVER_PACKAGES constant listing marko/authentication-token', function (): void { - $reflection = new ReflectionClass(NoDriverException::class); - $constant = $reflection->getReflectionConstant('DRIVER_PACKAGES'); + it('authentication NoDriverException reads from known-drivers.php and includes docs URL', function (): void { + $knownDrivers = require __DIR__ . '/../../known-drivers.php'; + $exception = NoDriverException::noDriverInstalled(); - expect($constant)->not->toBeFalse() - ->and($constant->getValue())->toContain('marko/authentication-token'); + foreach ($knownDrivers as $package => $description) { + $basename = substr($package, strlen('marko/')); + expect($exception->getSuggestion()) + ->toContain($package) + ->and($exception->getSuggestion())->toContain($description) + ->and($exception->getSuggestion())->toContain("composer require $package") + ->and($exception->getSuggestion())->toContain("https://marko.build/docs/packages/$basename/"); + } }); - it('provides suggestion with composer require command', function (): void { - $exception = NoDriverException::noDriverInstalled(); + it('no longer exposes a DRIVER_PACKAGES const', function (): void { + $reflection = new ReflectionClass(NoDriverException::class); + $constant = $reflection->getReflectionConstant('DRIVER_PACKAGES'); - expect($exception->getSuggestion())->toContain('composer require marko/authentication-token'); + expect($constant)->toBeFalse(); }); it('includes context about resolving authentication interfaces', function (): void { diff --git a/packages/authentication/tests/KnownDriversTest.php b/packages/authentication/tests/KnownDriversTest.php new file mode 100644 index 00000000..db2d4e7c --- /dev/null +++ b/packages/authentication/tests/KnownDriversTest.php @@ -0,0 +1,15 @@ +toBeTrue(); + + $drivers = require $path; + + expect(array_key_exists('marko/authentication-token', $drivers))->toBeTrue(); + }); +}); diff --git a/packages/authentication/tests/KnownDriversValidationTest.php b/packages/authentication/tests/KnownDriversValidationTest.php new file mode 100644 index 00000000..605b631c --- /dev/null +++ b/packages/authentication/tests/KnownDriversValidationTest.php @@ -0,0 +1,16 @@ + 'File-based cache driver (recommended for single-server apps)', + 'marko/cache-redis' => 'Redis cache driver (recommended for distributed deployments and high-throughput apps)', + 'marko/cache-array' => 'In-memory cache driver (request-lifetime only; intended for testing and dev environments)', +]; diff --git a/packages/cache/src/Exceptions/NoDriverException.php b/packages/cache/src/Exceptions/NoDriverException.php index 1c198736..f672cb46 100644 --- a/packages/cache/src/Exceptions/NoDriverException.php +++ b/packages/cache/src/Exceptions/NoDriverException.php @@ -8,23 +8,38 @@ class NoDriverException extends MarkoException { - private const array DRIVER_PACKAGES = [ - 'marko/cache-array', - 'marko/cache-file', - 'marko/cache-redis', - ]; - public static function noDriverInstalled(): self { - $packageList = implode("\n", array_map( - fn (string $pkg) => "- `composer require $pkg`", - self::DRIVER_PACKAGES, - )); + $drivers = require __DIR__ . '/../../known-drivers.php'; + $packageList = self::formatDriverList($drivers); return new self( message: 'No cache driver installed.', context: 'Attempted to resolve a cache interface but no implementation is bound.', - suggestion: "Install a cache driver:\n$packageList", + suggestion: "Install one of these drivers:\n$packageList", ); } + + /** + * @param array $drivers + */ + private static function formatDriverList(array $drivers): string + { + $lines = []; + foreach ($drivers as $package => $description) { + $docsUrl = self::docsUrl($package); + $lines[] = "- $package: $description"; + $lines[] = " Install: composer require $package"; + $lines[] = " Docs: $docsUrl"; + } + + return implode("\n", $lines); + } + + private static function docsUrl(string $package): string + { + $basename = substr($package, strlen('marko/')); + + return "https://marko.build/docs/packages/$basename/"; + } } diff --git a/packages/cache/tests/Exceptions/NoDriverExceptionTest.php b/packages/cache/tests/Exceptions/NoDriverExceptionTest.php index 1a942e87..5120e126 100644 --- a/packages/cache/tests/Exceptions/NoDriverExceptionTest.php +++ b/packages/cache/tests/Exceptions/NoDriverExceptionTest.php @@ -6,14 +6,17 @@ use Marko\Core\Exceptions\MarkoException; describe('NoDriverException', function (): void { - it('has DRIVER_PACKAGES constant listing marko/cache-array, marko/cache-file, and marko/cache-redis', function (): void { - $reflection = new ReflectionClass(NoDriverException::class); - $constant = $reflection->getReflectionConstant('DRIVER_PACKAGES'); - - expect($constant)->not->toBeFalse() - ->and($constant->getValue())->toContain('marko/cache-array') - ->and($constant->getValue())->toContain('marko/cache-file') - ->and($constant->getValue())->toContain('marko/cache-redis'); + it('cache NoDriverException reads from known-drivers.php and includes docs URLs', function (): void { + $exception = NoDriverException::noDriverInstalled(); + $suggestion = $exception->getSuggestion(); + + expect($suggestion) + ->toContain('marko/cache-file') + ->and($suggestion)->toContain('marko/cache-redis') + ->and($suggestion)->toContain('marko/cache-array') + ->and($suggestion)->toContain('https://marko.build/docs/packages/cache-file/') + ->and($suggestion)->toContain('https://marko.build/docs/packages/cache-redis/') + ->and($suggestion)->toContain('https://marko.build/docs/packages/cache-array/'); }); it('provides suggestion with composer require commands for all driver packages', function (): void { diff --git a/packages/cache/tests/KnownDriversTest.php b/packages/cache/tests/KnownDriversTest.php new file mode 100644 index 00000000..b90007f9 --- /dev/null +++ b/packages/cache/tests/KnownDriversTest.php @@ -0,0 +1,25 @@ +toBeTrue(); + + $drivers = require $knownDriversPath; + + expect($drivers)->toBeArray() + ->and($drivers)->toHaveKey('marko/cache-file') + ->and($drivers)->toHaveKey('marko/cache-redis') + ->and($drivers)->toHaveKey('marko/cache-array'); +}); + +it('lists marko/cache-file first as the recommended driver', function (): void { + $knownDriversPath = dirname(__DIR__) . '/known-drivers.php'; + $drivers = require $knownDriversPath; + + $keys = array_keys($drivers); + + expect($keys[0])->toBe('marko/cache-file'); +}); diff --git a/packages/cache/tests/KnownDriversValidationTest.php b/packages/cache/tests/KnownDriversValidationTest.php new file mode 100644 index 00000000..178d3bcb --- /dev/null +++ b/packages/cache/tests/KnownDriversValidationTest.php @@ -0,0 +1,34 @@ +toBeFalse(); + + $skipped = false; + + try { + KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $nonExistentPath); + } catch (SkippedWithMessageException $e) { + $skipped = true; + expect($e->getMessage())->toContain('not found'); + } + + expect($skipped)->toBeTrue(); +}); diff --git a/packages/database/composer.json b/packages/database/composer.json index 6388c427..1a7a4549 100644 --- a/packages/database/composer.json +++ b/packages/database/composer.json @@ -8,6 +8,7 @@ "marko/core": "self.version" }, "require-dev": { + "marko/testing": "self.version", "pestphp/pest": "^4.0" }, "autoload": { diff --git a/packages/database/known-drivers.php b/packages/database/known-drivers.php new file mode 100644 index 00000000..87fbf22e --- /dev/null +++ b/packages/database/known-drivers.php @@ -0,0 +1,8 @@ + 'PostgreSQL driver (recommended for new projects — strong JSON, FTS, pgvector support)', + 'marko/database-mysql' => 'MySQL/MariaDB driver', +]; diff --git a/packages/database/src/Exceptions/NoDriverException.php b/packages/database/src/Exceptions/NoDriverException.php index ddccd381..b83d37d2 100644 --- a/packages/database/src/Exceptions/NoDriverException.php +++ b/packages/database/src/Exceptions/NoDriverException.php @@ -8,22 +8,38 @@ class NoDriverException extends MarkoException { - private const array DRIVER_PACKAGES = [ - 'marko/database-mysql', - 'marko/database-pgsql', - ]; - public static function noDriverInstalled(): self { - $packageList = implode("\n", array_map( - fn (string $pkg) => "- `composer require $pkg`", - self::DRIVER_PACKAGES, - )); + $drivers = require __DIR__ . '/../../known-drivers.php'; + $packageList = self::formatDriverList($drivers); return new self( message: 'No database driver installed.', context: 'Attempted to resolve a database interface but no implementation is bound.', - suggestion: "Install a database driver:\n$packageList", + suggestion: "Install one of these drivers:\n$packageList", ); } + + /** + * @param array $drivers + */ + private static function formatDriverList(array $drivers): string + { + $lines = []; + foreach ($drivers as $package => $description) { + $docsUrl = self::docsUrl($package); + $lines[] = "- $package: $description"; + $lines[] = " Install: composer require $package"; + $lines[] = " Docs: $docsUrl"; + } + + return implode("\n", $lines); + } + + private static function docsUrl(string $package): string + { + $basename = substr($package, strlen('marko/')); + + return "https://marko.build/docs/packages/$basename/"; + } } diff --git a/packages/database/tests/KnownDriversTest.php b/packages/database/tests/KnownDriversTest.php new file mode 100644 index 00000000..e468e128 --- /dev/null +++ b/packages/database/tests/KnownDriversTest.php @@ -0,0 +1,48 @@ +toBeTrue(); + }); + + it('lists marko/database-pgsql as the first entry (recommended default)', function (): void { + $drivers = require __DIR__ . '/../known-drivers.php'; + $keys = array_keys($drivers); + + expect($keys[0])->toBe('marko/database-pgsql'); + }); + + it('lists marko/database-mysql as the second entry', function (): void { + $drivers = (static fn (): array => require __DIR__ . '/../known-drivers.php')(); + $keys = array_keys($drivers); + + expect($keys[1])->toBe('marko/database-mysql'); + }); + + it('does not list marko/database-readwrite (add-on, not a driver)', function (): void { + $drivers = (static fn (): array => require __DIR__ . '/../known-drivers.php')(); + + expect(array_key_exists('marko/database-readwrite', $drivers))->toBeFalse(); + }); + + it('returns a flat package-to-description associative array', function (): void { + $drivers = (static fn (): array => require __DIR__ . '/../known-drivers.php')(); + + expect(is_array($drivers))->toBeTrue(); + + foreach ($drivers as $key => $value) { + expect(is_string($key))->toBeTrue() + ->and(is_string($value))->toBeTrue(); + } + }); + + it('uses declare strict_types', function (): void { + $contents = file_get_contents(__DIR__ . '/../known-drivers.php'); + + expect(str_contains($contents, 'declare(strict_types=1)'))->toBeTrue(); + }); +}); diff --git a/packages/database/tests/KnownDriversValidationTest.php b/packages/database/tests/KnownDriversValidationTest.php new file mode 100644 index 00000000..e05620aa --- /dev/null +++ b/packages/database/tests/KnownDriversValidationTest.php @@ -0,0 +1,16 @@ +getSuggestion())->toContain($package); + } + }); + + it('includes the description for each driver in the suggestion', function (): void { + $knownDrivers = require __DIR__ . '/../known-drivers.php'; + $exception = NoDriverException::noDriverInstalled(); + + foreach ($knownDrivers as $package => $description) { + expect($exception->getSuggestion())->toContain($description); + } + }); + + it('includes a composer require command for each driver', function (): void { + $knownDrivers = require __DIR__ . '/../known-drivers.php'; + $exception = NoDriverException::noDriverInstalled(); + + foreach (array_keys($knownDrivers) as $package) { + expect($exception->getSuggestion())->toContain("composer require $package"); + } + }); + + it('includes a derived docs URL for each driver', function (): void { + $knownDrivers = require __DIR__ . '/../known-drivers.php'; + $exception = NoDriverException::noDriverInstalled(); + + foreach (array_keys($knownDrivers) as $package) { + $basename = substr($package, strlen('marko/')); + expect($exception->getSuggestion())->toContain("https://marko.build/docs/packages/$basename/"); + } + }); + + it('derives docs URLs from the package basename (marko slash prefix stripped)', function (): void { + $exception = NoDriverException::noDriverInstalled(); + + expect($exception->getSuggestion())->toContain('https://marko.build/docs/packages/database-pgsql/') + ->and($exception->getSuggestion())->toContain('https://marko.build/docs/packages/database-mysql/'); + }); + + it('lists pgsql first in the suggestion (matching known-drivers.php order)', function (): void { + $exception = NoDriverException::noDriverInstalled(); + $suggestion = $exception->getSuggestion(); + + $pgsqlPos = strpos($suggestion, 'marko/database-pgsql'); + $mysqlPos = strpos($suggestion, 'marko/database-mysql'); + + expect($pgsqlPos)->toBeLessThan($mysqlPos); + }); + + it('no longer exposes a DRIVER_PACKAGES const', function (): void { $reflection = new ReflectionClass(NoDriverException::class); $constant = $reflection->getReflectionConstant('DRIVER_PACKAGES'); - expect($constant)->not->toBeFalse() - ->and($constant->getValue())->toContain('marko/database-mysql') - ->and($constant->getValue())->toContain('marko/database-pgsql'); + expect($constant)->toBeFalse(); }); it('provides suggestion with composer require commands for all driver packages', function (): void { diff --git a/packages/encryption/known-drivers.php b/packages/encryption/known-drivers.php new file mode 100644 index 00000000..6406adac --- /dev/null +++ b/packages/encryption/known-drivers.php @@ -0,0 +1,7 @@ + 'OpenSSL-based symmetric encryption driver (AES-256-GCM)', +]; diff --git a/packages/encryption/src/Exceptions/NoDriverException.php b/packages/encryption/src/Exceptions/NoDriverException.php index 742c3687..e3ccbefa 100644 --- a/packages/encryption/src/Exceptions/NoDriverException.php +++ b/packages/encryption/src/Exceptions/NoDriverException.php @@ -8,21 +8,38 @@ class NoDriverException extends MarkoException { - private const array DRIVER_PACKAGES = [ - 'marko/encryption-openssl', - ]; - public static function noDriverInstalled(): self { - $packageList = implode("\n", array_map( - fn (string $pkg) => "- `composer require $pkg`", - self::DRIVER_PACKAGES, - )); + $drivers = require __DIR__ . '/../../known-drivers.php'; + $packageList = self::formatDriverList($drivers); return new self( message: 'No encryption driver installed.', context: 'Attempted to resolve an encryption interface but no implementation is bound.', - suggestion: "Install an encryption driver:\n$packageList", + suggestion: "Install one of these drivers:\n$packageList", ); } + + /** + * @param array $drivers + */ + private static function formatDriverList(array $drivers): string + { + $lines = []; + foreach ($drivers as $package => $description) { + $docsUrl = self::docsUrl($package); + $lines[] = "- $package: $description"; + $lines[] = " Install: composer require $package"; + $lines[] = " Docs: $docsUrl"; + } + + return implode("\n", $lines); + } + + private static function docsUrl(string $package): string + { + $basename = substr($package, strlen('marko/')); + + return "https://marko.build/docs/packages/$basename/"; + } } diff --git a/packages/encryption/tests/KnownDriversValidationTest.php b/packages/encryption/tests/KnownDriversValidationTest.php new file mode 100644 index 00000000..012b01cc --- /dev/null +++ b/packages/encryption/tests/KnownDriversValidationTest.php @@ -0,0 +1,16 @@ +and($config)->toHaveKey('bindings') ->and($config['bindings'])->toBeArray(); }); + +it('ships a known-drivers.php file listing marko/encryption-openssl', function () { + $knownDriversPath = dirname(__DIR__) . '/known-drivers.php'; + + expect(file_exists($knownDriversPath))->toBeTrue(); + + $drivers = require $knownDriversPath; + + expect($drivers)->toBeArray() + ->and($drivers)->toHaveKey('marko/encryption-openssl') + ->and($drivers['marko/encryption-openssl'])->toBe('OpenSSL-based symmetric encryption driver (AES-256-GCM)'); +}); diff --git a/packages/encryption/tests/Unit/Exceptions/NoDriverExceptionTest.php b/packages/encryption/tests/Unit/Exceptions/NoDriverExceptionTest.php index c55d2174..ffbf0881 100644 --- a/packages/encryption/tests/Unit/Exceptions/NoDriverExceptionTest.php +++ b/packages/encryption/tests/Unit/Exceptions/NoDriverExceptionTest.php @@ -6,12 +6,13 @@ use Marko\Encryption\Exceptions\NoDriverException; describe('NoDriverException', function (): void { - it('has DRIVER_PACKAGES constant listing marko/encryption-openssl', function (): void { - $reflection = new ReflectionClass(NoDriverException::class); - $constant = $reflection->getReflectionConstant('DRIVER_PACKAGES'); + it('encryption NoDriverException reads from known-drivers.php and includes docs URL', function (): void { + $exception = NoDriverException::noDriverInstalled(); - expect($constant)->not->toBeFalse() - ->and($constant->getValue())->toContain('marko/encryption-openssl'); + expect($exception->getSuggestion()) + ->toContain('marko/encryption-openssl') + ->and($exception->getSuggestion())->toContain('composer require marko/encryption-openssl') + ->and($exception->getSuggestion())->toContain('https://marko.build/docs/packages/encryption-openssl/'); }); it('provides suggestion with composer require command', function (): void { diff --git a/packages/errors-advanced/src/PrettyHtmlFormatter.php b/packages/errors-advanced/src/PrettyHtmlFormatter.php index 9f68b836..2d3c25a9 100644 --- a/packages/errors-advanced/src/PrettyHtmlFormatter.php +++ b/packages/errors-advanced/src/PrettyHtmlFormatter.php @@ -58,7 +58,7 @@ private function formatProduction(): string private function formatDevelopment( ErrorReport $report, ): string { - $message = $this->escape($report->message); + $message = $this->escapeAndLinkifyUrls($report->message); $file = $this->escape($report->file); $line = $report->line; $codeSnippet = $this->formatCodeSnippet($report->file, $report->line); @@ -66,6 +66,12 @@ private function formatDevelopment( $stackTrace = $this->formatStackTrace($report->trace); $requestData = $this->formatRequestData(); $previousException = $this->formatPreviousException($report->previous); + $contextBlock = $report->context !== '' + ? '

' . $this->escapeAndLinkifyUrls($report->context) . '

' + : ''; + $suggestionBlock = $report->suggestion !== '' + ? '

' . $this->escapeAndLinkifyUrls($report->suggestion) . '

' + : ''; return << @@ -78,6 +84,8 @@ private function formatDevelopment(

$message

+$contextBlock +$suggestionBlock

$file:$line

$codeSnippet
$stackTrace
@@ -96,12 +104,16 @@ private function getEmbeddedCss(): string return << $part) { + if ($i % 2 === 0) { + $output .= htmlspecialchars($part, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } else { + $trailing = ''; + while (strlen($part) > 0 && in_array($part[-1], ['.', ',', ';', ':', '!', '?', ')', ']'], true)) { + $trailing = $part[-1] . $trailing; + $part = substr($part, 0, -1); + } + $escapedUrl = htmlspecialchars($part, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + $output .= "
$escapedUrl"; + $output .= htmlspecialchars($trailing, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + } + + return $output; + } + /** * @param array> $trace */ diff --git a/packages/errors-advanced/tests/Unit/UrlLinkificationTest.php b/packages/errors-advanced/tests/Unit/UrlLinkificationTest.php new file mode 100644 index 00000000..3cfd4fac --- /dev/null +++ b/packages/errors-advanced/tests/Unit/UrlLinkificationTest.php @@ -0,0 +1,233 @@ + 'GET', + 'uri' => '/', + 'headers' => [], + 'query' => [], + 'post' => [], + 'server' => ['php_version' => '8.5.0', 'software' => '', 'name' => ''], + ]; + + return new class ($data) extends RequestDataCollector + { + public function __construct( + private readonly array $testData, + ) {} + + public function collect(): array + { + return $this->testData; + } + }; +} + +function createMarkoExceptionWith( + string $message = 'Test error', + string $context = '', + string $suggestion = '', +): MarkoException { + return new class ($message, $context, $suggestion) extends MarkoException + { + public function __construct( + string $message, + private readonly string $contextText, + private readonly string $suggestionText, + ) { + parent::__construct($message); + } + + public function getContext(): string + { + return $this->contextText; + } + + public function getSuggestion(): string + { + return $this->suggestionText; + } + }; +} + +function createFormatterWithReport( + string $message = 'Test error', + string $context = '', + string $suggestion = '', +): array { + $exception = createMarkoExceptionWith($message, $context, $suggestion); + $report = ErrorReport::fromThrowable($exception, Severity::Error); + $formatter = new PrettyHtmlFormatter( + requestCollector: createMinimalRequestCollector(), + environment: 'development', + ); + + return [$formatter, $report]; +} + +describe('URL Linkification - Context and Suggestion Rendering', function (): void { + it('renders the context field in the HTML output when non-empty', function (): void { + [$formatter, $report] = createFormatterWithReport( + context: 'This is helpful context about the error.', + ); + + $output = $formatter->format($report); + + expect($output)->toContain('This is helpful context about the error.') + ->and($output)->toContain('

'); + }); + + it('renders the suggestion field in the HTML output when non-empty', function (): void { + [$formatter, $report] = createFormatterWithReport( + suggestion: 'Try running composer install to fix this.', + ); + + $output = $formatter->format($report); + + expect($output)->toContain('Try running composer install to fix this.') + ->and($output)->toContain('

'); + }); + + it('omits empty context and suggestion blocks (does not render empty paragraphs)', function (): void { + [$formatter, $report] = createFormatterWithReport( + context: '', + suggestion: '', + ); + + $output = $formatter->format($report); + + expect($output)->not->toContain('

') + ->and($output)->not->toContain('

'); + }); + + it('preserves newlines in the suggestion text via white-space pre-wrap', function (): void { + [$formatter, $report] = createFormatterWithReport( + suggestion: "Line one\nLine two\nLine three", + ); + + $output = $formatter->format($report); + + expect($output)->toContain('

') + ->and($output)->toMatch('/\.suggestion\s*\{[^}]*white-space:\s*pre-wrap/'); + }); +}); + +describe('URL Linkification - URL Detection and Rendering', function (): void { + it('renders http URLs as anchor tags with target blank and noopener noreferrer', function (): void { + [$formatter, $report] = createFormatterWithReport( + message: 'See http://example.com for details', + ); + + $output = $formatter->format($report); + + expect($output)->toContain('and($output)->toContain('target="_blank"') + ->and($output)->toContain('rel="noopener noreferrer"'); + }); + + it('renders https URLs as anchor tags', function (): void { + [$formatter, $report] = createFormatterWithReport( + message: 'See https://example.com/docs for details', + ); + + $output = $formatter->format($report); + + expect($output)->toContain('and($output)->toContain('target="_blank"') + ->and($output)->toContain('rel="noopener noreferrer"'); + }); + + it('htmlspecialchars-escapes non-URL text portions', function (): void { + [$formatter, $report] = createFormatterWithReport( + message: 'Error: ', + ); + + $output = $formatter->format($report); + + expect($output)->toContain('<script>') + ->and($output)->not->toContain(''); + }); + + it('preserves URLs inside mixed text correctly', function (): void { + [$formatter, $report] = createFormatterWithReport( + message: 'Visit https://docs.example.com/guide and read the docs.', + ); + + $output = $formatter->format($report); + + expect($output)->toContain('and($output)->toContain('Visit ') + ->and($output)->toContain(' and read the docs.'); + }); + + it('does not linkify text that looks URL-ish but lacks a protocol (e.g., www.example.com without http)', function (): void { + [$formatter, $report] = createFormatterWithReport( + message: 'Visit www.example.com for help', + ); + + $output = $formatter->format($report); + + expect($output)->not->toContain('and($output)->toContain('and($output)->toContain('and($output)->not->toContain('href="https://example.com/docs."') + ->and($output)->not->toContain('href="https://example.com/help,"') + ->and($output)->not->toContain('href="https://example.com/faq!"'); + }); + + it('escapes HTML special characters within the URL itself (defense against malformed input)', function (): void { + [$formatter, $report] = createFormatterWithReport( + message: 'See https://example.com/search?q=a&b=c for results', + ); + + $output = $formatter->format($report); + + expect($output)->toContain('https://example.com/search?q=a&b=c') + ->and($output)->not->toContain('href="https://example.com/search?q=a&b=c"'); + }); + + it('does not double-escape when the input has no URLs', function (): void { + [$formatter, $report] = createFormatterWithReport( + message: 'The value \'foo\' & "bar" are invalid', + ); + + $output = $formatter->format($report); + + expect($output)->toContain('& "bar"') + ->and($output)->not->toContain('&amp;') + ->and($output)->not->toContain('&quot;'); + }); + + it('linkifies URLs that appear in the suggestion field (NoDriverException docs URLs)', function (): void { + [$formatter, $report] = createFormatterWithReport( + suggestion: "Install the driver.\n\nSee https://marko.dev/docs/drivers for the full list.", + ); + + $output = $formatter->format($report); + + expect($output)->toContain('

') + ->and($output)->toContain('and($output)->toContain('target="_blank"') + ->and($output)->toContain('rel="noopener noreferrer"'); + }); +}); diff --git a/packages/errors/composer.json b/packages/errors/composer.json index f6dc6e05..6cbda2f2 100644 --- a/packages/errors/composer.json +++ b/packages/errors/composer.json @@ -8,6 +8,7 @@ "marko/core": "self.version" }, "require-dev": { + "marko/testing": "self.version", "pestphp/pest": "^4.0" }, "autoload": { diff --git a/packages/errors/known-drivers.php b/packages/errors/known-drivers.php new file mode 100644 index 00000000..dfd904ee --- /dev/null +++ b/packages/errors/known-drivers.php @@ -0,0 +1,8 @@ + 'Simple error handler (recommended for production — minimal information disclosure)', + 'marko/errors-advanced' => 'Advanced error handler with pretty stack traces and suggestions (recommended for development)', +]; diff --git a/packages/errors/src/Exceptions/NoDriverException.php b/packages/errors/src/Exceptions/NoDriverException.php index 949edfa6..b4978d39 100644 --- a/packages/errors/src/Exceptions/NoDriverException.php +++ b/packages/errors/src/Exceptions/NoDriverException.php @@ -8,22 +8,38 @@ class NoDriverException extends MarkoException { - private const array DRIVER_PACKAGES = [ - 'marko/errors-advanced', - 'marko/errors-simple', - ]; - public static function noDriverInstalled(): self { - $packageList = implode("\n", array_map( - fn (string $pkg) => "- `composer require $pkg`", - self::DRIVER_PACKAGES, - )); + $drivers = require __DIR__ . '/../../known-drivers.php'; + $packageList = self::formatDriverList($drivers); return new self( message: 'No error handler driver installed.', context: 'Attempted to resolve an error handler interface but no implementation is bound.', - suggestion: "Install an error handler driver:\n$packageList", + suggestion: "Install one of these drivers:\n$packageList", ); } + + /** + * @param array $drivers + */ + private static function formatDriverList(array $drivers): string + { + $lines = []; + foreach ($drivers as $package => $description) { + $docsUrl = self::docsUrl($package); + $lines[] = "- $package: $description"; + $lines[] = " Install: composer require $package"; + $lines[] = " Docs: $docsUrl"; + } + + return implode("\n", $lines); + } + + private static function docsUrl(string $package): string + { + $basename = substr($package, strlen('marko/')); + + return "https://marko.build/docs/packages/$basename/"; + } } diff --git a/packages/errors/tests/KnownDriversValidationTest.php b/packages/errors/tests/KnownDriversValidationTest.php new file mode 100644 index 00000000..f4582321 --- /dev/null +++ b/packages/errors/tests/KnownDriversValidationTest.php @@ -0,0 +1,32 @@ +toBeTrue(); + + $drivers = require $knownDriversPath; + + expect($drivers)->toBeArray() + ->and(array_key_exists('marko/errors-simple', $drivers))->toBeTrue() + ->and(array_key_exists('marko/errors-advanced', $drivers))->toBeTrue(); +}); + +test('it lists marko/errors-simple first as the recommended driver', function () use ($knownDriversPath): void { + $drivers = require $knownDriversPath; + + expect(array_key_first($drivers))->toBe('marko/errors-simple'); +}); + +test('skeleton suggest block contains all errors drivers', function () use ($knownDriversPath, $skeletonComposerPath): void { + KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath); +}); + +test('every errors driver follows marko slash prefix pattern', function () use ($knownDriversPath): void { + KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath); +}); diff --git a/packages/errors/tests/Unit/Exceptions/NoDriverExceptionTest.php b/packages/errors/tests/Unit/Exceptions/NoDriverExceptionTest.php index 10a25f81..692f88ef 100644 --- a/packages/errors/tests/Unit/Exceptions/NoDriverExceptionTest.php +++ b/packages/errors/tests/Unit/Exceptions/NoDriverExceptionTest.php @@ -6,13 +6,14 @@ use Marko\Errors\Exceptions\NoDriverException; describe('NoDriverException', function (): void { - it('has DRIVER_PACKAGES constant listing marko/errors-advanced and marko/errors-simple', function (): void { - $reflection = new ReflectionClass(NoDriverException::class); - $constant = $reflection->getReflectionConstant('DRIVER_PACKAGES'); + it('errors NoDriverException reads from known-drivers.php and includes docs URLs', function (): void { + $exception = NoDriverException::noDriverInstalled(); - expect($constant)->not->toBeFalse() - ->and($constant->getValue())->toContain('marko/errors-advanced') - ->and($constant->getValue())->toContain('marko/errors-simple'); + expect($exception->getSuggestion()) + ->toContain('marko/errors-simple') + ->and($exception->getSuggestion())->toContain('marko/errors-advanced') + ->and($exception->getSuggestion())->toContain('https://marko.build/docs/packages/errors-simple/') + ->and($exception->getSuggestion())->toContain('https://marko.build/docs/packages/errors-advanced/'); }); it('provides suggestion with composer require commands for all driver packages', function (): void { diff --git a/packages/filesystem/known-drivers.php b/packages/filesystem/known-drivers.php new file mode 100644 index 00000000..ca92c61a --- /dev/null +++ b/packages/filesystem/known-drivers.php @@ -0,0 +1,8 @@ + 'Local disk filesystem driver (recommended default; zero infrastructure required)', + 'marko/filesystem-s3' => 'Amazon S3 filesystem driver (for cloud and distributed deployments)', +]; diff --git a/packages/filesystem/src/Exceptions/NoDriverException.php b/packages/filesystem/src/Exceptions/NoDriverException.php index 8269aefb..dd6db208 100644 --- a/packages/filesystem/src/Exceptions/NoDriverException.php +++ b/packages/filesystem/src/Exceptions/NoDriverException.php @@ -8,22 +8,38 @@ class NoDriverException extends MarkoException { - private const array DRIVER_PACKAGES = [ - 'marko/filesystem-local', - 'marko/filesystem-s3', - ]; - public static function noDriverInstalled(): self { - $packageList = implode("\n", array_map( - fn (string $pkg) => "- `composer require $pkg`", - self::DRIVER_PACKAGES, - )); + $drivers = require __DIR__ . '/../../known-drivers.php'; + $packageList = self::formatDriverList($drivers); return new self( message: 'No filesystem driver installed.', context: 'Attempted to resolve a filesystem interface but no implementation is bound.', - suggestion: "Install a filesystem driver:\n$packageList", + suggestion: "Install one of these drivers:\n$packageList", ); } + + /** + * @param array $drivers + */ + private static function formatDriverList(array $drivers): string + { + $lines = []; + foreach ($drivers as $package => $description) { + $docsUrl = self::docsUrl($package); + $lines[] = "- $package: $description"; + $lines[] = " Install: composer require $package"; + $lines[] = " Docs: $docsUrl"; + } + + return implode("\n", $lines); + } + + private static function docsUrl(string $package): string + { + $basename = substr($package, strlen('marko/')); + + return "https://marko.build/docs/packages/$basename/"; + } } diff --git a/packages/filesystem/tests/KnownDriversTest.php b/packages/filesystem/tests/KnownDriversTest.php new file mode 100644 index 00000000..622a9bc0 --- /dev/null +++ b/packages/filesystem/tests/KnownDriversTest.php @@ -0,0 +1,23 @@ +toBeTrue(); + + $drivers = require $path; + + expect(array_key_exists('marko/filesystem-local', $drivers))->toBeTrue() + ->and(array_key_exists('marko/filesystem-s3', $drivers))->toBeTrue(); + }); + + it('lists marko/filesystem-local first as the recommended driver', function (): void { + $drivers = (static fn (): array => require __DIR__ . '/../known-drivers.php')(); + $keys = array_keys($drivers); + + expect($keys[0])->toBe('marko/filesystem-local'); + }); +}); diff --git a/packages/filesystem/tests/KnownDriversValidationTest.php b/packages/filesystem/tests/KnownDriversValidationTest.php new file mode 100644 index 00000000..efd5961e --- /dev/null +++ b/packages/filesystem/tests/KnownDriversValidationTest.php @@ -0,0 +1,16 @@ +getSuggestion())->toContain($package) + ->and($exception->getSuggestion())->toContain("https://marko.build/docs/packages/$basename/"); + } + }); + + it('loads the driver list from known-drivers.php', function (): void { + $knownDrivers = require __DIR__ . '/../../../known-drivers.php'; + $exception = NoDriverException::noDriverInstalled(); + + foreach (array_keys($knownDrivers) as $package) { + expect($exception->getSuggestion())->toContain($package); + } + }); + + it('includes the description for each driver in the suggestion', function (): void { + $knownDrivers = require __DIR__ . '/../../../known-drivers.php'; + $exception = NoDriverException::noDriverInstalled(); + + foreach ($knownDrivers as $package => $description) { + expect($exception->getSuggestion())->toContain($description); + } + }); + + it('includes a composer require command for each driver', function (): void { + $knownDrivers = require __DIR__ . '/../../../known-drivers.php'; + $exception = NoDriverException::noDriverInstalled(); + + foreach (array_keys($knownDrivers) as $package) { + expect($exception->getSuggestion())->toContain("composer require $package"); + } + }); + + it('includes a derived docs URL for each driver', function (): void { + $knownDrivers = require __DIR__ . '/../../../known-drivers.php'; + $exception = NoDriverException::noDriverInstalled(); + + foreach (array_keys($knownDrivers) as $package) { + $basename = substr($package, strlen('marko/')); + expect($exception->getSuggestion())->toContain("https://marko.build/docs/packages/$basename/"); + } + }); + + it('derives docs URLs from the package basename (marko slash prefix stripped)', function (): void { + $exception = NoDriverException::noDriverInstalled(); + + expect($exception->getSuggestion())->toContain('https://marko.build/docs/packages/filesystem-local/') + ->and($exception->getSuggestion())->toContain('https://marko.build/docs/packages/filesystem-s3/'); + }); + + it('lists filesystem-local first in the suggestion (matching known-drivers.php order)', function (): void { + $exception = NoDriverException::noDriverInstalled(); + $suggestion = $exception->getSuggestion(); + + $localPos = strpos($suggestion, 'marko/filesystem-local'); + $s3Pos = strpos($suggestion, 'marko/filesystem-s3'); + + expect($localPos)->toBeLessThan($s3Pos); + }); + + it('no longer exposes a DRIVER_PACKAGES const', function (): void { $reflection = new ReflectionClass(NoDriverException::class); $constant = $reflection->getReflectionConstant('DRIVER_PACKAGES'); - expect($constant)->not->toBeFalse() - ->and($constant->getValue())->toContain('marko/filesystem-local') - ->and($constant->getValue())->toContain('marko/filesystem-s3'); + expect($constant)->toBeFalse(); }); it('provides suggestion with composer require commands for all driver packages', function (): void { diff --git a/packages/http/composer.json b/packages/http/composer.json index ffe9fe9f..3305841f 100644 --- a/packages/http/composer.json +++ b/packages/http/composer.json @@ -8,7 +8,8 @@ "marko/core": "self.version" }, "require-dev": { - "pestphp/pest": "^4.0" + "pestphp/pest": "^4.0", + "marko/testing": "self.version" }, "autoload": { "psr-4": { diff --git a/packages/http/known-drivers.php b/packages/http/known-drivers.php new file mode 100644 index 00000000..f218b2f4 --- /dev/null +++ b/packages/http/known-drivers.php @@ -0,0 +1,7 @@ + 'Guzzle-based HTTP client driver', +]; diff --git a/packages/http/src/Exceptions/NoDriverException.php b/packages/http/src/Exceptions/NoDriverException.php index 8fe4a598..10bcfc5f 100644 --- a/packages/http/src/Exceptions/NoDriverException.php +++ b/packages/http/src/Exceptions/NoDriverException.php @@ -8,16 +8,10 @@ class NoDriverException extends MarkoException { - private const array DRIVER_PACKAGES = [ - 'marko/http-guzzle', - ]; - public static function noDriverInstalled(): self { - $packageList = implode("\n", array_map( - fn (string $pkg) => "- `composer require $pkg`", - self::DRIVER_PACKAGES, - )); + $drivers = require __DIR__ . '/../../known-drivers.php'; + $packageList = self::formatDriverList($drivers); return new self( message: 'No HTTP client driver installed.', @@ -25,4 +19,27 @@ public static function noDriverInstalled(): self suggestion: "Install an HTTP client driver:\n$packageList", ); } + + /** + * @param array $drivers + */ + private static function formatDriverList(array $drivers): string + { + $lines = []; + foreach ($drivers as $package => $description) { + $docsUrl = self::docsUrl($package); + $lines[] = "- $package: $description"; + $lines[] = " Install: composer require $package"; + $lines[] = " Docs: $docsUrl"; + } + + return implode("\n", $lines); + } + + private static function docsUrl(string $package): string + { + $basename = substr($package, strlen('marko/')); + + return "https://marko.build/docs/packages/$basename/"; + } } diff --git a/packages/http/tests/Exceptions/NoDriverExceptionTest.php b/packages/http/tests/Exceptions/NoDriverExceptionTest.php index 95a978a0..2e32cbf1 100644 --- a/packages/http/tests/Exceptions/NoDriverExceptionTest.php +++ b/packages/http/tests/Exceptions/NoDriverExceptionTest.php @@ -6,12 +6,12 @@ use Marko\Http\Exceptions\NoDriverException; describe('NoDriverException', function (): void { - it('has DRIVER_PACKAGES constant listing marko/http-guzzle', function (): void { - $reflection = new ReflectionClass(NoDriverException::class); - $constant = $reflection->getReflectionConstant('DRIVER_PACKAGES'); + it('http NoDriverException reads from known-drivers.php and includes docs URL', function (): void { + $exception = NoDriverException::noDriverInstalled(); - expect($constant)->not->toBeFalse() - ->and($constant->getValue())->toContain('marko/http-guzzle'); + expect($exception->getSuggestion()) + ->toContain('marko/http-guzzle') + ->toContain('https://marko.build/docs/packages/http-guzzle/'); }); it('provides suggestion with composer require command', function (): void { diff --git a/packages/http/tests/KnownDriversTest.php b/packages/http/tests/KnownDriversTest.php new file mode 100644 index 00000000..913c07c5 --- /dev/null +++ b/packages/http/tests/KnownDriversTest.php @@ -0,0 +1,10 @@ +toBeTrue() + ->and(require $knownDriversPath)->toHaveKey('marko/http-guzzle'); +}); diff --git a/packages/http/tests/KnownDriversValidationTest.php b/packages/http/tests/KnownDriversValidationTest.php new file mode 100644 index 00000000..f71e5785 --- /dev/null +++ b/packages/http/tests/KnownDriversValidationTest.php @@ -0,0 +1,11 @@ + KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath)); +test('every http driver follows marko slash prefix pattern', fn () => KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath)); diff --git a/packages/inertia/known-drivers.php b/packages/inertia/known-drivers.php new file mode 100644 index 00000000..f48c2c18 --- /dev/null +++ b/packages/inertia/known-drivers.php @@ -0,0 +1,9 @@ + 'React frontend driver for Inertia.js (recommended — largest community and tooling ecosystem)', + 'marko/inertia-svelte' => 'Svelte frontend driver for Inertia.js', + 'marko/inertia-vue' => 'Vue frontend driver for Inertia.js', +]; diff --git a/packages/inertia/src/Exceptions/NoDriverException.php b/packages/inertia/src/Exceptions/NoDriverException.php new file mode 100644 index 00000000..5b1925a7 --- /dev/null +++ b/packages/inertia/src/Exceptions/NoDriverException.php @@ -0,0 +1,45 @@ + $drivers + */ + private static function formatDriverList(array $drivers): string + { + $lines = []; + foreach ($drivers as $package => $description) { + $docsUrl = self::docsUrl($package); + $lines[] = "- $package: $description"; + $lines[] = " Install: composer require $package"; + $lines[] = " Docs: $docsUrl"; + } + + return implode("\n", $lines); + } + + private static function docsUrl(string $package): string + { + $basename = substr($package, strlen('marko/')); + + return "https://marko.build/docs/packages/$basename/"; + } +} diff --git a/packages/inertia/tests/Exceptions/NoDriverExceptionTest.php b/packages/inertia/tests/Exceptions/NoDriverExceptionTest.php new file mode 100644 index 00000000..fef412fe --- /dev/null +++ b/packages/inertia/tests/Exceptions/NoDriverExceptionTest.php @@ -0,0 +1,45 @@ +getSuggestion()) + ->toContain($package) + ->and($exception->getSuggestion()) + ->toContain("https://marko.build/docs/packages/$basename/"); + } + }); + + it('includes the description for each driver in the suggestion', function (): void { + $knownDrivers = require __DIR__ . '/../../known-drivers.php'; + $exception = NoDriverException::noDriverInstalled(); + + foreach ($knownDrivers as $package => $description) { + expect($exception->getSuggestion())->toContain($description); + } + }); + + it('includes a composer require command for each driver', function (): void { + $knownDrivers = require __DIR__ . '/../../known-drivers.php'; + $exception = NoDriverException::noDriverInstalled(); + + foreach (array_keys($knownDrivers) as $package) { + expect($exception->getSuggestion())->toContain("composer require $package"); + } + }); + + it('extends MarkoException', function (): void { + $exception = NoDriverException::noDriverInstalled(); + + expect($exception)->toBeInstanceOf(MarkoException::class); + }); +}); diff --git a/packages/inertia/tests/KnownDriversTest.php b/packages/inertia/tests/KnownDriversTest.php new file mode 100644 index 00000000..2045c8b3 --- /dev/null +++ b/packages/inertia/tests/KnownDriversTest.php @@ -0,0 +1,22 @@ +toBeTrue(); + + $drivers = require $knownDriversPath; + + expect($drivers)->toHaveKey('marko/inertia-react') + ->and($drivers)->toHaveKey('marko/inertia-svelte') + ->and($drivers)->toHaveKey('marko/inertia-vue'); +}); + +test('it lists marko/inertia-react first as the recommended driver', function (): void { + $drivers = require __DIR__ . '/../known-drivers.php'; + $keys = array_keys($drivers); + + expect($keys[0])->toBe('marko/inertia-react'); +}); diff --git a/packages/inertia/tests/KnownDriversValidationTest.php b/packages/inertia/tests/KnownDriversValidationTest.php new file mode 100644 index 00000000..275da234 --- /dev/null +++ b/packages/inertia/tests/KnownDriversValidationTest.php @@ -0,0 +1,16 @@ + 'File-based logger with rotation', +]; diff --git a/packages/log/src/Exceptions/NoDriverException.php b/packages/log/src/Exceptions/NoDriverException.php index 928807b9..c0cb0a1a 100644 --- a/packages/log/src/Exceptions/NoDriverException.php +++ b/packages/log/src/Exceptions/NoDriverException.php @@ -8,21 +8,38 @@ class NoDriverException extends MarkoException { - private const array DRIVER_PACKAGES = [ - 'marko/log-file', - ]; - public static function noDriverInstalled(): self { - $packageList = implode("\n", array_map( - fn (string $pkg) => "- `composer require $pkg`", - self::DRIVER_PACKAGES, - )); + $drivers = require __DIR__ . '/../../known-drivers.php'; + $packageList = self::formatDriverList($drivers); return new self( message: 'No log driver installed.', context: 'Attempted to resolve a logger interface but no implementation is bound.', - suggestion: "Install a log driver:\n$packageList", + suggestion: "Install one of these drivers:\n$packageList", ); } + + /** + * @param array $drivers + */ + private static function formatDriverList(array $drivers): string + { + $lines = []; + foreach ($drivers as $package => $description) { + $docsUrl = self::docsUrl($package); + $lines[] = "- $package: $description"; + $lines[] = " Install: composer require $package"; + $lines[] = " Docs: $docsUrl"; + } + + return implode("\n", $lines); + } + + private static function docsUrl(string $package): string + { + $basename = substr($package, strlen('marko/')); + + return "https://marko.build/docs/packages/$basename/"; + } } diff --git a/packages/log/tests/KnownDriversValidationTest.php b/packages/log/tests/KnownDriversValidationTest.php new file mode 100644 index 00000000..4b3ab860 --- /dev/null +++ b/packages/log/tests/KnownDriversValidationTest.php @@ -0,0 +1,16 @@ +toBeTrue(); }); +it('ships a known-drivers.php file listing marko/log-file', function () { + $knownDriversPath = dirname(__DIR__) . '/known-drivers.php'; + + expect(file_exists($knownDriversPath))->toBeTrue(); + + $drivers = require $knownDriversPath; + + expect($drivers)->toBeArray() + ->and($drivers)->toHaveKey('marko/log-file') + ->and($drivers['marko/log-file'])->toBe('File-based logger with rotation'); +}); + it('has default log.php config file', function () { $configPath = dirname(__DIR__) . '/config/log.php'; diff --git a/packages/log/tests/Unit/Exceptions/NoDriverExceptionTest.php b/packages/log/tests/Unit/Exceptions/NoDriverExceptionTest.php index f8a2e473..b9f8b5dd 100644 --- a/packages/log/tests/Unit/Exceptions/NoDriverExceptionTest.php +++ b/packages/log/tests/Unit/Exceptions/NoDriverExceptionTest.php @@ -6,12 +6,12 @@ use Marko\Log\Exceptions\NoDriverException; describe('NoDriverException', function (): void { - it('has DRIVER_PACKAGES constant listing marko/log-file', function (): void { - $reflection = new ReflectionClass(NoDriverException::class); - $constant = $reflection->getReflectionConstant('DRIVER_PACKAGES'); + it('log NoDriverException reads from known-drivers.php and includes docs URL', function (): void { + $exception = NoDriverException::noDriverInstalled(); - expect($constant)->not->toBeFalse() - ->and($constant->getValue())->toContain('marko/log-file'); + expect($exception->getSuggestion())->toContain('marko/log-file') + ->and($exception->getSuggestion())->toContain('composer require marko/log-file') + ->and($exception->getSuggestion())->toContain('https://marko.build/docs/packages/log-file/'); }); it('provides suggestion with composer require command', function (): void { diff --git a/packages/mail/known-drivers.php b/packages/mail/known-drivers.php new file mode 100644 index 00000000..731fdda2 --- /dev/null +++ b/packages/mail/known-drivers.php @@ -0,0 +1,8 @@ + 'SMTP mailer driver (recommended for production)', + 'marko/mail-log' => 'Log-only mailer driver (writes emails to LoggerInterface; intended for development and testing)', +]; diff --git a/packages/mail/src/Exceptions/NoDriverException.php b/packages/mail/src/Exceptions/NoDriverException.php index e01c4558..8440f120 100644 --- a/packages/mail/src/Exceptions/NoDriverException.php +++ b/packages/mail/src/Exceptions/NoDriverException.php @@ -8,22 +8,38 @@ class NoDriverException extends MarkoException { - private const array DRIVER_PACKAGES = [ - 'marko/mail-log', - 'marko/mail-smtp', - ]; - public static function noDriverInstalled(): self { - $packageList = implode("\n", array_map( - fn (string $pkg) => "- `composer require $pkg`", - self::DRIVER_PACKAGES, - )); + $drivers = require __DIR__ . '/../../known-drivers.php'; + $packageList = self::formatDriverList($drivers); return new self( message: 'No mail driver installed.', context: 'Attempted to resolve a mail interface but no implementation is bound.', - suggestion: "Install a mail driver:\n$packageList", + suggestion: "Install one of these drivers:\n$packageList", ); } + + /** + * @param array $drivers + */ + private static function formatDriverList(array $drivers): string + { + $lines = []; + foreach ($drivers as $package => $description) { + $docsUrl = self::docsUrl($package); + $lines[] = "- $package: $description"; + $lines[] = " Install: composer require $package"; + $lines[] = " Docs: $docsUrl"; + } + + return implode("\n", $lines); + } + + private static function docsUrl(string $package): string + { + $basename = substr($package, strlen('marko/')); + + return "https://marko.build/docs/packages/$basename/"; + } } diff --git a/packages/mail/tests/KnownDriversTest.php b/packages/mail/tests/KnownDriversTest.php new file mode 100644 index 00000000..ca041f31 --- /dev/null +++ b/packages/mail/tests/KnownDriversTest.php @@ -0,0 +1,23 @@ +toBeTrue(); + + $drivers = require $path; + + expect($drivers)->toHaveKey('marko/mail-smtp') + ->and($drivers)->toHaveKey('marko/mail-log'); + }); + + it('lists marko/mail-smtp first as the recommended driver', function (): void { + $drivers = (static fn (): array => require __DIR__ . '/../known-drivers.php')(); + $keys = array_keys($drivers); + + expect($keys[0])->toBe('marko/mail-smtp'); + }); +}); diff --git a/packages/mail/tests/KnownDriversValidationTest.php b/packages/mail/tests/KnownDriversValidationTest.php new file mode 100644 index 00000000..95b400ff --- /dev/null +++ b/packages/mail/tests/KnownDriversValidationTest.php @@ -0,0 +1,16 @@ +getConstants(); - - expect($constants)->toHaveKey('DRIVER_PACKAGES') - ->and($constants['DRIVER_PACKAGES'])->toBe([ - 'marko/mail-log', - 'marko/mail-smtp', - ]); -}); +describe('NoDriverException', function (): void { + it('mail NoDriverException reads from known-drivers.php and includes docs URLs', function (): void { + $knownDrivers = require __DIR__ . '/../../../known-drivers.php'; + $exception = NoDriverException::noDriverInstalled(); -test('it provides suggestion with composer require commands for all driver packages', function () { - $exception = NoDriverException::noDriverInstalled(); + foreach (array_keys($knownDrivers) as $package) { + $basename = substr($package, strlen('marko/')); + expect($exception->getSuggestion())->toContain("https://marko.build/docs/packages/$basename/"); + } + }); - expect($exception->getSuggestion())->toContain('composer require marko/mail-log') - ->and($exception->getSuggestion())->toContain('composer require marko/mail-smtp'); -}); + it('loads the driver list from known-drivers.php', function (): void { + $knownDrivers = require __DIR__ . '/../../../known-drivers.php'; + $exception = NoDriverException::noDriverInstalled(); -test('it includes context about resolving mail interfaces', function () { - $exception = NoDriverException::noDriverInstalled(); + foreach (array_keys($knownDrivers) as $package) { + expect($exception->getSuggestion())->toContain($package); + } + }); - expect($exception->getContext())->toBe('Attempted to resolve a mail interface but no implementation is bound.'); -}); + it('includes the description for each driver in the suggestion', function (): void { + $knownDrivers = require __DIR__ . '/../../../known-drivers.php'; + $exception = NoDriverException::noDriverInstalled(); + + foreach ($knownDrivers as $package => $description) { + expect($exception->getSuggestion())->toContain($description); + } + }); + + it('includes a composer require command for each driver', function (): void { + $knownDrivers = require __DIR__ . '/../../../known-drivers.php'; + $exception = NoDriverException::noDriverInstalled(); + + foreach (array_keys($knownDrivers) as $package) { + expect($exception->getSuggestion())->toContain("composer require $package"); + } + }); + + it('no longer exposes a DRIVER_PACKAGES const', function (): void { + $reflection = new ReflectionClass(NoDriverException::class); + $constant = $reflection->getReflectionConstant('DRIVER_PACKAGES'); + + expect($constant)->toBeFalse(); + }); + + it('provides suggestion with composer require commands for all driver packages', function (): void { + $exception = NoDriverException::noDriverInstalled(); + + expect($exception->getSuggestion())->toContain('composer require marko/mail-smtp') + ->and($exception->getSuggestion())->toContain('composer require marko/mail-log'); + }); + + it('includes context about resolving mail interfaces', function (): void { + $exception = NoDriverException::noDriverInstalled(); + + expect($exception->getContext())->toBe('Attempted to resolve a mail interface but no implementation is bound.'); + }); -test('it extends MarkoException', function () { - $exception = NoDriverException::noDriverInstalled(); + it('extends MarkoException', function (): void { + $exception = NoDriverException::noDriverInstalled(); - expect($exception)->toBeInstanceOf(MarkoException::class); + expect($exception)->toBeInstanceOf(MarkoException::class); + }); }); diff --git a/packages/media/known-drivers.php b/packages/media/known-drivers.php new file mode 100644 index 00000000..0faf878f --- /dev/null +++ b/packages/media/known-drivers.php @@ -0,0 +1,8 @@ + 'GD image processor (recommended — ships with most PHP installations)', + 'marko/media-imagick' => 'ImageMagick image processor (higher fidelity; requires ext-imagick and ImageMagick library installed)', +]; diff --git a/packages/media/src/Exceptions/NoDriverException.php b/packages/media/src/Exceptions/NoDriverException.php index 58ef8d0d..0674d5bb 100644 --- a/packages/media/src/Exceptions/NoDriverException.php +++ b/packages/media/src/Exceptions/NoDriverException.php @@ -6,22 +6,38 @@ class NoDriverException extends MediaException { - private const array DRIVER_PACKAGES = [ - 'marko/media-gd', - 'marko/media-imagick', - ]; - public static function noDriverInstalled(): self { - $packageList = implode("\n", array_map( - fn (string $pkg) => "- `composer require $pkg`", - self::DRIVER_PACKAGES, - )); + $drivers = require __DIR__ . '/../../known-drivers.php'; + $packageList = self::formatDriverList($drivers); return new self( message: 'No media driver installed.', context: 'Attempted to resolve a media processing interface but no implementation is bound.', - suggestion: "Install a media driver:\n$packageList", + suggestion: "Install one of these drivers:\n$packageList", ); } + + /** + * @param array $drivers + */ + private static function formatDriverList(array $drivers): string + { + $lines = []; + foreach ($drivers as $package => $description) { + $docsUrl = self::docsUrl($package); + $lines[] = "- $package: $description"; + $lines[] = " Install: composer require $package"; + $lines[] = " Docs: $docsUrl"; + } + + return implode("\n", $lines); + } + + private static function docsUrl(string $package): string + { + $basename = substr($package, strlen('marko/')); + + return "https://marko.build/docs/packages/$basename/"; + } } diff --git a/packages/media/tests/Exceptions/NoDriverExceptionTest.php b/packages/media/tests/Exceptions/NoDriverExceptionTest.php index 888e4afb..717389c1 100644 --- a/packages/media/tests/Exceptions/NoDriverExceptionTest.php +++ b/packages/media/tests/Exceptions/NoDriverExceptionTest.php @@ -6,14 +6,16 @@ use Marko\Media\Exceptions\MediaException; use Marko\Media\Exceptions\NoDriverException; -use ReflectionClass; -it('has DRIVER_PACKAGES constant listing marko/media-gd and marko/media-imagick', function (): void { - $reflection = new ReflectionClass(NoDriverException::class); - $constants = $reflection->getConstants(); +it('media NoDriverException reads from known-drivers.php and includes docs URLs', function (): void { + $exception = NoDriverException::noDriverInstalled(); + $suggestion = $exception->getSuggestion(); - expect($constants)->toHaveKey('DRIVER_PACKAGES') - ->and($constants['DRIVER_PACKAGES'])->toBe(['marko/media-gd', 'marko/media-imagick']); + expect($suggestion) + ->toContain('marko/media-gd') + ->and($suggestion)->toContain('marko/media-imagick') + ->and($suggestion)->toContain('https://marko.build/docs/packages/media-gd/') + ->and($suggestion)->toContain('https://marko.build/docs/packages/media-imagick/'); }); it('provides suggestion with composer require commands for all driver packages', function (): void { diff --git a/packages/media/tests/KnownDriversTest.php b/packages/media/tests/KnownDriversTest.php new file mode 100644 index 00000000..920096a6 --- /dev/null +++ b/packages/media/tests/KnownDriversTest.php @@ -0,0 +1,19 @@ +toBeTrue() + ->and(require $knownDriversPath)->toHaveKey('marko/media-gd') + ->and(require $knownDriversPath)->toHaveKey('marko/media-imagick'); +}); + +it('lists marko/media-gd first as the recommended driver', function (): void { + $knownDriversPath = __DIR__ . '/../known-drivers.php'; + $drivers = require $knownDriversPath; + $keys = array_keys($drivers); + + expect($keys[0])->toBe('marko/media-gd'); +}); diff --git a/packages/media/tests/KnownDriversValidationTest.php b/packages/media/tests/KnownDriversValidationTest.php new file mode 100644 index 00000000..aee79f79 --- /dev/null +++ b/packages/media/tests/KnownDriversValidationTest.php @@ -0,0 +1,16 @@ + 'Database-backed notification driver', +]; diff --git a/packages/notification/src/Exceptions/NoDriverException.php b/packages/notification/src/Exceptions/NoDriverException.php index a869ae67..a1e9be5a 100644 --- a/packages/notification/src/Exceptions/NoDriverException.php +++ b/packages/notification/src/Exceptions/NoDriverException.php @@ -6,21 +6,38 @@ class NoDriverException extends NotificationException { - private const array DRIVER_PACKAGES = [ - 'marko/notification-database', - ]; - public static function noDriverInstalled(): self { - $packageList = implode("\n", array_map( - fn (string $pkg) => "- `composer require $pkg`", - self::DRIVER_PACKAGES, - )); + $drivers = require __DIR__ . '/../../known-drivers.php'; + $packageList = self::formatDriverList($drivers); return new self( message: 'No notification driver installed.', context: 'Attempted to resolve a notification interface but no implementation is bound.', - suggestion: "Install a notification driver:\n$packageList", + suggestion: "Install one of these drivers:\n$packageList", ); } + + /** + * @param array $drivers + */ + private static function formatDriverList(array $drivers): string + { + $lines = []; + foreach ($drivers as $package => $description) { + $docsUrl = self::docsUrl($package); + $lines[] = "- $package: $description"; + $lines[] = " Install: composer require $package"; + $lines[] = " Docs: $docsUrl"; + } + + return implode("\n", $lines); + } + + private static function docsUrl(string $package): string + { + $basename = substr($package, strlen('marko/')); + + return "https://marko.build/docs/packages/$basename/"; + } } diff --git a/packages/notification/tests/KnownDriversValidationTest.php b/packages/notification/tests/KnownDriversValidationTest.php new file mode 100644 index 00000000..297fc19f --- /dev/null +++ b/packages/notification/tests/KnownDriversValidationTest.php @@ -0,0 +1,16 @@ +not->toHaveKey('version'); }); + it('ships a known-drivers.php file listing marko/notification-database', function (): void { + $knownDriversPath = dirname(__DIR__) . '/known-drivers.php'; + + expect(file_exists($knownDriversPath))->toBeTrue(); + + $drivers = require $knownDriversPath; + + expect($drivers)->toBeArray() + ->and($drivers)->toHaveKey('marko/notification-database') + ->and($drivers['marko/notification-database'])->toBe('Database-backed notification driver'); + }); + it('returns valid module configuration array with bindings', function (): void { $module = require dirname(__DIR__) . '/module.php'; diff --git a/packages/notification/tests/Unit/Exceptions/NoDriverExceptionTest.php b/packages/notification/tests/Unit/Exceptions/NoDriverExceptionTest.php index e39926b5..eb3c798c 100644 --- a/packages/notification/tests/Unit/Exceptions/NoDriverExceptionTest.php +++ b/packages/notification/tests/Unit/Exceptions/NoDriverExceptionTest.php @@ -6,12 +6,13 @@ use Marko\Notification\Exceptions\NotificationException; describe('NoDriverException', function (): void { - it('has DRIVER_PACKAGES constant listing marko/notification-database', function (): void { - $reflection = new ReflectionClass(NoDriverException::class); - $constant = $reflection->getReflectionConstant('DRIVER_PACKAGES'); + it('notification NoDriverException reads from known-drivers.php and includes docs URL', function (): void { + $exception = NoDriverException::noDriverInstalled(); - expect($constant)->not->toBeFalse() - ->and($constant->getValue())->toContain('marko/notification-database'); + expect($exception->getSuggestion()) + ->toContain('marko/notification-database') + ->and($exception->getSuggestion())->toContain('composer require marko/notification-database') + ->and($exception->getSuggestion())->toContain('https://marko.build/docs/packages/notification-database/'); }); it('provides suggestion with composer require command', function (): void { diff --git a/packages/page-cache/known-drivers.php b/packages/page-cache/known-drivers.php new file mode 100644 index 00000000..2b0df2fe --- /dev/null +++ b/packages/page-cache/known-drivers.php @@ -0,0 +1,7 @@ + 'File-based page cache driver', +]; diff --git a/packages/page-cache/src/Exceptions/NoDriverException.php b/packages/page-cache/src/Exceptions/NoDriverException.php index 408b68fb..3b9dc242 100644 --- a/packages/page-cache/src/Exceptions/NoDriverException.php +++ b/packages/page-cache/src/Exceptions/NoDriverException.php @@ -6,12 +6,38 @@ class NoDriverException extends PageCacheException { - public static function noBinding(): self + public static function noDriverInstalled(): self { + $drivers = require __DIR__ . '/../../known-drivers.php'; + $packageList = self::formatDriverList($drivers); + return new self( message: 'No page cache driver installed.', context: 'Attempted to resolve a page cache interface but no implementation is bound.', - suggestion: 'Install a page cache driver: `composer require marko/page-cache-file` or another driver', + suggestion: "Install one of these drivers:\n$packageList", ); } + + /** + * @param array $drivers + */ + private static function formatDriverList(array $drivers): string + { + $lines = []; + foreach ($drivers as $package => $description) { + $docsUrl = self::docsUrl($package); + $lines[] = "- $package: $description"; + $lines[] = " Install: composer require $package"; + $lines[] = " Docs: $docsUrl"; + } + + return implode("\n", $lines); + } + + private static function docsUrl(string $package): string + { + $basename = substr($package, strlen('marko/')); + + return "https://marko.build/docs/packages/$basename/"; + } } diff --git a/packages/page-cache/tests/KnownDriversTest.php b/packages/page-cache/tests/KnownDriversTest.php new file mode 100644 index 00000000..e45c8ac7 --- /dev/null +++ b/packages/page-cache/tests/KnownDriversTest.php @@ -0,0 +1,21 @@ +toBeTrue(); + + $drivers = require $path; + + expect($drivers)->toBeArray() + ->and(array_keys($drivers))->toBe(['marko/page-cache-file']); +}); + +it('does not list marko/page-cache-entity (add-on, not driver)', function (): void { + $path = __DIR__ . '/../known-drivers.php'; + $drivers = require $path; + + expect(array_key_exists('marko/page-cache-entity', $drivers))->toBeFalse(); +}); diff --git a/packages/page-cache/tests/KnownDriversValidationTest.php b/packages/page-cache/tests/KnownDriversValidationTest.php new file mode 100644 index 00000000..f8f376c6 --- /dev/null +++ b/packages/page-cache/tests/KnownDriversValidationTest.php @@ -0,0 +1,11 @@ + KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath)); +test('every page-cache driver follows marko slash prefix pattern', fn () => KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath)); diff --git a/packages/page-cache/tests/Unit/Exceptions/NoDriverExceptionTest.php b/packages/page-cache/tests/Unit/Exceptions/NoDriverExceptionTest.php index f61f474c..c3f3fd55 100644 --- a/packages/page-cache/tests/Unit/Exceptions/NoDriverExceptionTest.php +++ b/packages/page-cache/tests/Unit/Exceptions/NoDriverExceptionTest.php @@ -5,15 +5,16 @@ use Marko\PageCache\Exceptions\NoDriverException; use Marko\PageCache\Exceptions\PageCacheException; -it('produces a NoDriverException via static factory with helpful message and suggestion', function (): void { - $exception = NoDriverException::noBinding(); +it('page-cache NoDriverException reads from known-drivers.php and includes docs URL', function (): void { + $exception = NoDriverException::noDriverInstalled(); - expect($exception->getMessage())->not->toBeEmpty() - ->and($exception->getSuggestion())->toContain('marko/page-cache-'); + expect($exception->getSuggestion()) + ->toContain('marko/page-cache-file') + ->and($exception->getSuggestion())->toContain('https://marko.build/docs/packages/page-cache-file/'); }); -it('inherits NoDriverException from PageCacheException', function (): void { - $exception = NoDriverException::noBinding(); +it('page-cache NoDriverException exposes a noDriverInstalled() factory (renamed from noBinding for consistency)', function (): void { + $exception = NoDriverException::noDriverInstalled(); expect($exception)->toBeInstanceOf(NoDriverException::class) ->and($exception)->toBeInstanceOf(PageCacheException::class); diff --git a/packages/pubsub/known-drivers.php b/packages/pubsub/known-drivers.php new file mode 100644 index 00000000..f9c73007 --- /dev/null +++ b/packages/pubsub/known-drivers.php @@ -0,0 +1,8 @@ + 'Redis pub/sub driver (recommended — purpose-built for messaging)', + 'marko/pubsub-pgsql' => 'PostgreSQL LISTEN/NOTIFY pub/sub driver (no additional infrastructure if you already use Postgres)', +]; diff --git a/packages/pubsub/src/Exceptions/NoDriverException.php b/packages/pubsub/src/Exceptions/NoDriverException.php index b8ac5a5d..9686628c 100644 --- a/packages/pubsub/src/Exceptions/NoDriverException.php +++ b/packages/pubsub/src/Exceptions/NoDriverException.php @@ -6,22 +6,38 @@ class NoDriverException extends PubSubException { - private const array DRIVER_PACKAGES = [ - 'marko/pubsub-pgsql', - 'marko/pubsub-redis', - ]; - public static function noDriverInstalled(): self { - $packageList = implode("\n", array_map( - fn (string $pkg) => "- `composer require $pkg`", - self::DRIVER_PACKAGES, - )); + $drivers = require __DIR__ . '/../../known-drivers.php'; + $packageList = self::formatDriverList($drivers); return new self( message: 'No pub/sub driver installed.', context: 'Attempted to resolve a pub/sub interface but no implementation is bound.', - suggestion: "Install a pub/sub driver:\n$packageList", + suggestion: "Install one of these drivers:\n$packageList", ); } + + /** + * @param array $drivers + */ + private static function formatDriverList(array $drivers): string + { + $lines = []; + foreach ($drivers as $package => $description) { + $docsUrl = self::docsUrl($package); + $lines[] = "- $package: $description"; + $lines[] = " Install: composer require $package"; + $lines[] = " Docs: $docsUrl"; + } + + return implode("\n", $lines); + } + + private static function docsUrl(string $package): string + { + $basename = substr($package, strlen('marko/')); + + return "https://marko.build/docs/packages/$basename/"; + } } diff --git a/packages/pubsub/tests/Exceptions/NoDriverExceptionTest.php b/packages/pubsub/tests/Exceptions/NoDriverExceptionTest.php index 923b6bed..0a4dcc4d 100644 --- a/packages/pubsub/tests/Exceptions/NoDriverExceptionTest.php +++ b/packages/pubsub/tests/Exceptions/NoDriverExceptionTest.php @@ -6,15 +6,6 @@ use Marko\PubSub\Exceptions\PubSubException; describe('NoDriverException', function (): void { - it('has DRIVER_PACKAGES constant listing marko/pubsub-pgsql and marko/pubsub-redis', function (): void { - $reflection = new ReflectionClass(NoDriverException::class); - $constant = $reflection->getReflectionConstant('DRIVER_PACKAGES'); - - expect($constant)->not->toBeFalse() - ->and($constant->getValue())->toContain('marko/pubsub-pgsql') - ->and($constant->getValue())->toContain('marko/pubsub-redis'); - }); - it('provides suggestion with composer require commands for all driver packages', function (): void { $exception = NoDriverException::noDriverInstalled(); @@ -33,4 +24,11 @@ expect($exception)->toBeInstanceOf(PubSubException::class); }); + + it('pubsub NoDriverException reads from known-drivers.php and includes docs URLs', function (): void { + $exception = NoDriverException::noDriverInstalled(); + + expect($exception->getSuggestion())->toContain('https://marko.build/docs/packages/pubsub-redis/') + ->and($exception->getSuggestion())->toContain('https://marko.build/docs/packages/pubsub-pgsql/'); + }); }); diff --git a/packages/pubsub/tests/KnownDriversTest.php b/packages/pubsub/tests/KnownDriversTest.php new file mode 100644 index 00000000..784f15a0 --- /dev/null +++ b/packages/pubsub/tests/KnownDriversTest.php @@ -0,0 +1,22 @@ +toBeTrue(); + + $drivers = require $path; + + expect($drivers)->toHaveKey('marko/pubsub-redis') + ->and($drivers)->toHaveKey('marko/pubsub-pgsql'); +}); + +it('lists marko/pubsub-redis first as the recommended driver', function (): void { + $drivers = require __DIR__ . '/../known-drivers.php'; + + $keys = array_keys($drivers); + + expect($keys[0])->toBe('marko/pubsub-redis'); +}); diff --git a/packages/pubsub/tests/KnownDriversValidationTest.php b/packages/pubsub/tests/KnownDriversValidationTest.php new file mode 100644 index 00000000..1a1bd17e --- /dev/null +++ b/packages/pubsub/tests/KnownDriversValidationTest.php @@ -0,0 +1,11 @@ + KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath)); +test('every pubsub driver follows marko slash prefix pattern', fn () => KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath)); diff --git a/packages/queue/known-drivers.php b/packages/queue/known-drivers.php new file mode 100644 index 00000000..4b5c0664 --- /dev/null +++ b/packages/queue/known-drivers.php @@ -0,0 +1,9 @@ + 'Synchronous queue driver (recommended for development — runs jobs inline, no infrastructure)', + 'marko/queue-database' => 'Database-backed queue driver (production-ready; uses your existing database)', + 'marko/queue-rabbitmq' => 'RabbitMQ queue driver (recommended for high-throughput production deployments)', +]; diff --git a/packages/queue/src/Exceptions/NoDriverException.php b/packages/queue/src/Exceptions/NoDriverException.php index 18b08219..39d5daa5 100644 --- a/packages/queue/src/Exceptions/NoDriverException.php +++ b/packages/queue/src/Exceptions/NoDriverException.php @@ -6,23 +6,38 @@ class NoDriverException extends QueueException { - private const array DRIVER_PACKAGES = [ - 'marko/queue-database', - 'marko/queue-rabbitmq', - 'marko/queue-sync', - ]; - public static function noDriverInstalled(): self { - $packageList = implode("\n", array_map( - fn (string $pkg) => "- `composer require $pkg`", - self::DRIVER_PACKAGES, - )); + $drivers = require __DIR__ . '/../../known-drivers.php'; + $packageList = self::formatDriverList($drivers); return new self( message: 'No queue driver installed.', context: 'Attempted to resolve a queue interface but no implementation is bound.', - suggestion: "Install a queue driver:\n$packageList", + suggestion: "Install one of these drivers:\n$packageList", ); } + + /** + * @param array $drivers + */ + private static function formatDriverList(array $drivers): string + { + $lines = []; + foreach ($drivers as $package => $description) { + $docsUrl = self::docsUrl($package); + $lines[] = "- $package: $description"; + $lines[] = " Install: composer require $package"; + $lines[] = " Docs: $docsUrl"; + } + + return implode("\n", $lines); + } + + private static function docsUrl(string $package): string + { + $basename = substr($package, strlen('marko/')); + + return "https://marko.build/docs/packages/$basename/"; + } } diff --git a/packages/queue/tests/KnownDriversValidationTest.php b/packages/queue/tests/KnownDriversValidationTest.php new file mode 100644 index 00000000..84bc53f7 --- /dev/null +++ b/packages/queue/tests/KnownDriversValidationTest.php @@ -0,0 +1,30 @@ +toBeTrue(); + + $drivers = require $knownDriversPath; + + expect($drivers)->toBeArray() + ->and(array_keys($drivers))->toContain('marko/queue-sync') + ->and(array_keys($drivers))->toContain('marko/queue-database') + ->and(array_keys($drivers))->toContain('marko/queue-rabbitmq') + ->and($drivers)->toHaveCount(3); +}); + +test('it lists marko/queue-sync first as the recommended development default', function () use ($knownDriversPath): void { + $drivers = require $knownDriversPath; + + expect(array_key_first($drivers))->toBe('marko/queue-sync'); +}); + +test('skeleton suggest block contains all queue drivers', fn () => KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath)); + +test('every queue driver follows marko slash prefix pattern', fn () => KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath)); diff --git a/packages/queue/tests/NoDriverExceptionTest.php b/packages/queue/tests/NoDriverExceptionTest.php index 0c13274e..0b96b793 100644 --- a/packages/queue/tests/NoDriverExceptionTest.php +++ b/packages/queue/tests/NoDriverExceptionTest.php @@ -6,23 +6,78 @@ use Marko\Queue\Exceptions\QueueException; describe('NoDriverException', function (): void { - it('has DRIVER_PACKAGES constant listing marko/queue-database, marko/queue-rabbitmq, and marko/queue-sync', function (): void { + it('loads the driver list from known-drivers.php', function (): void { + $knownDrivers = require __DIR__ . '/../known-drivers.php'; + $exception = NoDriverException::noDriverInstalled(); + + foreach (array_keys($knownDrivers) as $package) { + expect($exception->getSuggestion())->toContain($package); + } + }); + + it('includes the description for each driver in the suggestion', function (): void { + $knownDrivers = require __DIR__ . '/../known-drivers.php'; + $exception = NoDriverException::noDriverInstalled(); + + foreach ($knownDrivers as $package => $description) { + expect($exception->getSuggestion())->toContain($description); + } + }); + + it('includes a composer require command for each driver', function (): void { + $knownDrivers = require __DIR__ . '/../known-drivers.php'; + $exception = NoDriverException::noDriverInstalled(); + + foreach (array_keys($knownDrivers) as $package) { + expect($exception->getSuggestion())->toContain("composer require $package"); + } + }); + + it('includes a derived docs URL for each driver', function (): void { + $knownDrivers = require __DIR__ . '/../known-drivers.php'; + $exception = NoDriverException::noDriverInstalled(); + + foreach (array_keys($knownDrivers) as $package) { + $basename = substr($package, strlen('marko/')); + expect($exception->getSuggestion())->toContain("https://marko.build/docs/packages/$basename/"); + } + }); + + it('queue NoDriverException reads from known-drivers.php and includes docs URLs', function (): void { + $exception = NoDriverException::noDriverInstalled(); + + expect($exception->getSuggestion()) + ->toContain('https://marko.build/docs/packages/queue-sync/') + ->and($exception->getSuggestion())->toContain('https://marko.build/docs/packages/queue-database/') + ->and($exception->getSuggestion())->toContain('https://marko.build/docs/packages/queue-rabbitmq/'); + }); + + it('lists queue-sync first in the suggestion (matching known-drivers.php order)', function (): void { + $exception = NoDriverException::noDriverInstalled(); + $suggestion = $exception->getSuggestion(); + + $syncPos = strpos($suggestion, 'marko/queue-sync'); + $databasePos = strpos($suggestion, 'marko/queue-database'); + $rabbitmqPos = strpos($suggestion, 'marko/queue-rabbitmq'); + + expect($syncPos)->toBeLessThan($databasePos) + ->and($databasePos)->toBeLessThan($rabbitmqPos); + }); + + it('no longer exposes a DRIVER_PACKAGES const', function (): void { $reflection = new ReflectionClass(NoDriverException::class); $constant = $reflection->getReflectionConstant('DRIVER_PACKAGES'); - expect($constant)->not->toBeFalse() - ->and($constant->getValue())->toContain('marko/queue-database') - ->and($constant->getValue())->toContain('marko/queue-rabbitmq') - ->and($constant->getValue())->toContain('marko/queue-sync'); + expect($constant)->toBeFalse(); }); it('provides suggestion with composer require commands for all driver packages', function (): void { $exception = NoDriverException::noDriverInstalled(); expect($exception->getSuggestion()) - ->toContain('composer require marko/queue-database') - ->and($exception->getSuggestion())->toContain('composer require marko/queue-rabbitmq') - ->and($exception->getSuggestion())->toContain('composer require marko/queue-sync'); + ->toContain('composer require marko/queue-sync') + ->and($exception->getSuggestion())->toContain('composer require marko/queue-database') + ->and($exception->getSuggestion())->toContain('composer require marko/queue-rabbitmq'); }); it('includes context about resolving queue interfaces', function (): void { diff --git a/packages/session/composer.json b/packages/session/composer.json index d2b60d87..97f04487 100644 --- a/packages/session/composer.json +++ b/packages/session/composer.json @@ -8,6 +8,10 @@ "marko/core": "self.version", "marko/config": "self.version" }, + "require-dev": { + "marko/testing": "self.version", + "pestphp/pest": "^4.0" + }, "autoload": { "psr-4": { "Marko\\Session\\": "src/" diff --git a/packages/session/known-drivers.php b/packages/session/known-drivers.php new file mode 100644 index 00000000..c5c98ed9 --- /dev/null +++ b/packages/session/known-drivers.php @@ -0,0 +1,8 @@ + 'File-based session driver (recommended default for single-server apps)', + 'marko/session-database' => 'Database-backed session driver (recommended for distributed deployments and queryable session data)', +]; diff --git a/packages/session/src/Exceptions/NoDriverException.php b/packages/session/src/Exceptions/NoDriverException.php index 6419fe59..7795c438 100644 --- a/packages/session/src/Exceptions/NoDriverException.php +++ b/packages/session/src/Exceptions/NoDriverException.php @@ -8,22 +8,38 @@ class NoDriverException extends MarkoException { - private const array DRIVER_PACKAGES = [ - 'marko/session-database', - 'marko/session-file', - ]; - public static function noDriverInstalled(): self { - $packageList = implode("\n", array_map( - fn (string $pkg) => "- `composer require $pkg`", - self::DRIVER_PACKAGES, - )); + $drivers = require __DIR__ . '/../../known-drivers.php'; + $packageList = self::formatDriverList($drivers); return new self( message: 'No session driver installed.', context: 'Attempted to resolve a session interface but no implementation is bound.', - suggestion: "Install a session driver:\n$packageList", + suggestion: "Install one of these drivers:\n$packageList", ); } + + /** + * @param array $drivers + */ + private static function formatDriverList(array $drivers): string + { + $lines = []; + foreach ($drivers as $package => $description) { + $docsUrl = self::docsUrl($package); + $lines[] = "- $package: $description"; + $lines[] = " Install: composer require $package"; + $lines[] = " Docs: $docsUrl"; + } + + return implode("\n", $lines); + } + + private static function docsUrl(string $package): string + { + $basename = substr($package, strlen('marko/')); + + return "https://marko.build/docs/packages/$basename/"; + } } diff --git a/packages/session/tests/KnownDriversTest.php b/packages/session/tests/KnownDriversTest.php new file mode 100644 index 00000000..d2b2ac2b --- /dev/null +++ b/packages/session/tests/KnownDriversTest.php @@ -0,0 +1,18 @@ +toBeTrue(); + }); + + it('lists marko/session-file first as the recommended driver', function (): void { + $drivers = (static fn (): array => require __DIR__ . '/../known-drivers.php')(); + $keys = array_keys($drivers); + + expect($keys[0])->toBe('marko/session-file'); + }); +}); diff --git a/packages/session/tests/KnownDriversValidationTest.php b/packages/session/tests/KnownDriversValidationTest.php new file mode 100644 index 00000000..3691baf5 --- /dev/null +++ b/packages/session/tests/KnownDriversValidationTest.php @@ -0,0 +1,16 @@ +getConstant('DRIVER_PACKAGES'); - - expect($constant)->toBe([ - 'marko/session-database', - 'marko/session-file', - ]); -}); - -it('provides suggestion with composer require commands for all driver packages', function () { - $exception = NoDriverException::noDriverInstalled(); - - expect($exception->getSuggestion()) - ->toContain('composer require marko/session-database') - ->and($exception->getSuggestion()) - ->toContain('composer require marko/session-file'); -}); - -it('includes context about resolving session interfaces', function () { - $exception = NoDriverException::noDriverInstalled(); - - expect($exception->getContext())->toContain('session interface'); -}); - -it('extends MarkoException', function () { - $exception = NoDriverException::noDriverInstalled(); - - expect($exception)->toBeInstanceOf(MarkoException::class); +describe('NoDriverException', function (): void { + it('session NoDriverException reads from known-drivers.php and includes docs URLs', function (): void { + $knownDrivers = require __DIR__ . '/../../../known-drivers.php'; + $exception = NoDriverException::noDriverInstalled(); + + foreach (array_keys($knownDrivers) as $package) { + $basename = substr($package, strlen('marko/')); + expect($exception->getSuggestion()) + ->toContain($package) + ->and($exception->getSuggestion()) + ->toContain("https://marko.build/docs/packages/$basename/"); + } + }); + + it('includes a composer require command for each driver', function (): void { + $knownDrivers = require __DIR__ . '/../../../known-drivers.php'; + $exception = NoDriverException::noDriverInstalled(); + + foreach (array_keys($knownDrivers) as $package) { + expect($exception->getSuggestion())->toContain("composer require $package"); + } + }); + + it('includes the description for each driver in the suggestion', function (): void { + $knownDrivers = require __DIR__ . '/../../../known-drivers.php'; + $exception = NoDriverException::noDriverInstalled(); + + foreach ($knownDrivers as $package => $description) { + expect($exception->getSuggestion())->toContain($description); + } + }); + + it('no longer exposes a DRIVER_PACKAGES const', function (): void { + $reflection = new ReflectionClass(NoDriverException::class); + $constant = $reflection->getReflectionConstant('DRIVER_PACKAGES'); + + expect($constant)->toBeFalse(); + }); + + it('includes context about resolving session interfaces', function (): void { + $exception = NoDriverException::noDriverInstalled(); + + expect($exception->getContext())->toContain('session interface'); + }); + + it('extends MarkoException', function (): void { + $exception = NoDriverException::noDriverInstalled(); + + expect($exception)->toBeInstanceOf(MarkoException::class); + }); }); diff --git a/packages/skeleton/composer.json b/packages/skeleton/composer.json index a88a3ba5..0fc1006e 100644 --- a/packages/skeleton/composer.json +++ b/packages/skeleton/composer.json @@ -13,8 +13,40 @@ "pestphp/pest": "^4.0" }, "suggest": { + "marko/database-pgsql": "PostgreSQL driver (recommended for new projects — strong JSON, FTS, pgvector support)", + "marko/database-mysql": "MySQL/MariaDB driver", + "marko/filesystem-local": "Local disk filesystem driver (recommended default; zero infrastructure required)", + "marko/filesystem-s3": "Amazon S3 filesystem driver (for cloud and distributed deployments)", + "marko/errors-simple": "Simple error handler (recommended for production — minimal information disclosure)", + "marko/errors-advanced": "Advanced error handler with pretty stack traces and suggestions (recommended for development)", "marko/view-twig": "Twig template engine driver (recommended for broader ecosystem familiarity)", - "marko/view-latte": "Latte template engine driver (compile-time safety, n:attribute syntax)" + "marko/view-latte": "Latte template engine driver (compile-time safety, n:attribute syntax)", + "marko/cache-file": "File-based cache driver (recommended for single-server apps)", + "marko/cache-redis": "Redis cache driver (recommended for distributed deployments and high-throughput apps)", + "marko/cache-array": "In-memory cache driver (request-lifetime only; intended for testing and dev environments)", + "marko/inertia-react": "React frontend driver for Inertia.js (recommended — largest community and tooling ecosystem)", + "marko/inertia-svelte": "Svelte frontend driver for Inertia.js", + "marko/inertia-vue": "Vue frontend driver for Inertia.js", + "marko/queue-sync": "Synchronous queue driver (recommended for development — runs jobs inline, no infrastructure)", + "marko/queue-database": "Database-backed queue driver (production-ready; uses your existing database)", + "marko/queue-rabbitmq": "RabbitMQ queue driver (recommended for high-throughput production deployments)", + "marko/pubsub-redis": "Redis pub/sub driver (recommended — purpose-built for messaging)", + "marko/pubsub-pgsql": "PostgreSQL LISTEN/NOTIFY pub/sub driver (no additional infrastructure if you already use Postgres)", + "marko/media-gd": "GD image processor (recommended — ships with most PHP installations)", + "marko/media-imagick": "ImageMagick image processor (higher fidelity; requires ext-imagick and ImageMagick library installed)", + "marko/mail-smtp": "SMTP mailer driver (recommended for production)", + "marko/mail-log": "Log-only mailer driver (writes emails to LoggerInterface; intended for development and testing)", + "marko/session-file": "File-based session driver (recommended default for single-server apps)", + "marko/session-database": "Database-backed session driver (recommended for distributed deployments and queryable session data)", + "marko/authentication-token": "Token-based authentication driver (signed token sessions)", + "marko/encryption-openssl": "OpenSSL-based symmetric encryption driver (AES-256-GCM)", + "marko/http-guzzle": "Guzzle-based HTTP client driver", + "marko/log-file": "File-based logger with rotation", + "marko/translation-file": "File-based translation driver (PHP array files per locale)", + "marko/notification-database": "Database-backed notification driver", + "marko/page-cache-file": "File-based page cache driver", + "marko/database-readwrite": "Read/write connection splitting decorator (optional — works alongside a base driver)", + "marko/page-cache-entity": "Auto-purges page-cache tags on entity save/delete (optional add-on)" }, "config": { "allow-plugins": { diff --git a/packages/skeleton/tests/KnownDriversSuggestParityTest.php b/packages/skeleton/tests/KnownDriversSuggestParityTest.php new file mode 100644 index 00000000..ed868d45 --- /dev/null +++ b/packages/skeleton/tests/KnownDriversSuggestParityTest.php @@ -0,0 +1,84 @@ + $description) { + expect($skeletonSuggest)->toHaveKey($package); + } + } +}); + +test('it preserves descriptions verbatim between known-drivers.php and skeleton suggest', function (): void { + $knownDriversFiles = glob(__DIR__ . '/../../*/known-drivers.php'); + $skeletonSuggest = json_decode( + file_get_contents(__DIR__ . '/../composer.json'), + associative: true, + )['suggest'] ?? []; + + foreach ($knownDriversFiles as $knownDriversPath) { + $drivers = require $knownDriversPath; + foreach ($drivers as $package => $description) { + expect($skeletonSuggest)->toHaveKey($package) + ->and($skeletonSuggest[$package])->toBe($description); + } + } +}); + +test('it includes marko/database-readwrite as an optional add-on', function (): void { + $skeletonSuggest = json_decode( + file_get_contents(__DIR__ . '/../composer.json'), + associative: true, + )['suggest'] ?? []; + + expect($skeletonSuggest)->toHaveKey('marko/database-readwrite') + ->and($skeletonSuggest['marko/database-readwrite'])->toBe('Read/write connection splitting decorator (optional — works alongside a base driver)'); +}); + +test('it includes marko/page-cache-entity as an optional add-on', function (): void { + $skeletonSuggest = json_decode( + file_get_contents(__DIR__ . '/../composer.json'), + associative: true, + )['suggest'] ?? []; + + expect($skeletonSuggest)->toHaveKey('marko/page-cache-entity') + ->and($skeletonSuggest['marko/page-cache-entity'])->toBe('Auto-purges page-cache tags on entity save/delete (optional add-on)'); +}); + +test('it does not move any view, database, cache, etc. drivers into require or require-dev', function (): void { + $composer = json_decode( + file_get_contents(__DIR__ . '/../composer.json'), + associative: true, + ); + + $require = array_merge( + array_keys($composer['require'] ?? []), + array_keys($composer['require-dev'] ?? []), + ); + + $knownDriversFiles = glob(__DIR__ . '/../../*/known-drivers.php'); + + foreach ($knownDriversFiles as $knownDriversPath) { + $drivers = require $knownDriversPath; + foreach ($drivers as $package => $description) { + expect($require)->not->toContain($package); + } + } +}); + +test('skeleton composer.json remains valid JSON after the consolidation', function (): void { + $composerPath = __DIR__ . '/../composer.json'; + $content = file_get_contents($composerPath); + $composer = json_decode($content, associative: true); + + expect(json_last_error())->toBe(JSON_ERROR_NONE) + ->and($composer['suggest'])->toBeArray(); +}); diff --git a/packages/testing/README.md b/packages/testing/README.md index aa960d46..f553f344 100644 --- a/packages/testing/README.md +++ b/packages/testing/README.md @@ -16,6 +16,7 @@ composer require marko/testing --dev `FakeEventDispatcher`, `FakeMailer`, `FakeQueue`, `FakeSession`, `FakeCookieJar`, `FakeLogger`, `FakeConfigRepository`, `FakeAuthenticatable`, `FakeUserProvider`, `FakeGuard` + ## Usage ### FakeEventDispatcher @@ -153,6 +154,21 @@ $guard->assertGuest(); $guard->assertLoggedOut(); ``` +### KnownDriversValidator + +```php +use Marko\Testing\KnownDrivers\KnownDriversValidator; + +KnownDriversValidator::assertDocsUrlsResolveToValidPattern( + __DIR__ . '/../known-drivers.php', +); + +KnownDriversValidator::assertSkeletonSuggestContainsAll( + __DIR__ . '/../known-drivers.php', + __DIR__ . '/../../skeleton/composer.json', +); +``` + ## API Reference ### FakeEventDispatcher @@ -197,6 +213,11 @@ $guard->assertLoggedOut(); - `assertNotAttempted(): void` — Assert attempt() was never called - `assertLoggedOut(): void` — Assert logout() was called +### KnownDriversValidator + +- `assertDocsUrlsResolveToValidPattern(string $knownDriversPath): void` — Assert every key in `known-drivers.php` follows the `marko/*` prefix pattern +- `assertSkeletonSuggestContainsAll(string $knownDriversPath, string $skeletonComposerPath): void` — Assert the skeleton's `suggest` block contains every entry from `known-drivers.php` with matching descriptions + ## Pest Expectations `marko/testing` ships Pest custom expectations that are auto-loaded via `autoload.files`. diff --git a/packages/testing/src/KnownDrivers/KnownDriversValidator.php b/packages/testing/src/KnownDrivers/KnownDriversValidator.php new file mode 100644 index 00000000..450e89ba --- /dev/null +++ b/packages/testing/src/KnownDrivers/KnownDriversValidator.php @@ -0,0 +1,88 @@ + + * + * @throws InvalidArgumentException + */ + private static function readKnownDrivers(string $knownDriversPath): array + { + if (! file_exists($knownDriversPath)) { + throw new InvalidArgumentException( + "known-drivers.php not found at: $knownDriversPath", + ); + } + + /** @var array */ + return require $knownDriversPath; + } + + /** + * @throws InvalidArgumentException + * @throws AssertionFailedError + */ + public static function assertDocsUrlsResolveToValidPattern(string $knownDriversPath): void + { + $drivers = self::readKnownDrivers($knownDriversPath); + + foreach (array_keys($drivers) as $packageName) { + if (! str_starts_with($packageName, 'marko/')) { + throw new AssertionFailedError( + "Driver key '$packageName' does not follow the 'marko/*' prefix pattern.", + ); + } + } + } + + /** + * @throws InvalidArgumentException + * @throws SkippedWithMessageException + * @throws AssertionFailedError + */ + public static function assertSkeletonSuggestContainsAll( + string $knownDriversPath, + string $skeletonComposerPath, + ): void { + if (! file_exists($skeletonComposerPath)) { + throw new SkippedWithMessageException( + "Skeleton composer.json not found at: $skeletonComposerPath", + ); + } + + $composerJson = json_decode((string) file_get_contents($skeletonComposerPath), true); + + if (! isset($composerJson['suggest'])) { + throw new SkippedWithMessageException( + 'Skeleton composer.json has no suggest key yet — skipping until task 024 populates it.', + ); + } + + /** @var array $suggest */ + $suggest = $composerJson['suggest']; + $drivers = self::readKnownDrivers($knownDriversPath); + + foreach ($drivers as $packageName => $description) { + if (! array_key_exists($packageName, $suggest)) { + throw new AssertionFailedError( + "Skeleton suggest is missing known driver '$packageName'.", + ); + } + + if ($suggest[$packageName] !== $description) { + throw new AssertionFailedError( + "Skeleton suggest entry for '$packageName' has description '{$suggest[$packageName]}' but expected '$description'.", + ); + } + } + } +} diff --git a/packages/testing/tests/KnownDrivers/KnownDriversValidatorTest.php b/packages/testing/tests/KnownDrivers/KnownDriversValidatorTest.php new file mode 100644 index 00000000..cad1a100 --- /dev/null +++ b/packages/testing/tests/KnownDrivers/KnownDriversValidatorTest.php @@ -0,0 +1,80 @@ + KnownDriversValidator::assertDocsUrlsResolveToValidPattern($missingPath)) + ->toThrow(InvalidArgumentException::class, 'known-drivers.php not found'); +}); + +it('asserts every known driver follows marko slash prefix pattern', function (): void { + $validPath = __DIR__ . '/fixtures/known-drivers.php'; + $invalidPath = __DIR__ . '/fixtures/known-drivers-invalid-prefix.php'; + + expect(fn () => KnownDriversValidator::assertDocsUrlsResolveToValidPattern($validPath)) + ->not->toThrow(Throwable::class) + ->and(fn () => KnownDriversValidator::assertDocsUrlsResolveToValidPattern($invalidPath)) + ->toThrow(AssertionFailedError::class, 'vendor/invalid-package'); +}); + +it('allows skeleton suggest to contain entries beyond the known drivers list', function (): void { + $knownDriversPath = __DIR__ . '/fixtures/known-drivers.php'; + $skeletonComposerPath = __DIR__ . '/fixtures/skeleton-with-extra-suggest-composer.json'; + + expect(fn () => KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath)) + ->not->toThrow(Throwable::class); +}); + +it('fails skeleton assertion when skeleton has a suggest entry but description does not match', function (): void { + $knownDriversPath = __DIR__ . '/fixtures/known-drivers.php'; + $skeletonComposerPath = __DIR__ . '/fixtures/skeleton-wrong-description-composer.json'; + + expect(fn () => KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath)) + ->toThrow(AssertionFailedError::class, 'marko/cache-redis'); +}); + +it('fails skeleton assertion when skeleton has a suggest key but is missing a known driver entry', function (): void { + $knownDriversPath = __DIR__ . '/fixtures/known-drivers.php'; + $skeletonComposerPath = __DIR__ . '/fixtures/skeleton-missing-driver-composer.json'; + + expect(fn () => KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath)) + ->toThrow(AssertionFailedError::class, "missing known driver 'marko/cache-memcached'"); +}); + +it('skips skeleton assertion when skeleton composer.json has no suggest key yet', function (): void { + $knownDriversPath = __DIR__ . '/fixtures/known-drivers.php'; + $skeletonComposerPath = __DIR__ . '/fixtures/skeleton-without-suggest-composer.json'; + + expect(fn () => KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath)) + ->toThrow(SkippedWithMessageException::class); +}); + +it('skips skeleton assertion gracefully when skeleton composer.json is not on disk', function (): void { + $knownDriversPath = __DIR__ . '/fixtures/known-drivers.php'; + $skeletonComposerPath = __DIR__ . '/fixtures/non-existent-composer.json'; + + expect(fn () => KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath)) + ->toThrow(SkippedWithMessageException::class); +}); + +it('asserts skeleton suggest block contains all known drivers with matching descriptions', function (): void { + $knownDriversPath = __DIR__ . '/fixtures/known-drivers.php'; + $skeletonComposerPath = __DIR__ . '/fixtures/skeleton-with-suggest-composer.json'; + + expect(fn () => KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath)) + ->not->toThrow(Throwable::class); +}); + +it('reads driver list from known-drivers.php file', function (): void { + $knownDriversPath = __DIR__ . '/fixtures/known-drivers.php'; + + // Should not throw — verifies the file is read successfully + expect(fn () => KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath)) + ->not->toThrow(Throwable::class); +}); diff --git a/packages/testing/tests/KnownDrivers/fixtures/known-drivers-invalid-prefix.php b/packages/testing/tests/KnownDrivers/fixtures/known-drivers-invalid-prefix.php new file mode 100644 index 00000000..b9b239f6 --- /dev/null +++ b/packages/testing/tests/KnownDrivers/fixtures/known-drivers-invalid-prefix.php @@ -0,0 +1,8 @@ + 'Redis cache driver', + 'vendor/invalid-package' => 'This entry does not follow the marko/* pattern', +]; diff --git a/packages/testing/tests/KnownDrivers/fixtures/known-drivers.php b/packages/testing/tests/KnownDrivers/fixtures/known-drivers.php new file mode 100644 index 00000000..e77f19f0 --- /dev/null +++ b/packages/testing/tests/KnownDrivers/fixtures/known-drivers.php @@ -0,0 +1,8 @@ + 'Redis cache driver', + 'marko/cache-memcached' => 'Memcached cache driver', +]; diff --git a/packages/testing/tests/KnownDrivers/fixtures/skeleton-missing-driver-composer.json b/packages/testing/tests/KnownDrivers/fixtures/skeleton-missing-driver-composer.json new file mode 100644 index 00000000..1093c740 --- /dev/null +++ b/packages/testing/tests/KnownDrivers/fixtures/skeleton-missing-driver-composer.json @@ -0,0 +1,7 @@ +{ + "name": "marko/skeleton", + "require": {}, + "suggest": { + "marko/cache-redis": "Redis cache driver" + } +} diff --git a/packages/testing/tests/KnownDrivers/fixtures/skeleton-with-extra-suggest-composer.json b/packages/testing/tests/KnownDrivers/fixtures/skeleton-with-extra-suggest-composer.json new file mode 100644 index 00000000..6aec5d18 --- /dev/null +++ b/packages/testing/tests/KnownDrivers/fixtures/skeleton-with-extra-suggest-composer.json @@ -0,0 +1,9 @@ +{ + "name": "marko/skeleton", + "require": {}, + "suggest": { + "marko/cache-redis": "Redis cache driver", + "marko/cache-memcached": "Memcached cache driver", + "marko/some-addon": "An extra add-on not in known-drivers.php" + } +} diff --git a/packages/testing/tests/KnownDrivers/fixtures/skeleton-with-suggest-composer.json b/packages/testing/tests/KnownDrivers/fixtures/skeleton-with-suggest-composer.json new file mode 100644 index 00000000..ac6873d6 --- /dev/null +++ b/packages/testing/tests/KnownDrivers/fixtures/skeleton-with-suggest-composer.json @@ -0,0 +1,8 @@ +{ + "name": "marko/skeleton", + "require": {}, + "suggest": { + "marko/cache-redis": "Redis cache driver", + "marko/cache-memcached": "Memcached cache driver" + } +} diff --git a/packages/testing/tests/KnownDrivers/fixtures/skeleton-without-suggest-composer.json b/packages/testing/tests/KnownDrivers/fixtures/skeleton-without-suggest-composer.json new file mode 100644 index 00000000..8b1dcc1e --- /dev/null +++ b/packages/testing/tests/KnownDrivers/fixtures/skeleton-without-suggest-composer.json @@ -0,0 +1,4 @@ +{ + "name": "marko/skeleton", + "require": {} +} diff --git a/packages/testing/tests/KnownDrivers/fixtures/skeleton-wrong-description-composer.json b/packages/testing/tests/KnownDrivers/fixtures/skeleton-wrong-description-composer.json new file mode 100644 index 00000000..d7471e06 --- /dev/null +++ b/packages/testing/tests/KnownDrivers/fixtures/skeleton-wrong-description-composer.json @@ -0,0 +1,8 @@ +{ + "name": "marko/skeleton", + "require": {}, + "suggest": { + "marko/cache-redis": "Wrong description for redis", + "marko/cache-memcached": "Memcached cache driver" + } +} diff --git a/packages/translation/known-drivers.php b/packages/translation/known-drivers.php new file mode 100644 index 00000000..ad5b7a97 --- /dev/null +++ b/packages/translation/known-drivers.php @@ -0,0 +1,7 @@ + 'File-based translation driver (PHP array files per locale)', +]; diff --git a/packages/translation/src/Exceptions/NoDriverException.php b/packages/translation/src/Exceptions/NoDriverException.php index 899166c6..63de533b 100644 --- a/packages/translation/src/Exceptions/NoDriverException.php +++ b/packages/translation/src/Exceptions/NoDriverException.php @@ -6,21 +6,38 @@ class NoDriverException extends TranslationException { - private const array DRIVER_PACKAGES = [ - 'marko/translation-file', - ]; - public static function noDriverInstalled(): self { - $packageList = implode("\n", array_map( - fn (string $pkg) => "- `composer require $pkg`", - self::DRIVER_PACKAGES, - )); + $drivers = require __DIR__ . '/../../known-drivers.php'; + $packageList = self::formatDriverList($drivers); return new self( message: 'No translation driver installed.', context: 'Attempted to resolve a translation interface but no implementation is bound.', - suggestion: "Install a translation driver:\n$packageList", + suggestion: "Install one of these drivers:\n$packageList", ); } + + /** + * @param array $drivers + */ + private static function formatDriverList(array $drivers): string + { + $lines = []; + foreach ($drivers as $package => $description) { + $docsUrl = self::docsUrl($package); + $lines[] = "- $package: $description"; + $lines[] = " Install: composer require $package"; + $lines[] = " Docs: $docsUrl"; + } + + return implode("\n", $lines); + } + + private static function docsUrl(string $package): string + { + $basename = substr($package, strlen('marko/')); + + return "https://marko.build/docs/packages/$basename/"; + } } diff --git a/packages/translation/tests/Exceptions/NoDriverExceptionTest.php b/packages/translation/tests/Exceptions/NoDriverExceptionTest.php index 2902a422..9f7dcce1 100644 --- a/packages/translation/tests/Exceptions/NoDriverExceptionTest.php +++ b/packages/translation/tests/Exceptions/NoDriverExceptionTest.php @@ -5,28 +5,73 @@ use Marko\Translation\Exceptions\NoDriverException; use Marko\Translation\Exceptions\TranslationException; -it('has DRIVER_PACKAGES constant listing marko/translation-file', function (): void { - $reflection = new ReflectionClass(NoDriverException::class); - $constant = $reflection->getReflectionConstant('DRIVER_PACKAGES'); +describe('NoDriverException', function (): void { + it('loads the driver list from known-drivers.php', function (): void { + $knownDrivers = require __DIR__ . '/../../known-drivers.php'; + $exception = NoDriverException::noDriverInstalled(); - expect($constant)->not->toBeFalse() - ->and($constant->getValue())->toContain('marko/translation-file'); -}); + foreach (array_keys($knownDrivers) as $package) { + expect($exception->getSuggestion())->toContain($package); + } + }); -it('provides suggestion with composer require command', function (): void { - $exception = NoDriverException::noDriverInstalled(); + it('includes the description for each driver in the suggestion', function (): void { + $knownDrivers = require __DIR__ . '/../../known-drivers.php'; + $exception = NoDriverException::noDriverInstalled(); - expect($exception->getSuggestion())->toContain('composer require marko/translation-file'); -}); + foreach ($knownDrivers as $package => $description) { + expect($exception->getSuggestion())->toContain($description); + } + }); -it('includes context about resolving translation interfaces', function (): void { - $exception = NoDriverException::noDriverInstalled(); + it('includes a composer require command for each driver', function (): void { + $knownDrivers = require __DIR__ . '/../../known-drivers.php'; + $exception = NoDriverException::noDriverInstalled(); - expect($exception->getContext())->toContain('Attempted to resolve a translation interface but no implementation is bound.'); -}); + foreach (array_keys($knownDrivers) as $package) { + expect($exception->getSuggestion())->toContain("composer require $package"); + } + }); + + it('includes a derived docs URL for each driver', function (): void { + $knownDrivers = require __DIR__ . '/../../known-drivers.php'; + $exception = NoDriverException::noDriverInstalled(); + + foreach (array_keys($knownDrivers) as $package) { + $basename = substr($package, strlen('marko/')); + expect($exception->getSuggestion())->toContain("https://marko.build/docs/packages/$basename/"); + } + }); + + it('translation NoDriverException reads from known-drivers.php and includes docs URL', function (): void { + $exception = NoDriverException::noDriverInstalled(); + + expect($exception->getSuggestion())->toContain('marko/translation-file') + ->and($exception->getSuggestion())->toContain('https://marko.build/docs/packages/translation-file/'); + }); + + it('no longer exposes a DRIVER_PACKAGES const', function (): void { + $reflection = new ReflectionClass(NoDriverException::class); + $constant = $reflection->getReflectionConstant('DRIVER_PACKAGES'); + + expect($constant)->toBeFalse(); + }); + + it('provides suggestion with composer require command for translation-file', function (): void { + $exception = NoDriverException::noDriverInstalled(); + + expect($exception->getSuggestion())->toContain('composer require marko/translation-file'); + }); + + it('includes context about resolving translation interfaces', function (): void { + $exception = NoDriverException::noDriverInstalled(); + + expect($exception->getContext())->toContain('Attempted to resolve a translation interface but no implementation is bound.'); + }); -it('extends TranslationException', function (): void { - $exception = NoDriverException::noDriverInstalled(); + it('extends TranslationException', function (): void { + $exception = NoDriverException::noDriverInstalled(); - expect($exception)->toBeInstanceOf(TranslationException::class); + expect($exception)->toBeInstanceOf(TranslationException::class); + }); }); diff --git a/packages/translation/tests/KnownDriversValidationTest.php b/packages/translation/tests/KnownDriversValidationTest.php new file mode 100644 index 00000000..e964ea90 --- /dev/null +++ b/packages/translation/tests/KnownDriversValidationTest.php @@ -0,0 +1,11 @@ + KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath)); +test('every translation driver follows marko slash prefix pattern', fn () => KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath)); diff --git a/packages/translation/tests/PackageStructureTest.php b/packages/translation/tests/PackageStructureTest.php index 9dd303c0..df823965 100644 --- a/packages/translation/tests/PackageStructureTest.php +++ b/packages/translation/tests/PackageStructureTest.php @@ -65,3 +65,15 @@ it('has config directory for default configuration', function () { expect(is_dir(dirname(__DIR__) . '/config'))->toBeTrue(); }); + +it('ships a known-drivers.php file listing marko/translation-file', function (): void { + $knownDriversPath = dirname(__DIR__) . '/known-drivers.php'; + + expect(file_exists($knownDriversPath))->toBeTrue(); + + $drivers = require $knownDriversPath; + + expect($drivers)->toBeArray() + ->and($drivers)->toHaveKey('marko/translation-file') + ->and($drivers['marko/translation-file'])->toBe('File-based translation driver (PHP array files per locale)'); +}); diff --git a/packages/view/tests/Feature/IntegrationTest.php b/packages/view-latte/tests/Feature/IntegrationTest.php similarity index 100% rename from packages/view/tests/Feature/IntegrationTest.php rename to packages/view-latte/tests/Feature/IntegrationTest.php diff --git a/packages/view/known-drivers.php b/packages/view/known-drivers.php new file mode 100644 index 00000000..711db66f --- /dev/null +++ b/packages/view/known-drivers.php @@ -0,0 +1,8 @@ + 'Twig template engine driver (recommended for broader ecosystem familiarity)', + 'marko/view-latte' => 'Latte template engine driver (compile-time safety, n:attribute syntax)', +]; diff --git a/packages/view/src/Exceptions/NoDriverException.php b/packages/view/src/Exceptions/NoDriverException.php index e3ef45b3..5078c14b 100644 --- a/packages/view/src/Exceptions/NoDriverException.php +++ b/packages/view/src/Exceptions/NoDriverException.php @@ -6,17 +6,10 @@ class NoDriverException extends ViewException { - private const array DRIVER_PACKAGES = [ - 'marko/view-latte', - 'marko/view-twig', - ]; - public static function noDriverInstalled(): self { - $packageList = implode("\n", array_map( - fn (string $pkg) => "- `composer require $pkg`", - self::DRIVER_PACKAGES, - )); + $drivers = require __DIR__ . '/../../known-drivers.php'; + $packageList = self::formatDriverList($drivers); return new self( message: 'No view driver installed.', @@ -24,4 +17,27 @@ public static function noDriverInstalled(): self suggestion: "Install a view driver:\n$packageList", ); } + + /** + * @param array $drivers + */ + private static function formatDriverList(array $drivers): string + { + $lines = []; + foreach ($drivers as $package => $description) { + $docsUrl = self::docsUrl($package); + $lines[] = "- $package: $description"; + $lines[] = " Install: composer require $package"; + $lines[] = " Docs: $docsUrl"; + } + + return implode("\n", $lines); + } + + private static function docsUrl(string $package): string + { + $basename = substr($package, strlen('marko/')); + + return "https://marko.build/docs/packages/$basename/"; + } } diff --git a/packages/view/tests/Exceptions/NoDriverExceptionTest.php b/packages/view/tests/Exceptions/NoDriverExceptionTest.php index 89e8e4a4..4508bce4 100644 --- a/packages/view/tests/Exceptions/NoDriverExceptionTest.php +++ b/packages/view/tests/Exceptions/NoDriverExceptionTest.php @@ -5,59 +5,84 @@ use Marko\View\Exceptions\NoDriverException; use Marko\View\Exceptions\ViewException; -it('has DRIVER_PACKAGES constant listing marko/view-latte', function (): void { - $reflection = new ReflectionClass(NoDriverException::class); - $constant = $reflection->getReflectionConstant('DRIVER_PACKAGES'); +describe('NoDriverException', function (): void { + it('loads the driver list from known-drivers.php', function (): void { + $knownDrivers = require __DIR__ . '/../../known-drivers.php'; + $exception = NoDriverException::noDriverInstalled(); - expect($constant)->not->toBeFalse() - ->and($constant->getValue())->toContain('marko/view-latte'); -}); + foreach (array_keys($knownDrivers) as $package) { + expect($exception->getSuggestion())->toContain($package); + } + }); -it('provides suggestion with composer require commands for all driver packages', function (): void { - $exception = NoDriverException::noDriverInstalled(); + it('includes the description for each driver in the suggestion', function (): void { + $knownDrivers = require __DIR__ . '/../../known-drivers.php'; + $exception = NoDriverException::noDriverInstalled(); - expect($exception->getSuggestion())->toContain('composer require marko/view-latte'); -}); + foreach ($knownDrivers as $package => $description) { + expect($exception->getSuggestion())->toContain($description); + } + }); -it('includes context about resolving ViewInterface', function (): void { - $exception = NoDriverException::noDriverInstalled(); + it('includes a composer require command for each driver', function (): void { + $knownDrivers = require __DIR__ . '/../../known-drivers.php'; + $exception = NoDriverException::noDriverInstalled(); - expect($exception->getContext())->toContain('Attempted to resolve ViewInterface but no implementation is bound.'); -}); + foreach (array_keys($knownDrivers) as $package) { + expect($exception->getSuggestion())->toContain("composer require $package"); + } + }); -it('extends ViewException', function (): void { - $exception = NoDriverException::noDriverInstalled(); + it('includes a derived docs URL for each driver', function (): void { + $knownDrivers = require __DIR__ . '/../../known-drivers.php'; + $exception = NoDriverException::noDriverInstalled(); - expect($exception)->toBeInstanceOf(ViewException::class); -}); + foreach (array_keys($knownDrivers) as $package) { + $basename = substr($package, strlen('marko/')); + expect($exception->getSuggestion())->toContain("https://marko.build/docs/packages/$basename/"); + } + }); -it('lists marko/view-latte as an installable driver', function (): void { - $reflection = new ReflectionClass(NoDriverException::class); - $constant = $reflection->getReflectionConstant('DRIVER_PACKAGES'); + it('derives docs URLs from the package basename (marko slash prefix stripped)', function (): void { + $exception = NoDriverException::noDriverInstalled(); - expect($constant->getValue())->toContain('marko/view-latte'); -}); + expect($exception->getSuggestion())->toContain('https://marko.build/docs/packages/view-twig/') + ->and($exception->getSuggestion())->toContain('https://marko.build/docs/packages/view-latte/'); + }); -it('lists marko/view-twig as an installable driver', function (): void { - $reflection = new ReflectionClass(NoDriverException::class); - $constant = $reflection->getReflectionConstant('DRIVER_PACKAGES'); + it('lists view-twig first in the suggestion (matching known-drivers.php order)', function (): void { + $exception = NoDriverException::noDriverInstalled(); + $suggestion = $exception->getSuggestion(); - expect($constant->getValue())->toContain('marko/view-twig'); -}); + $twigPos = strpos($suggestion, 'marko/view-twig'); + $lattePos = strpos($suggestion, 'marko/view-latte'); -it('formats each driver as a composer require command in the suggestion', function (): void { - $exception = NoDriverException::noDriverInstalled(); + expect($twigPos)->toBeLessThan($lattePos); + }); - expect($exception->getSuggestion()) - ->toContain('composer require marko/view-latte') - ->and($exception->getSuggestion())->toContain('composer require marko/view-twig'); -}); + it('no longer exposes a DRIVER_PACKAGES const', function (): void { + $reflection = new ReflectionClass(NoDriverException::class); + $constant = $reflection->getReflectionConstant('DRIVER_PACKAGES'); + + expect($constant)->toBeFalse(); + }); + + it('provides suggestion with composer require commands for all driver packages', function (): void { + $exception = NoDriverException::noDriverInstalled(); + + expect($exception->getSuggestion())->toContain('composer require marko/view-twig') + ->and($exception->getSuggestion())->toContain('composer require marko/view-latte'); + }); + + it('includes context about resolving ViewInterface', function (): void { + $exception = NoDriverException::noDriverInstalled(); + + expect($exception->getContext())->toContain('Attempted to resolve ViewInterface but no implementation is bound.'); + }); -it('keeps DRIVER_PACKAGES alphabetically ordered', function (): void { - $reflection = new ReflectionClass(NoDriverException::class); - $packages = $reflection->getReflectionConstant('DRIVER_PACKAGES')->getValue(); - $sorted = $packages; - sort($sorted); + it('extends ViewException', function (): void { + $exception = NoDriverException::noDriverInstalled(); - expect($packages)->toBe($sorted); + expect($exception)->toBeInstanceOf(ViewException::class); + }); }); diff --git a/packages/view/tests/KnownDriversTest.php b/packages/view/tests/KnownDriversTest.php new file mode 100644 index 00000000..bc5c48a3 --- /dev/null +++ b/packages/view/tests/KnownDriversTest.php @@ -0,0 +1,22 @@ +toBeTrue(); + + $drivers = require $knownDriversPath; + + expect($drivers)->toBeArray() + ->and($drivers)->toHaveKey('marko/view-twig') + ->and($drivers)->toHaveKey('marko/view-latte'); +}); + +it('lists marko/view-twig first as the recommended driver', function () use ($knownDriversPath): void { + $drivers = require $knownDriversPath; + $keys = array_keys($drivers); + + expect($keys[0])->toBe('marko/view-twig'); +}); diff --git a/packages/view/tests/KnownDriversValidationTest.php b/packages/view/tests/KnownDriversValidationTest.php new file mode 100644 index 00000000..dc00cc4f --- /dev/null +++ b/packages/view/tests/KnownDriversValidationTest.php @@ -0,0 +1,16 @@ +toBeTrue() @@ -11,10 +12,29 @@ ->and(json_decode(file_get_contents($composerPath), true)['autoload']['psr-4']['Marko\\View\\'])->toBe('src/'); }); -it('composer.json has marko/core dependency', function () { +it('composer.json has marko/core dependency', function (): void { $composerPath = dirname(__DIR__) . '/composer.json'; $composer = json_decode(file_get_contents($composerPath), true); expect($composer)->toHaveKey('require') ->and($composer['require'])->toHaveKey('marko/core'); }); + +it('marko/view test suite contains no imports of Marko\\View\\Latte namespace', function (): void { + $testsDir = __DIR__; + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($testsDir)); + $phpFiles = new RegexIterator($iterator, '/\.php$/'); + + // Build the pattern indirectly so this file does not match itself + $forbiddenNamespace = implode('\\', ['Marko', 'View', 'Latte']); + + $violations = []; + foreach ($phpFiles as $file) { + $contents = file_get_contents($file->getPathname()); + if (str_contains($contents, $forbiddenNamespace)) { + $violations[] = $file->getPathname(); + } + } + + expect($violations)->toBeEmpty('Found ' . $forbiddenNamespace . ' imports in: ' . implode(', ', $violations)); +}); From 9f79d05ac1d711134cc42a9c3991ae5928b90ef7 Mon Sep 17 00:00:00 2001 From: Mark Shust Date: Wed, 27 May 2026 11:36:18 -0400 Subject: [PATCH 5/5] refactor(known-drivers): normalize description format across all known-drivers.php files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enforce a single canonical shape for driver descriptions: - '{Tech} {category} driver ({parenthetical})' - Category noun matches family name (mail/log not mailer/logger; errors/media not error handler/image processor; view not template engine driver) - Recommended marker uses '(recommended; {justification})' — never '(recommended for X)' which read ambiguously - At most one recommended driver per family (was previously multiple) - All descriptive detail inside parens (no prose before/after the parenthetical) - No em-dashes within driver descriptions Document the convention at docs/concepts/known-drivers.md with format rules, recommendation policy, punctuation, and the three category exceptions (HTTP client, Inertia.js frontend, page cache). Update skeleton composer.json suggest block to match new strings verbatim so per-package KnownDriversValidationTest assertions pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../content/docs/concepts/known-drivers.md | 137 ++++++++++++++++++ docs/src/content/docs/packages/testing.md | 2 +- packages/cache/known-drivers.php | 6 +- packages/database/known-drivers.php | 4 +- packages/encryption/known-drivers.php | 2 +- .../encryption/tests/PackageStructureTest.php | 2 +- packages/errors/known-drivers.php | 4 +- packages/filesystem/known-drivers.php | 4 +- packages/inertia/known-drivers.php | 6 +- packages/log/known-drivers.php | 2 +- packages/log/tests/PackageStructureTest.php | 2 +- packages/mail/known-drivers.php | 4 +- packages/media/known-drivers.php | 4 +- packages/pubsub/known-drivers.php | 2 +- packages/queue/known-drivers.php | 4 +- packages/session/known-drivers.php | 4 +- packages/skeleton/composer.json | 50 +++---- packages/view/known-drivers.php | 4 +- 18 files changed, 190 insertions(+), 53 deletions(-) create mode 100644 docs/src/content/docs/concepts/known-drivers.md diff --git a/docs/src/content/docs/concepts/known-drivers.md b/docs/src/content/docs/concepts/known-drivers.md new file mode 100644 index 00000000..69721239 --- /dev/null +++ b/docs/src/content/docs/concepts/known-drivers.md @@ -0,0 +1,137 @@ +--- +title: Known Drivers +description: How Marko interface packages declare their official drivers via known-drivers.php — and the conventions that keep descriptions consistent. +--- + +Most Marko interface packages (`marko/cache`, `marko/database`, `marko/queue`, etc.) define an abstraction without an implementation. The implementation lives in a separate sibling driver package (`marko/cache-redis`, `marko/database-pgsql`, `marko/queue-rabbitmq`). For a given interface package, multiple driver siblings may exist, but only one can be bound at a time. + +Each interface package ships a `known-drivers.php` file at its package root that lists every official driver for that interface. This file is the curated source of truth: it tells `NoDriverException` what to suggest when no driver is bound, and it feeds skeleton's `composer.json` `suggest` block so `composer create-project marko/skeleton` surfaces driver options at install time. + +## File Format + +`known-drivers.php` returns a flat associative array of package name → description string: + +```php title="packages/cache/known-drivers.php" + 'File-based cache driver (recommended; no infrastructure, single-server apps)', + 'marko/cache-redis' => 'Redis cache driver (distributed deployments and high-throughput)', + 'marko/cache-array' => 'In-memory cache driver (request-lifetime only; testing and dev)', +]; +``` + +**Ordering matters.** The recommended driver (if any) comes first. `NoDriverException` lists drivers in this order; `composer create-project` displays suggestions in this order. + +**Only drivers belong here.** Optional add-ons like `marko/database-readwrite` (a decorator) or `marko/page-cache-entity` (an observer add-on) are NOT drivers — they coexist with a base driver rather than replacing it. Add-ons go into skeleton's `suggest` block directly, not into any `known-drivers.php`. + +## Description Conventions + +Driver descriptions follow a strict format so they read consistently across all 18+ interface packages, both in `NoDriverException` output and in skeleton's `suggest` block. + +### Canonical Shape + +``` +{Tech} {category} driver ({parenthetical}) +``` + +- **`{Tech}`** — the concrete tech name (`Redis`, `PostgreSQL`, `Twig`) OR a descriptive `{Adjective}-based` form (`File-based`, `Token-based`, `OpenSSL-based`) when there's no single product name. +- **`{category}`** — the interface package's family name, used as a noun (`cache`, `database`, `view`, `pub/sub`). +- **`driver`** — always present. Every entry ends with the word "driver". +- **`{parenthetical}`** — optional; one short phrase, all detail inside the parens. + +### The `recommended` Marker + +At most **one driver per family** can be marked recommended. Use it for the driver that answers "which one should I pick when in doubt?" If every option has a use case, none of them is the default — describe each one's use case instead. + +``` +'Twig view driver (recommended; broader ecosystem familiarity)' +'Latte view driver (compile-time safety, n:attribute syntax)' +``` + +The recommended driver's parenthetical follows the shape `(recommended; {justification})`. The semicolon separates the marker from the reason. Don't use `(recommended for X)` — `for` reads ambiguously (sometimes "use in" a scenario, sometimes "because of" a feature). + +### Non-Recommended Parentheticals + +For non-recommended drivers, describe the driver's nature in one short phrase. Avoid scenario-based "use this if X" framing — those belong in the package docs, not a one-liner. + +``` +'Redis cache driver (distributed deployments and high-throughput)' +'MySQL/MariaDB database driver' +``` + +When the parenthetical genuinely combines two independent facts, use a semicolon: + +``` +'In-memory cache driver (request-lifetime only; testing and dev)' +'ImageMagick media driver (higher fidelity; requires ext-imagick)' +``` + +### Single-Driver Families + +When an interface has only one official driver, no `(recommended)` marker is needed — there's nothing to recommend against. The parenthetical is optional, used only when meaningful tech detail clarifies the driver. + +``` +'Database-backed notification driver' +'Token-based authentication driver (signed token sessions)' +``` + +### Punctuation Rules + +- **Em-dash (`—`):** not used in driver descriptions. (Skeleton's add-on descriptions may use one for the "optional add-on" qualifier, since add-ons don't follow the driver convention.) +- **Semicolon (`;`):** separates the `(recommended)` marker from its justification, or separates two independent facts in a non-recommended parenthetical. +- **Comma (`,`):** lists items inside a single fact. + +### Documented Category Exceptions + +Three families use slightly extended category nouns because the bare family name wouldn't be informative enough: + +| Family | Category noun used | Why | +|---|---|---| +| `http` | `HTTP client` | "HTTP" alone wouldn't read as a noun | +| `inertia` | `Inertia.js frontend` | Inertia drivers are frontend frameworks, not "Inertia drivers" of various kinds | +| `page-cache` | `page cache` | two-word family name, kept readable with a space | + +## Why a Curated Registry? + +Marko could have mechanically derived "which packages are drivers" from on-disk introspection — looking for any package whose `module.php` binds a particular interface. We considered that and chose curation instead, for three reasons: + +1. **Third-party drivers exist.** Anyone can publish a `marko/cache-*` package. The official `known-drivers.php` lists the drivers the interface maintainer supports. It's the curated answer to "which driver should I pick?", not the exhaustive answer to "what driver packages exist on Packagist?" +2. **Recommendations need a maintainer's voice.** Mechanical derivation can't tell you that `cache-file` is the sensible default and `cache-redis` is the opt-in for distributed deployments. That judgment belongs in `known-drivers.php`. +3. **CI can mechanically enforce sync.** The [`KnownDriversValidator`](/packages/testing/#knowndriversvalidator) helper compares each interface's `known-drivers.php` against skeleton's `suggest` block. Drift fails the build. + +## NoDriverException Integration + +Every interface package's `NoDriverException` reads its driver list from `known-drivers.php` at exception-construction time. When no driver is bound, the suggestion text lists every known driver with its description, the `composer require` command, and a docs URL derived from the package basename: + +``` +Install one of these drivers: +- marko/cache-file: File-based cache driver (recommended; no infrastructure, single-server apps) + Install: composer require marko/cache-file + Docs: https://marko.build/docs/packages/cache-file/ +- marko/cache-redis: Redis cache driver (distributed deployments and high-throughput) + Install: composer require marko/cache-redis + Docs: https://marko.build/docs/packages/cache-redis/ +- marko/cache-array: In-memory cache driver (request-lifetime only; testing and dev) + Install: composer require marko/cache-array + Docs: https://marko.build/docs/packages/cache-array/ +``` + +The description string is taken verbatim from `known-drivers.php`. This is why description-string consistency matters — every divergence shows up in user-facing error output. + +## Adding a New Driver + +Maintainers of an interface package add a new driver by appending an entry to that package's `known-drivers.php`: + +```php +return [ + // existing entries... + 'marko/cache-memcached' => 'Memcached cache driver (legacy clusters, broad client support)', +]; +``` + +The per-package `KnownDriversValidationTest` will then fail until the matching entry is added to skeleton's `composer.json` `suggest` block with the same description string. That's the intended workflow — the CI test guarantees the two stay in sync. + +When picking the description string, follow the conventions above. If you're unsure, look at the existing entries in [`packages/cache/known-drivers.php`](https://github.com/marko-php/marko/blob/develop/packages/cache/known-drivers.php) and [`packages/queue/known-drivers.php`](https://github.com/marko-php/marko/blob/develop/packages/queue/known-drivers.php) for representative shapes. diff --git a/docs/src/content/docs/packages/testing.md b/docs/src/content/docs/packages/testing.md index 3fa0ae7b..8988130e 100644 --- a/docs/src/content/docs/packages/testing.md +++ b/docs/src/content/docs/packages/testing.md @@ -173,7 +173,7 @@ expect($provider->lastRememberTokenUpdate['token'])->toBe('new-token'); ### KnownDriversValidator -`KnownDriversValidator` is a static utility for package authors to assert that a package's `known-drivers.php` file is well-formed and stays in sync with the skeleton's `suggest` block. +`KnownDriversValidator` is a static utility for package authors to assert that a package's `known-drivers.php` file is well-formed and stays in sync with the skeleton's `suggest` block. See [Known Drivers](/concepts/known-drivers/) for the file format and description-string conventions. ```php use Marko\Testing\KnownDrivers\KnownDriversValidator; diff --git a/packages/cache/known-drivers.php b/packages/cache/known-drivers.php index 66f59b31..07f2c693 100644 --- a/packages/cache/known-drivers.php +++ b/packages/cache/known-drivers.php @@ -3,7 +3,7 @@ declare(strict_types=1); return [ - 'marko/cache-file' => 'File-based cache driver (recommended for single-server apps)', - 'marko/cache-redis' => 'Redis cache driver (recommended for distributed deployments and high-throughput apps)', - 'marko/cache-array' => 'In-memory cache driver (request-lifetime only; intended for testing and dev environments)', + 'marko/cache-file' => 'File-based cache driver (recommended; no infrastructure, single-server apps)', + 'marko/cache-redis' => 'Redis cache driver (distributed deployments and high-throughput)', + 'marko/cache-array' => 'In-memory cache driver (request-lifetime only; testing and dev)', ]; diff --git a/packages/database/known-drivers.php b/packages/database/known-drivers.php index 87fbf22e..7a5d9748 100644 --- a/packages/database/known-drivers.php +++ b/packages/database/known-drivers.php @@ -3,6 +3,6 @@ declare(strict_types=1); return [ - 'marko/database-pgsql' => 'PostgreSQL driver (recommended for new projects — strong JSON, FTS, pgvector support)', - 'marko/database-mysql' => 'MySQL/MariaDB driver', + 'marko/database-pgsql' => 'PostgreSQL database driver (recommended; strong JSON, FTS, pgvector support)', + 'marko/database-mysql' => 'MySQL/MariaDB database driver', ]; diff --git a/packages/encryption/known-drivers.php b/packages/encryption/known-drivers.php index 6406adac..2fbd9c60 100644 --- a/packages/encryption/known-drivers.php +++ b/packages/encryption/known-drivers.php @@ -3,5 +3,5 @@ declare(strict_types=1); return [ - 'marko/encryption-openssl' => 'OpenSSL-based symmetric encryption driver (AES-256-GCM)', + 'marko/encryption-openssl' => 'OpenSSL-based encryption driver (AES-256-GCM)', ]; diff --git a/packages/encryption/tests/PackageStructureTest.php b/packages/encryption/tests/PackageStructureTest.php index 2d060a80..a67a2220 100644 --- a/packages/encryption/tests/PackageStructureTest.php +++ b/packages/encryption/tests/PackageStructureTest.php @@ -100,5 +100,5 @@ expect($drivers)->toBeArray() ->and($drivers)->toHaveKey('marko/encryption-openssl') - ->and($drivers['marko/encryption-openssl'])->toBe('OpenSSL-based symmetric encryption driver (AES-256-GCM)'); + ->and($drivers['marko/encryption-openssl'])->toBe('OpenSSL-based encryption driver (AES-256-GCM)'); }); diff --git a/packages/errors/known-drivers.php b/packages/errors/known-drivers.php index dfd904ee..56b0d24a 100644 --- a/packages/errors/known-drivers.php +++ b/packages/errors/known-drivers.php @@ -3,6 +3,6 @@ declare(strict_types=1); return [ - 'marko/errors-simple' => 'Simple error handler (recommended for production — minimal information disclosure)', - 'marko/errors-advanced' => 'Advanced error handler with pretty stack traces and suggestions (recommended for development)', + 'marko/errors-simple' => 'Simple errors driver (recommended; minimal information disclosure, production-safe)', + 'marko/errors-advanced' => 'Advanced errors driver (pretty stack traces and suggestions; for development)', ]; diff --git a/packages/filesystem/known-drivers.php b/packages/filesystem/known-drivers.php index ca92c61a..687ca5a4 100644 --- a/packages/filesystem/known-drivers.php +++ b/packages/filesystem/known-drivers.php @@ -3,6 +3,6 @@ declare(strict_types=1); return [ - 'marko/filesystem-local' => 'Local disk filesystem driver (recommended default; zero infrastructure required)', - 'marko/filesystem-s3' => 'Amazon S3 filesystem driver (for cloud and distributed deployments)', + 'marko/filesystem-local' => 'Local disk filesystem driver (recommended; zero infrastructure)', + 'marko/filesystem-s3' => 'Amazon S3 filesystem driver (cloud and distributed deployments)', ]; diff --git a/packages/inertia/known-drivers.php b/packages/inertia/known-drivers.php index f48c2c18..d48f442e 100644 --- a/packages/inertia/known-drivers.php +++ b/packages/inertia/known-drivers.php @@ -3,7 +3,7 @@ declare(strict_types=1); return [ - 'marko/inertia-react' => 'React frontend driver for Inertia.js (recommended — largest community and tooling ecosystem)', - 'marko/inertia-svelte' => 'Svelte frontend driver for Inertia.js', - 'marko/inertia-vue' => 'Vue frontend driver for Inertia.js', + 'marko/inertia-react' => 'React Inertia.js frontend driver (recommended; largest community and tooling ecosystem)', + 'marko/inertia-svelte' => 'Svelte Inertia.js frontend driver', + 'marko/inertia-vue' => 'Vue Inertia.js frontend driver', ]; diff --git a/packages/log/known-drivers.php b/packages/log/known-drivers.php index 13a1cded..69cd1de0 100644 --- a/packages/log/known-drivers.php +++ b/packages/log/known-drivers.php @@ -3,5 +3,5 @@ declare(strict_types=1); return [ - 'marko/log-file' => 'File-based logger with rotation', + 'marko/log-file' => 'File-based log driver (with log rotation)', ]; diff --git a/packages/log/tests/PackageStructureTest.php b/packages/log/tests/PackageStructureTest.php index 89a73ed6..8d27423f 100644 --- a/packages/log/tests/PackageStructureTest.php +++ b/packages/log/tests/PackageStructureTest.php @@ -94,7 +94,7 @@ expect($drivers)->toBeArray() ->and($drivers)->toHaveKey('marko/log-file') - ->and($drivers['marko/log-file'])->toBe('File-based logger with rotation'); + ->and($drivers['marko/log-file'])->toBe('File-based log driver (with log rotation)'); }); it('has default log.php config file', function () { diff --git a/packages/mail/known-drivers.php b/packages/mail/known-drivers.php index 731fdda2..37ce7091 100644 --- a/packages/mail/known-drivers.php +++ b/packages/mail/known-drivers.php @@ -3,6 +3,6 @@ declare(strict_types=1); return [ - 'marko/mail-smtp' => 'SMTP mailer driver (recommended for production)', - 'marko/mail-log' => 'Log-only mailer driver (writes emails to LoggerInterface; intended for development and testing)', + 'marko/mail-smtp' => 'SMTP mail driver (recommended; for production)', + 'marko/mail-log' => 'Log-based mail driver (writes emails to LoggerInterface; for development and testing)', ]; diff --git a/packages/media/known-drivers.php b/packages/media/known-drivers.php index 0faf878f..6a7d79ca 100644 --- a/packages/media/known-drivers.php +++ b/packages/media/known-drivers.php @@ -3,6 +3,6 @@ declare(strict_types=1); return [ - 'marko/media-gd' => 'GD image processor (recommended — ships with most PHP installations)', - 'marko/media-imagick' => 'ImageMagick image processor (higher fidelity; requires ext-imagick and ImageMagick library installed)', + 'marko/media-gd' => 'GD media driver (recommended; built into most PHP installations)', + 'marko/media-imagick' => 'ImageMagick media driver (higher fidelity; requires ext-imagick)', ]; diff --git a/packages/pubsub/known-drivers.php b/packages/pubsub/known-drivers.php index f9c73007..0b0326a3 100644 --- a/packages/pubsub/known-drivers.php +++ b/packages/pubsub/known-drivers.php @@ -3,6 +3,6 @@ declare(strict_types=1); return [ - 'marko/pubsub-redis' => 'Redis pub/sub driver (recommended — purpose-built for messaging)', + 'marko/pubsub-redis' => 'Redis pub/sub driver (recommended; purpose-built for messaging)', 'marko/pubsub-pgsql' => 'PostgreSQL LISTEN/NOTIFY pub/sub driver (no additional infrastructure if you already use Postgres)', ]; diff --git a/packages/queue/known-drivers.php b/packages/queue/known-drivers.php index 4b5c0664..87313449 100644 --- a/packages/queue/known-drivers.php +++ b/packages/queue/known-drivers.php @@ -3,7 +3,7 @@ declare(strict_types=1); return [ - 'marko/queue-sync' => 'Synchronous queue driver (recommended for development — runs jobs inline, no infrastructure)', + 'marko/queue-sync' => 'Synchronous queue driver (recommended; runs jobs inline, no infrastructure)', 'marko/queue-database' => 'Database-backed queue driver (production-ready; uses your existing database)', - 'marko/queue-rabbitmq' => 'RabbitMQ queue driver (recommended for high-throughput production deployments)', + 'marko/queue-rabbitmq' => 'RabbitMQ queue driver (high-throughput production deployments)', ]; diff --git a/packages/session/known-drivers.php b/packages/session/known-drivers.php index c5c98ed9..a0c8403f 100644 --- a/packages/session/known-drivers.php +++ b/packages/session/known-drivers.php @@ -3,6 +3,6 @@ declare(strict_types=1); return [ - 'marko/session-file' => 'File-based session driver (recommended default for single-server apps)', - 'marko/session-database' => 'Database-backed session driver (recommended for distributed deployments and queryable session data)', + 'marko/session-file' => 'File-based session driver (recommended; single-server apps)', + 'marko/session-database' => 'Database-backed session driver (distributed deployments and queryable session data)', ]; diff --git a/packages/skeleton/composer.json b/packages/skeleton/composer.json index 0fc1006e..85949f86 100644 --- a/packages/skeleton/composer.json +++ b/packages/skeleton/composer.json @@ -13,35 +13,35 @@ "pestphp/pest": "^4.0" }, "suggest": { - "marko/database-pgsql": "PostgreSQL driver (recommended for new projects — strong JSON, FTS, pgvector support)", - "marko/database-mysql": "MySQL/MariaDB driver", - "marko/filesystem-local": "Local disk filesystem driver (recommended default; zero infrastructure required)", - "marko/filesystem-s3": "Amazon S3 filesystem driver (for cloud and distributed deployments)", - "marko/errors-simple": "Simple error handler (recommended for production — minimal information disclosure)", - "marko/errors-advanced": "Advanced error handler with pretty stack traces and suggestions (recommended for development)", - "marko/view-twig": "Twig template engine driver (recommended for broader ecosystem familiarity)", - "marko/view-latte": "Latte template engine driver (compile-time safety, n:attribute syntax)", - "marko/cache-file": "File-based cache driver (recommended for single-server apps)", - "marko/cache-redis": "Redis cache driver (recommended for distributed deployments and high-throughput apps)", - "marko/cache-array": "In-memory cache driver (request-lifetime only; intended for testing and dev environments)", - "marko/inertia-react": "React frontend driver for Inertia.js (recommended — largest community and tooling ecosystem)", - "marko/inertia-svelte": "Svelte frontend driver for Inertia.js", - "marko/inertia-vue": "Vue frontend driver for Inertia.js", - "marko/queue-sync": "Synchronous queue driver (recommended for development — runs jobs inline, no infrastructure)", + "marko/database-pgsql": "PostgreSQL database driver (recommended; strong JSON, FTS, pgvector support)", + "marko/database-mysql": "MySQL/MariaDB database driver", + "marko/filesystem-local": "Local disk filesystem driver (recommended; zero infrastructure)", + "marko/filesystem-s3": "Amazon S3 filesystem driver (cloud and distributed deployments)", + "marko/errors-simple": "Simple errors driver (recommended; minimal information disclosure, production-safe)", + "marko/errors-advanced": "Advanced errors driver (pretty stack traces and suggestions; for development)", + "marko/view-twig": "Twig view driver (recommended; broader ecosystem familiarity)", + "marko/view-latte": "Latte view driver (compile-time safety, n:attribute syntax)", + "marko/cache-file": "File-based cache driver (recommended; no infrastructure, single-server apps)", + "marko/cache-redis": "Redis cache driver (distributed deployments and high-throughput)", + "marko/cache-array": "In-memory cache driver (request-lifetime only; testing and dev)", + "marko/inertia-react": "React Inertia.js frontend driver (recommended; largest community and tooling ecosystem)", + "marko/inertia-svelte": "Svelte Inertia.js frontend driver", + "marko/inertia-vue": "Vue Inertia.js frontend driver", + "marko/queue-sync": "Synchronous queue driver (recommended; runs jobs inline, no infrastructure)", "marko/queue-database": "Database-backed queue driver (production-ready; uses your existing database)", - "marko/queue-rabbitmq": "RabbitMQ queue driver (recommended for high-throughput production deployments)", - "marko/pubsub-redis": "Redis pub/sub driver (recommended — purpose-built for messaging)", + "marko/queue-rabbitmq": "RabbitMQ queue driver (high-throughput production deployments)", + "marko/pubsub-redis": "Redis pub/sub driver (recommended; purpose-built for messaging)", "marko/pubsub-pgsql": "PostgreSQL LISTEN/NOTIFY pub/sub driver (no additional infrastructure if you already use Postgres)", - "marko/media-gd": "GD image processor (recommended — ships with most PHP installations)", - "marko/media-imagick": "ImageMagick image processor (higher fidelity; requires ext-imagick and ImageMagick library installed)", - "marko/mail-smtp": "SMTP mailer driver (recommended for production)", - "marko/mail-log": "Log-only mailer driver (writes emails to LoggerInterface; intended for development and testing)", - "marko/session-file": "File-based session driver (recommended default for single-server apps)", - "marko/session-database": "Database-backed session driver (recommended for distributed deployments and queryable session data)", + "marko/media-gd": "GD media driver (recommended; built into most PHP installations)", + "marko/media-imagick": "ImageMagick media driver (higher fidelity; requires ext-imagick)", + "marko/mail-smtp": "SMTP mail driver (recommended; for production)", + "marko/mail-log": "Log-based mail driver (writes emails to LoggerInterface; for development and testing)", + "marko/session-file": "File-based session driver (recommended; single-server apps)", + "marko/session-database": "Database-backed session driver (distributed deployments and queryable session data)", "marko/authentication-token": "Token-based authentication driver (signed token sessions)", - "marko/encryption-openssl": "OpenSSL-based symmetric encryption driver (AES-256-GCM)", + "marko/encryption-openssl": "OpenSSL-based encryption driver (AES-256-GCM)", "marko/http-guzzle": "Guzzle-based HTTP client driver", - "marko/log-file": "File-based logger with rotation", + "marko/log-file": "File-based log driver (with log rotation)", "marko/translation-file": "File-based translation driver (PHP array files per locale)", "marko/notification-database": "Database-backed notification driver", "marko/page-cache-file": "File-based page cache driver", diff --git a/packages/view/known-drivers.php b/packages/view/known-drivers.php index 711db66f..b9ef98cc 100644 --- a/packages/view/known-drivers.php +++ b/packages/view/known-drivers.php @@ -3,6 +3,6 @@ declare(strict_types=1); return [ - 'marko/view-twig' => 'Twig template engine driver (recommended for broader ecosystem familiarity)', - 'marko/view-latte' => 'Latte template engine driver (compile-time safety, n:attribute syntax)', + 'marko/view-twig' => 'Twig view driver (recommended; broader ecosystem familiarity)', + 'marko/view-latte' => 'Latte view driver (compile-time safety, n:attribute syntax)', ];