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
2 changes: 2 additions & 0 deletions .claude/pr-review-process.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ Check each changed file against project standards:
- **No unnecessary imports** (self-namespace, unused)
- **No hardcoded paths or environment-specific values** — use `PHP_BINARY`, env vars, etc.
- **Tests exist** for new classes, bindings, and behavior
- **No tests read documentation** — reject any test that opens a docs page, README, `CLAUDE.md` or `.claude/` file (see `.claude/testing.md`)
- **Module bindings** have corresponding tests in `ModuleBindingsTest.php`
- **No `final` classes**, including exceptions (blocks Preferences extensibility — see CLAUDE.md)
- **Type declarations** on all parameters, returns, properties
Expand Down Expand Up @@ -251,6 +252,7 @@ Before merging any package PR:
- [ ] `composer phpstan` reports zero errors
- [ ] `gh pr checks <N>` green — `Tests`, `Lint`, `Static analysis` all passing, and `Integration` too (not yet a required check, but still blocking)
- [ ] New code has corresponding tests
- [ ] No test reads documentation (docs pages, READMEs, `CLAUDE.md`, `.claude/`)
- [ ] No hardcoded paths or environment-specific values
- [ ] No `final` classes (including exceptions)
- [ ] No defensive `instanceof` checks on container resolutions
Expand Down
11 changes: 11 additions & 0 deletions .claude/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,17 @@ packages/
- **Feature tests** (`tests/Feature/`): Test integrated functionality, may touch multiple classes
- **Browser tests** (`tests/Browser/`): End-to-end tests using Playwright (Pest 4)

## Tests Never Read Documentation (Hard Rule)

No test may open, assert on, or depend on documentation: docs pages under `packages/docs-markdown/docs/`, package `README.md` files, `CLAUDE.md`, anything in `.claude/`, or `docs/DOCS-STANDARDS.md`. Docs explain how things work for humans and AI; they are not a spec for the implementation, and a test that greps them turns every wording change into a red build.

- Do not write `ReadmeTest`, `DocsTest`, "documents X" or "README has section Y" tests.
- Do not check that a docs page or README exists, or that it links somewhere.
- Code that reads Markdown (e.g. `MarkdownRepository`) is tested against fixtures under `tests/Fixtures/`, never the real docs tree.
- Files the code itself ships or emits (templates, generated config, installed skill files, exception messages that contain a docs URL) are product, not docs, and may be tested.

Docs accuracy is checked outside the test suite: `composer docs:lint` (run by the CI `Lint` job on every PR) fails when a PHP example in a README or docs page references a `Marko\...` class that does not exist; the `doc-updater` agent and PR review cover the rest.

## Coverage Requirements
- Minimum: 80%
- All new code must have tests
Expand Down
16 changes: 10 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
# Redis integration suites (packages/cache-redis, packages/ratelimiter)
# skip without a server; this keeps them running on every build.
redis:
image: redis:7-alpine
image: public.ecr.aws/docker/library/redis:7-alpine
ports:
- 6379:6379
options: >-
Expand Down Expand Up @@ -72,6 +72,10 @@ jobs:
- name: PHP CS Fixer
run: vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.php --dry-run --diff

- name: Docs class references
if: ${{ !cancelled() }}
run: composer docs:lint

static:
name: Static analysis
runs-on: ubuntu-latest
Expand Down Expand Up @@ -105,7 +109,7 @@ jobs:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
image: public.ecr.aws/docker/library/postgres:17
env:
POSTGRES_USER: marko
POSTGRES_PASSWORD: marko
Expand All @@ -118,7 +122,7 @@ jobs:
--health-timeout 5s
--health-retries 10
mysql:
image: mysql:8.4
image: public.ecr.aws/docker/library/mysql:8.4
env:
MYSQL_ROOT_PASSWORD: marko
MYSQL_DATABASE: marko_test
Expand All @@ -133,7 +137,7 @@ jobs:
# stored as LONGTEXT) run only against MariaDB. 11.8 is the first LTS
# with innodb_snapshot_isolation=ON by default.
mariadb:
image: mariadb:11.8
image: public.ecr.aws/docker/library/mariadb:11.8
env:
MARIADB_ROOT_PASSWORD: marko
MARIADB_DATABASE: marko_test
Expand All @@ -147,7 +151,7 @@ jobs:
# The oldest MariaDB LTS still supported. The MySQL driver suite runs
# against it too, so the documented floor is tested.
mariadb-10-11:
image: mariadb:10.11
image: public.ecr.aws/docker/library/mariadb:10.11
env:
MARIADB_ROOT_PASSWORD: marko
MARIADB_DATABASE: marko_test
Expand All @@ -159,7 +163,7 @@ jobs:
--health-timeout 5s
--health-retries 20
redis:
image: redis:7
image: public.ecr.aws/docker/library/redis:7
ports:
- 6379:6379
options: >-
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
# Redis integration suites (packages/cache-redis, packages/ratelimiter)
# skip without a server; this keeps them running on every build.
redis:
image: redis:7-alpine
image: public.ecr.aws/docker/library/redis:7-alpine
ports:
- 6379:6379
options: >-
Expand Down
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,10 @@ composer test:all
# Static analysis — NOT part of composer test; must be zero errors
composer phpstan

