docs(agents): encode list-export convention as GR-UI-EXPORT guard

Make the core-primitive-end-to-end rule for Grid.js list exports binding
instead of advisory — so future consumers (and reviewers) cannot quietly
drift back into inline fputcsv / hand-rolled headers / bespoke click
listeners / lurl() for query-carrying URLs.

- CLAUDE.md: new UI Patterns bullet + two "Never Do This" entries
  spelling out the server/view/client contract in one place.
- .agents/checks/guard-catalog.json: new GR-UI-EXPORT entry describing
  the end-to-end requirement (exportRequireGetRequest + exportCapLimit +
  exportSendCsv + ExportColumn + shared filter-schema + dropdown partial
  + endpointUrl() + initListExport).
- .agents/checks/guard-enforcement-map.json: wire the new guard to
  tests/Architecture/ListExportContractTest.php as the automated
  evidence source.
- .agents/checks/guard-checklist.md & .agents/prompts/reviewer-code.md:
  add GR-UI-EXPORT so the Code Reviewer prompt and the checklist flag
  violations alongside GR-UI-LIST.
- tests/Architecture/ListExportContractFiles: registry of every list
  export (currently helpdesk-domains, admin/users, audit/system-audit).
  Adding a new list = one entry, contract test covers the rest.
- tests/Architecture/ListExportContractTest: six checks per registered
  entry — endpoint uses the primitive, no hand-rolled output, data and
  export share the same filter-schema, template includes the dropdown
  partial and uses endpointUrl(), page module imports and invokes
  initListExport, and the shared dropdown partial stays zero-config.
- pages/admin/users/export().php: align with the new contract by
  switching from ad-hoc requestInput()->queryAll() reads to
  gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'), same
  as the data endpoint — eliminates the last place Grid and export
  could disagree on what "the current filter" means.

Gates: PHPUnit 1900 OK (+6 contract checks), PHPStan 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-21 22:50:43 +02:00
parent 0e82e00495
commit 53ccd212d5
8 changed files with 256 additions and 27 deletions

View File

@@ -0,0 +1,60 @@
<?php
namespace MintyPHP\Tests\Architecture;
/**
* Registry of Grid.js list pages that expose a CSV/Excel export. Every
* entry carries enough context for ListExportContractTest to verify:
*
* - endpoint → the export action file (must use exportSendCsv + co.)
* - data_endpoint → the matching data() file (shares filter-schema.php)
* - filter_schema → the schema file both endpoints must call
* - index_template → the list page view (must include the dropdown partial)
* - page_module → the front-end module (must call initListExport)
* - route_path → the source path used by endpointUrl() / lurl
*
* Adding a new list export = add one entry here. The test will then
* enforce the full contract without any per-list bespoke code.
*/
trait ListExportContractFiles
{
/**
* @return list<array{
* endpoint: string,
* data_endpoint: string,
* filter_schema: string,
* index_template: string,
* page_module: string,
* route_path: string
* }>
*/
private function listExportRegistry(): array
{
return [
[
'endpoint' => 'modules/helpdesk/pages/helpdesk/domains/export().php',
'data_endpoint' => 'modules/helpdesk/pages/helpdesk/domains-data().php',
'filter_schema' => 'modules/helpdesk/pages/helpdesk/domains/filter-schema.php',
'index_template' => 'modules/helpdesk/pages/helpdesk/domains/index(default).phtml',
'page_module' => 'modules/helpdesk/web/js/pages/helpdesk-domains-index.js',
'route_path' => 'helpdesk/domains/export',
],
[
'endpoint' => 'pages/admin/users/export().php',
'data_endpoint' => 'pages/admin/users/data().php',
'filter_schema' => 'pages/admin/users/filter-schema.php',
'index_template' => 'pages/admin/users/index(default).phtml',
'page_module' => 'web/js/pages/app-users-list.js',
'route_path' => 'admin/users/export',
],
[
'endpoint' => 'modules/audit/pages/audit/system-audit/export().php',
'data_endpoint' => 'modules/audit/pages/audit/system-audit/data().php',
'filter_schema' => 'modules/audit/pages/audit/system-audit/filter-schema.php',
'index_template' => 'modules/audit/pages/audit/system-audit/index(default).phtml',
'page_module' => 'modules/audit/web/js/pages/admin-system-audit-index.js',
'route_path' => 'admin/system-audit/export',
],
];
}
}

View File

