Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Task 001: Add KnownDriversValidator helper to marko/testing

**Status**: completed
**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 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 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).

**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 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 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 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
(Left blank — filled in by programmer during implementation)
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Task 002: Pilot — create database known-drivers.php

**Status**: completed
**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
<?php

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',
];
```

**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`
- [ ] `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 004 — this test verifies file shape; that test verifies cross-package sync)
- Code follows code standards
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Task 003: Pilot — refactor database NoDriverException to read known-drivers.php

**Status**: completed
**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 016)

**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<string, string> $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
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Task 004: Pilot — add validation test in marko/database

**Status**: completed
**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 007-023 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 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
- Skeleton IS on disk with a `suggest` key but is missing a known driver entry or has a mismatched description

**Test file shape:**
```php
<?php

declare(strict_types=1);

use Marko\Testing\KnownDrivers\KnownDriversValidator;

$knownDriversPath = __DIR__ . '/../known-drivers.php';
$skeletonComposerPath = __DIR__ . '/../../skeleton/composer.json';

test('skeleton suggest block contains all database drivers', function () use ($knownDriversPath, $skeletonComposerPath) {
KnownDriversValidator::assertSkeletonSuggestContainsAll($knownDriversPath, $skeletonComposerPath);
});

test('every database driver follows marko slash prefix pattern', function () use ($knownDriversPath) {
KnownDriversValidator::assertDocsUrlsResolveToValidPattern($knownDriversPath);
});
```

## Requirements (Test Descriptions)
- [ ] `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 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
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Task 005: Render context/suggestion + URL linkification in marko/errors-advanced

**Status**: completed
**Depends on**: none
**Retry count**: 0

## 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 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 `<a href="..." target="_blank" rel="noopener noreferrer">` 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., `<p class="context">` and `<p class="suggestion">`, 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 .= "<a href=\"{$escapedUrl}\" target=\"_blank\" rel=\"noopener noreferrer\">{$escapedUrl}</a>";
$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)
Loading