# Everything the CI gate runs (tests + lint + static analysis)
# Docs lint — fails on any Marko class in a README/docs PHP example that does not exist
composer docs:lint

# Everything the CI gate runs (tests + lint + static analysis + docs lint)
composer ci

# Real-service suite: fixture app + pgsql/mysql drivers + live Redis (needs the
Expand All @@ -51,6 +54,7 @@ Every PR is gated by the `CI` workflow on `Tests`, `Lint`, and `Static analysis`
- **No final classes** - blocks Preferences (extensibility)
- **readonly** - use when appropriate for immutability, not as blanket rule
- **Type declarations** - required on all parameters, returns, properties
- **Tests never read documentation** - no test may open, assert on, or depend on a docs page, README, `CLAUDE.md`, `.claude/` file, or any other prose written for readers. Docs explain how things work; they are not a spec for the implementation. Test the code's behavior instead. Files the code itself ships or emits (templates, generated config, skill files a package installs) are product, not docs, and may be tested.

## Feature Development

Expand Down
69 changes: 69 additions & 0 deletions bin/docs-lint.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

declare(strict_types=1);

/*
* docs:lint — fails when a fenced PHP code block in the repository README, a
* package README or a docs page references a `Marko\...` class that does not exist.
*
* Classes resolve through the PSR-4 maps in packages/*\/composer.json (file
* existence only, nothing is autoloaded), so the result depends only on the
* files in the repository and runs in well under a second.
*
* Fictional example modules should use a non-Marko namespace (`App\`, `Acme\`).
* If one genuinely has to live under `Marko\`, add its namespace prefix to
* ALLOWED_FICTIONAL_PREFIXES rather than weakening the scan.
*
* Usage:
* php bin/docs-lint.php [--docs-root=<dir>]
*
* --docs-root scans another directory laid out like packages/ (used by the
* scanner's own tests); classes still resolve against the real packages.
*
* Exits 1 on any unresolved class, or when no Markdown files are found.
*/

use Marko\Tests\Support\DocsClassReference\DocsClassReferenceScanner;

require_once dirname(__DIR__) . '/tests/Support/DocsClassReference/DocsClassReferenceScanner.php';

const ALLOWED_FICTIONAL_PREFIXES = [];

$repositoryRoot = dirname(__DIR__);
$packagesRoot = $repositoryRoot . '/packages';
$options = getopt('', ['docs-root:']);
$docsRoot = is_string($options['docs-root'] ?? null) ? rtrim($options['docs-root'], '/') : $packagesRoot;

$files = DocsClassReferenceScanner::discoverMarkdownFiles($docsRoot);

// The repository README sits one level above the packages directory.
if (is_file(dirname($docsRoot) . '/README.md')) {
$files[] = dirname($docsRoot) . '/README.md';
}

if ($files === []) {
fwrite(STDERR, "docs:lint found no Markdown files under $docsRoot. Refusing to pass an empty scan.\n");
exit(1);
}

$scanner = new DocsClassReferenceScanner($packagesRoot, ALLOWED_FICTIONAL_PREFIXES);
$unresolved = $scanner->findUnresolved($files);

if ($unresolved !== []) {
foreach ($unresolved as $reference) {
fwrite(STDERR, sprintf(
"%s:%d references missing class %s\n",
str_replace($repositoryRoot . '/', '', $reference['file']),
$reference['line'],
$reference['class'],
));
}

fwrite(STDERR, sprintf(
"\ndocs:lint failed: %d missing class reference(s). Fix the docs, or the class name.\n",
count($unresolved),
));
exit(1);
}

echo sprintf("docs:lint passed: %d Markdown files, every Marko class reference resolves.\n", count($files));
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -529,11 +529,13 @@
"phpcbf --standard=phpcs.xml || true"
],
"phpstan": "php -d memory_limit=2G vendor/bin/phpstan analyse",
"docs:lint": "php bin/docs-lint.php",
"ci": [
"@test",
"@cs:check",
"php-cs-fixer fix --config=.php-cs-fixer.php --dry-run --diff",
"@phpstan"
"@phpstan",
"@docs:lint"
],
"rector": "rector process"
},
Expand Down
10 changes: 0 additions & 10 deletions packages/amphp/tests/PackageScaffoldingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,6 @@

declare(strict_types=1);

it('creates README.md for marko/amphp with all required sections', function (): void {
$readme = file_get_contents(dirname(__DIR__) . '/README.md');

expect($readme)
->toContain('## Overview')
->and($readme)->toContain('## Installation')
->and($readme)->toContain('## Usage')
->and($readme)->toContain('## API Reference');
});