@@ -0,0 +1,158 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
/**
* Enforces GR-UI-LIST-EXPORT: every Grid.js list export must go through
* the core primitive (no inline fputcsv, no hand-rolled headers, no
* bespoke URL-building), and the export endpoint must share the data
* endpoint's filter-schema so the download matches what the grid shows.
*
* Add new list exports to ListExportContractFiles::listExportRegistry().
*/
class ListExportContractTest extends TestCase
{
use ListExportContractFiles;
use ProjectFileAssertionSupport;
public function testEveryRegisteredExportEndpointUsesTheCorePrimitive(): void
{
foreach ($this->listExportRegistry() as $entry) {
$content = $this->readProjectFile($entry['endpoint']);
$this->assertStringContainsString('Guard::requireLogin();', $content, $entry['endpoint']);
$this->assertStringContainsString('exportRequireGetRequest();', $content, $entry['endpoint']);
$this->assertStringContainsString('exportCapLimit(', $content, $entry['endpoint']);
$this->assertStringContainsString('exportSendCsv(', $content, $entry['endpoint']);
$this->assertStringContainsString('exportResolveFlavor(', $content, $entry['endpoint']);
$this->assertStringContainsString('new ExportColumn(', $content, $entry['endpoint']);
$this->assertStringContainsString(
'MintyPHP\\Service\\Export\\ExportColumn',
$content,
$entry['endpoint']
);
}
}
public function testExportEndpointsBanHandRolledOutputPrimitives(): void
{
foreach ($this->listExportRegistry() as $entry) {
$content = $this->readProjectFile($entry['endpoint']);
$this->assertStringNotContainsString('fputcsv(', $content, $entry['endpoint']);
$this->assertStringNotContainsString(
"header('Content-Type: text/csv",
$content,
$entry['endpoint']
);
$this->assertStringNotContainsString(
"header('Content-Disposition:",
$content,
$entry['endpoint']
);
$this->assertStringNotContainsString('php://output', $content, $entry['endpoint']);
}
}
public function testExportAndDataEndpointsShareTheSameFilterSchema(): void
{
foreach ($this->listExportRegistry() as $entry) {
$export = $this->readProjectFile($entry['endpoint']);
$data = $this->readProjectFile($entry['data_endpoint']);
$schemaDir = dirname($entry['filter_schema']);
$endpointDir = dirname($entry['endpoint']);
$dataEndpointDir = dirname($entry['data_endpoint']);
$schemaRefFromExport = $this->relativeSchemaRef($endpointDir, $schemaDir);
$schemaRefFromData = $this->relativeSchemaRef($dataEndpointDir, $schemaDir);
$this->assertStringContainsString(
"gridParseFiltersFromSchemaFile(__DIR__ . '" . $schemaRefFromExport . "/filter-schema.php'",
$export,
$entry['endpoint'] . ' must parse the shared filter-schema.php'
);
$this->assertStringContainsString(
"gridParseFiltersFromSchemaFile(__DIR__ . '" . $schemaRefFromData . "/filter-schema.php'",
$data,
$entry['data_endpoint'] . ' must parse the same filter-schema.php as the export endpoint'
);
$this->assertFileExists(
$this->projectRootPath() . '/' . $entry['filter_schema'],
$entry['filter_schema']
);
}
}
public function testIndexTemplateIncludesExportDropdownAndUsesEndpointUrl(): void
{
foreach ($this->listExportRegistry() as $entry) {
$template = $this->readProjectFile($entry['index_template']);
$this->assertStringContainsString(
"templatePath('partials/app-list-export-dropdown.phtml')",
$template,
$entry['index_template'] . ' must require the shared export dropdown partial'
);
$this->assertStringContainsString(
"endpointUrl('" . $entry['route_path'] . "')",
$template,
$entry['index_template'] . ' must build exportUrl via endpointUrl(), not lurl()'
);
$this->assertStringNotContainsString(
"'exportUrl' => lurl(",
$template,
$entry['index_template'] . ' must not use lurl() for exportUrl (use endpointUrl())'
);
}
}
public function testPageModuleInvokesInitListExportFromCore(): void
{
foreach ($this->listExportRegistry() as $entry) {
$module = $this->readProjectFile($entry['page_module']);
$this->assertMatchesRegularExpression(
"#\\bimport\\s*\\{[^}]*\\binitListExport\\b[^}]*\\}\\s*from\\s*['\"](?:\\.\\./core|/js/core)/app-list-export\\.js['\"]#s",
$module,
$entry['page_module'] . ' must import initListExport from the core module'
);
$this->assertStringContainsString(
'initListExport(',
$module,
$entry['page_module'] . ' must invoke initListExport({ gridConfig, exportUrl })'
);
}
}
public function testExportDropdownPartialRemainsZeroConfigAndReusesCoreClasses(): void
{
$partial = $this->readProjectFile('templates/partials/app-list-export-dropdown.phtml');
$this->assertStringContainsString('data-list-export="csv"', $partial);
$this->assertStringContainsString('data-list-export="excel"', $partial);
$this->assertStringContainsString("<details class=\"dropdown app-list-export-dropdown\"", $partial);
$this->assertStringNotContainsString('<script', $partial, 'partial must not ship inline JS');
$this->assertStringNotContainsString('<style', $partial, 'partial must not ship inline CSS');
}
private function relativeSchemaRef(string $endpointDir, string $schemaDir): string
{
if ($endpointDir === $schemaDir) {
return '';
}
// Schema lives inside the endpoint dir (typical: endpoint sits one level up).
if (str_starts_with($schemaDir, $endpointDir . '/')) {
return '/' . substr($schemaDir, strlen($endpointDir) + 1);
}
// Schema lives in a sibling directory (endpoint sits in same dir parent).
$commonParent = dirname($endpointDir);
if ($schemaDir === $commonParent) {
return '/..';
}
if (str_starts_with($schemaDir, $commonParent . '/')) {
return '/../' . substr($schemaDir, strlen($commonParent) + 1);
}
return '/' . $schemaDir;
}
}