describe('Package Scaffolding', function (): void {
it('has valid module.php for marko/amphp with empty bindings', function (): void {
$modulePath = dirname(__DIR__) . '/module.php';
Expand Down
31 changes: 0 additions & 31 deletions packages/authentication/tests/ReadmeTest.php

This file was deleted.

10 changes: 0 additions & 10 deletions packages/broadcasting-amphp/tests/ModuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -128,14 +128,4 @@
->and($composer['autoload']['psr-4'])->toBe(['Marko\\Broadcasting\\Amphp\\' => 'src/'])
->and($composer['extra']['marko']['module'])->toBeTrue();
});

it('has a README with installation, quick example and docs link', function (): void {
$readme = file_get_contents(dirname(__DIR__) . '/README.md');

expect($readme)->toStartWith("# marko/broadcasting-amphp\n")
->toContain("## Installation\n")
->toContain('composer require marko/broadcasting-amphp')
->toContain("## Quick Example\n")
->toContain('https://marko.build/docs/packages/broadcasting-amphp/');
});
});
8 changes: 0 additions & 8 deletions packages/claude-plugins/tests/Unit/MarketplaceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,6 @@ function (): void {
);
});

it('architecture.md AI Development Tooling table includes a row for marko/claude-plugins', function (): void {
$archPath = dirname(__DIR__, 4) . '/.claude/architecture.md';
$contents = file_get_contents($archPath);

expect(str_contains($contents, 'marko/claude-plugins'))->toBeTrue()
->and(str_contains($contents, 'AI Development Tooling'))->toBeTrue();
});

it(
'marketplace.json each plugin entry includes name, description, author, category fields per Task 001\'s required-fields finding',
function (): void {
Expand Down
14 changes: 0 additions & 14 deletions packages/claude-plugins/tests/Unit/MarkoLspPluginTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
$this->pluginJsonPath = $this->pluginRoot . '/.claude-plugin/plugin.json';
$this->lspJsonPath = $this->pluginRoot . '/.lsp.json';
$this->shimPath = $this->pluginRoot . '/bin/marko-lsp';
$this->readmePath = $this->pluginRoot . '/README.md';
});

it(
Expand Down Expand Up @@ -110,17 +109,4 @@ function (): void {
expect(str_contains($contents, '>&2'))->toBeTrue()
->and(str_contains($contents, 'exit 1'))->toBeTrue();
});

it(
'README.md explains marko-lsp coexists with php-lsp (intelephense), what marko-lsp adds beyond it, the recommendation to uninstall php-lsp@claude-plugins-official to avoid duplication, and how to verify with claude plugin list',
function (): void {
$contents = file_exists($this->readmePath) ? file_get_contents($this->readmePath) : '';

expect(file_exists($this->readmePath))->toBeTrue()
->and(str_contains($contents, 'intelephense'))->toBeTrue()
->and(str_contains($contents, 'php-lsp@claude-plugins-official'))->toBeTrue()
->and(str_contains($contents, 'uninstall'))->toBeTrue()
->and(str_contains($contents, 'claude plugin list'))->toBeTrue();
},
);
});
16 changes: 0 additions & 16 deletions packages/claude-plugins/tests/Unit/MarkoMcpPluginTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -129,20 +129,4 @@ function (): void {
->and(str_contains($contents, 'exit 1'))->toBeTrue();
},
);

it(
'README.md explains what the plugin registers, how to install via the marko marketplace, and how to verify with claude mcp list',
function (): void {
$readmePath = $this->pluginRoot . '/README.md';
$contents = file_exists($readmePath) ? file_get_contents($readmePath) : '';

expect(file_exists($readmePath))->toBeTrue()
// What the plugin registers
->and(str_contains($contents, 'mcp:serve') || str_contains($contents, 'MCP server'))->toBeTrue()
// How to install via marketplace
->and(str_contains($contents, '/plugin install marko-mcp@marko'))->toBeTrue()
// How to verify
->and(str_contains($contents, 'claude mcp list'))->toBeTrue();
},
);
});
15 changes: 0 additions & 15 deletions packages/claude-plugins/tests/Unit/MarkoSkillsPluginTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,19 +45,4 @@ function (): void {
expect(is_dir($this->pluginRoot . '/skills'))->toBeTrue()
->and(is_dir($this->pluginRoot . '/.claude-plugin/skills'))->toBeFalse();
});

it(
'README.md lists the included skills (create-module, create-plugin), how they\'re invoked with the plugin namespace, and how to install via the marko marketplace',
function (): void {
$readmePath = $this->pluginRoot . '/README.md';
$content = file_exists($readmePath) ? file_get_contents($readmePath) : '';

expect(file_exists($readmePath))->toBeTrue()
->and($content)->toContain('create-module')
->and($content)->toContain('create-plugin')
->and($content)->toContain('/marko-skills:create-module')
->and($content)->toContain('/marko-skills:create-plugin')
->and($content)->toContain('/plugin install marko-skills@marko');
},
);
});
Loading
Loading