Skip to content
Draft
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
40 changes: 40 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,44 @@ Key constraints include:
* PER Coding Style (extending PSR-12).
* Strict type hinting for all parameters, return values, and properties.

### Global function and constant references

Inside a namespace, PHP resolves an unqualified function or constant name by first looking in the current namespace and only then falling back to the global namespace. That fallback is a runtime lookup on every call, and it prevents opcache from substituting the optimized handlers for common built-ins. Both `src/` and `tests/` are fully normalized to avoid it, and new code must stay that way.

The rule is per file, based on how many times the name is referenced in that file:

* **Referenced once:** prefix it with a leading backslash, e.g. `\gettype($value)` or `\PATHINFO_EXTENSION`.
* **Referenced two or more times:** import it at the top with `use function` or `use const`, and leave the call sites unqualified.

```php
namespace WordPress\AiClient\Files\ValueObjects;

use WordPress\AiClient\Common\Exception\InvalidArgumentException;

use function sprintf;
use function strtolower;

// ...

if (!\is_string($other)) { // used once: leading backslash
throw new InvalidArgumentException(
sprintf('Invalid MIME type: %s', \gettype($other)) // sprintf imported, gettype used once
);
}

return $this->value === strtolower($other);
```

Notes:

* `composer phpcs` enforces the first half of this automatically. The `SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly` rule in `phpcs.xml.dist` fails the build on any global function or constant referenced via the namespace fallback, so a bare `sprintf(...)` is a hard error. It accepts both approved forms equally, so the choice between a leading backslash and an import is a convention that reviewers need to check by eye.
* `composer phpcbf` can fix a fallback reference, but it always fixes by adding an import. For a name used only once, prefer prefixing with `\` by hand instead.
* PSR-12 treats class imports, `use function` imports, and `use const` imports as separate header blocks, each separated by a blank line and each sorted alphabetically. `composer phpcbf` fixes the spacing automatically.
* This applies to global **functions and constants** only. Classes need no equivalent treatment: PHP has no global fallback for class names, so a global class such as `Throwable` or `ReflectionClass` must already be imported or fully qualified for the code to run at all. Keep importing those with a plain `use` statement as usual.
* `true`, `false`, and `null` are language constructs, not constants, and must not be prefixed.
* Files with no namespace declaration, such as `cli.php` and `src/polyfills.php`, are already in the global namespace and need no qualification.
* Do not qualify calls to functions defined by this project or by a dependency inside a namespace; the rule covers global built-ins (including the `src/polyfills.php` shims, which are defined globally).

## Core Principles

* **Provider Agnostic:** The client is designed to work with any AI provider, avoiding vendor lock-in.
Expand Down Expand Up @@ -79,6 +117,7 @@ For a more detailed overview, refer to the `docs/ARCHITECTURE.md` file.
* **Write Tests:** All new features or bug fixes must be accompanied by corresponding unit tests.
* **Use the Fluent API:** When writing examples or tests for the implementer API, prefer the fluent API for readability.
* **Use `{@inheritDoc}`:** When implementing an interface method, use `{@inheritDoc}` in the PHPDoc block to avoid duplicating documentation, as specified in `CONTRIBUTING.md`.
* **Qualify Global Functions and Constants:** Within a namespace, prefix a global function or constant with `\` when it is referenced once in the file, or import it with `use function` / `use const` when it is referenced more than once. See "Global function and constant references" above.

### DON'T:

Expand All @@ -101,3 +140,4 @@ All exceptions must use the project's custom exception classes rather than PHP b
* **Direct HTTP Client Usage:** A common mistake is to instantiate a PSR-18 client directly in a model. This is incorrect. Instead, the model should receive an `HttpTransporter` instance and use it to send requests.
* **Ignoring the Fluent API:** While the traditional API is available, the fluent API is the preferred way for implementers to use the client. Avoid writing complex, nested method calls when the fluent API provides a cleaner alternative.
* **Duplicating Interface Documentation:** Manually writing PHPDoc descriptions for methods that implement an interface is a common pitfall. The `{@inheritDoc}` tag should be used instead to inherit the documentation from the interface.
* **Unqualified Global Functions:** Writing `sprintf(...)` or `is_array(...)` bare inside a namespace is easy to do by habit, but it forces a runtime namespace fallback lookup on every call. Either prefix with `\` or add a `use function` import, depending on how many times the name appears in the file.
27 changes: 27 additions & 0 deletions phpcs.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,31 @@
<property name="searchAnnotations" value="true" />
</properties>
</rule>

<!--
Forbid referencing global functions and constants via the namespace fallback.
A bare `sprintf()` inside a namespace makes PHP look in the current namespace
first and only then fall back to the global one, which costs a runtime lookup
on every call and blocks opcache from using its optimized handlers.

Both accepted forms pass: a leading backslash (`\sprintf()`) for a name used
once in a file, and a `use function` / `use const` import for a name used more
than once. The count-based split between those two is a convention documented
in AGENTS.md and is not machine-enforceable.

ReferenceViaFullyQualifiedName is excluded so this rule stays scoped to the
fallback problem and does not also start policing how classes are referenced.
-->
<rule ref="SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly">
<properties>
<property name="allowFallbackGlobalFunctions" value="false" />
<property name="allowFallbackGlobalConstants" value="false" />
<property name="allowFullyQualifiedGlobalFunctions" value="true" />
<property name="allowFullyQualifiedGlobalConstants" value="true" />
<property name="allowFullyQualifiedGlobalClasses" value="true" />
<property name="searchAnnotations" value="false" />
</properties>
<exclude name="SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly.ReferenceViaFullyQualifiedName" />
<exclude name="SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly.PartialUse" />
</rule>
</ruleset>
7 changes: 6 additions & 1 deletion src/AiClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
use WordPress\AiClient\Results\DTO\EmbeddingResult;
use WordPress\AiClient\Results\DTO\GenerativeAiResult;

use function get_class;
use function gettype;
use function is_object;
use function sprintf;

/**
* Main AI Client class providing both fluent and traditional APIs for AI operations.
*
Expand Down Expand Up @@ -206,7 +211,7 @@ public static function isConfigured($availabilityOrIdOrClassName): bool
}

// Handle string input (provider ID or class name) via registry
if (is_string($availabilityOrIdOrClassName)) {
if (\is_string($availabilityOrIdOrClassName)) {
return self::defaultRegistry()->isProviderConfigured($availabilityOrIdOrClassName);
}

Expand Down
10 changes: 7 additions & 3 deletions src/Builders/EmbeddingBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
use WordPress\AiClient\Results\DTO\Embedding;
use WordPress\AiClient\Results\DTO\EmbeddingResult;

use function count;
use function is_array;
use function sprintf;

/**
* Fluent builder for generating embeddings.
*
Expand Down Expand Up @@ -71,7 +75,7 @@ public function __construct(
return;
}

if (is_array($input) && array_is_list($input)) {
if (is_array($input) && \array_is_list($input)) {
/** @var list<EmbeddingInput> $input */
$this->withInput(...$input);
return;
Expand Down Expand Up @@ -256,8 +260,8 @@ private function parseInput($input): MessagePart
return $this->validatePart($input);
}

if (is_string($input)) {
if (trim($input) === '') {
if (\is_string($input)) {
if (\trim($input) === '') {
throw new InvalidArgumentException('Cannot create an embedding input from an empty string.');
}
return new MessagePart($input);
Expand Down
6 changes: 3 additions & 3 deletions src/Builders/MessageBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,15 @@ public function __construct($input = null, ?MessageRoleEnum $role = null)
// Handle different input types
if ($input instanceof MessagePart) {
$this->parts[] = $input;
} elseif (is_string($input)) {
} elseif (\is_string($input)) {
$this->withText($input);
} elseif ($input instanceof File) {
$this->withFile($input);
} elseif ($input instanceof FunctionCall) {
$this->withFunctionCall($input);
} elseif ($input instanceof FunctionResponse) {
$this->withFunctionResponse($input);
} elseif (is_array($input) && MessagePart::isArrayShape($input)) {
} elseif (\is_array($input) && MessagePart::isArrayShape($input)) {
$this->parts[] = MessagePart::fromArray($input);
} else {
throw new InvalidArgumentException(
Expand Down Expand Up @@ -141,7 +141,7 @@ public function usingModelRole(): self
*/
public function withText(string $text): self
{
if (trim($text) === '') {
if (\trim($text) === '') {
throw new InvalidArgumentException('Text content cannot be empty.');
}

Expand Down
15 changes: 11 additions & 4 deletions src/Builders/PromptBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@
use WordPress\AiClient\Tools\DTO\FunctionResponse;
use WordPress\AiClient\Tools\DTO\WebSearch;

use function array_is_list;
use function array_merge;
use function end;
use function is_array;
use function is_string;
use function sprintf;

/**
* Fluent builder for constructing AI prompts.
*
Expand Down Expand Up @@ -536,7 +543,7 @@ private function inferCapabilityFromOutputModalities(): CapabilityEnum

// Multi-modal output (multiple modalities) defaults to text generation. This is temporary
// as a multi-modal interface will be implemented in the future.
if (count($outputModalities) > 1) {
if (\count($outputModalities) > 1) {
return CapabilityEnum::textGeneration();
}

Expand Down Expand Up @@ -1114,7 +1121,7 @@ protected function appendPartToMessages(MessagePart $part): void

if ($lastMessage instanceof Message && $lastMessage->getRole()->isUser()) {
// Replace the last message with a new one containing the appended part
array_pop($this->messages);
\array_pop($this->messages);
$this->messages[] = $lastMessage->withPart($part);
return;
}
Expand Down Expand Up @@ -1166,7 +1173,7 @@ private function parseMessage($input, MessageRoleEnum $defaultRole): Message

// Handle string input
if (is_string($input)) {
if (trim($input) === '') {
if (\trim($input) === '') {
throw new InvalidArgumentException('Cannot create a message from an empty string.');
}
return new Message($defaultRole, [new MessagePart($input)]);
Expand Down Expand Up @@ -1242,7 +1249,7 @@ private function validateMessages(): void
);
}

$firstMessage = reset($messages);
$firstMessage = \reset($messages);
if (!$firstMessage->getRole()->isUser()) {
throw new InvalidArgumentException(
'The first message must be from a user role, not from ' . $firstMessage->getRole()->value
Expand Down
2 changes: 2 additions & 0 deletions src/Builders/Traits/ModelResolutionTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
use WordPress\AiClient\Providers\Models\Contracts\ModelInterface;
use WordPress\AiClient\Providers\Models\DTO\ModelConfig;

use function array_merge;

/**
* Provides shared model selection and configuration methods for builders.
*
Expand Down
8 changes: 5 additions & 3 deletions src/Common/AbstractDataTransferObject.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
use WordPress\AiClient\Common\Contracts\WithJsonSchemaInterface;
use WordPress\AiClient\Common\Exception\InvalidArgumentException;

use function is_array;

/**
* Abstract base class for all Data Value Objects in the AI Client.
*
Expand Down Expand Up @@ -46,17 +48,17 @@ protected static function validateFromArrayData(array $data, array $requiredKeys
$missingKeys = [];

foreach ($requiredKeys as $key) {
if (!array_key_exists($key, $data)) {
if (!\array_key_exists($key, $data)) {
$missingKeys[] = $key;
}
}

if (!empty($missingKeys)) {
throw new InvalidArgumentException(
sprintf(
\sprintf(
'%s::fromArray() missing required keys: %s',
static::class,
implode(', ', $missingKeys)
\implode(', ', $missingKeys)
)
);
}
Expand Down
19 changes: 11 additions & 8 deletions src/Common/AbstractEnum.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
use WordPress\AiClient\Common\Exception\InvalidArgumentException;
use WordPress\AiClient\Common\Exception\RuntimeException;

use function sprintf;
use function strtoupper;

/**
* Abstract base class for enum-like behavior in PHP 7.4.
*
Expand Down Expand Up @@ -204,7 +207,7 @@ final public function is(self $other): bool
*/
final public static function getValues(): array
{
return array_values(static::getConstants());
return \array_values(static::getConstants());
}

/**
Expand All @@ -217,7 +220,7 @@ final public static function getValues(): array
*/
final public static function isValidValue(string $value): bool
{
return in_array($value, self::getValues(), true);
return \in_array($value, self::getValues(), true);
}

/**
Expand Down Expand Up @@ -286,7 +289,7 @@ protected static function determineClassEnumerations(string $className): array
$enumConstants = [];
foreach ($constants as $name => $value) {
// Check if constant name follows uppercase snake_case pattern
if (!preg_match('/^[A-Z][A-Z0-9_]*$/', $name)) {
if (!\preg_match('/^[A-Z][A-Z0-9_]*$/', $name)) {
throw new RuntimeException(
sprintf(
'Invalid enum constant name "%s" in %s. Constants must be UPPER_SNAKE_CASE.',
Expand All @@ -297,14 +300,14 @@ protected static function determineClassEnumerations(string $className): array
}

// Check if value is valid type
if (!is_string($value)) {
if (!\is_string($value)) {
throw new RuntimeException(
sprintf(
'Invalid enum value type for constant %s::%s. ' .
'Only string values are allowed, %s given.',
$className,
$name,
gettype($value)
\gettype($value)
)
);
}
Expand All @@ -328,8 +331,8 @@ protected static function determineClassEnumerations(string $className): array
final public function __call(string $name, array $arguments): bool
{
// Handle is* methods
if (str_starts_with($name, 'is')) {
$constantName = self::camelCaseToConstant(substr($name, 2));
if (\str_starts_with($name, 'is')) {
$constantName = self::camelCaseToConstant(\substr($name, 2));
$constants = static::getConstants();

if (isset($constants[$constantName])) {
Expand Down Expand Up @@ -376,7 +379,7 @@ final public static function __callStatic(string $name, array $arguments): self
*/
private static function camelCaseToConstant(string $camelCase): string
{
$snakeCase = preg_replace('/([a-z])([A-Z])/', '$1_$2', $camelCase);
$snakeCase = \preg_replace('/([a-z])([A-Z])/', '$1_$2', $camelCase);
if ($snakeCase === null) {
return strtoupper($camelCase);
}
Expand Down
2 changes: 1 addition & 1 deletion src/Common/Traits/WithDataCachingTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ protected function hasCache(string $key): bool
return $cache->has($fullKey);
}

return array_key_exists($fullKey, $this->localCache);
return \array_key_exists($fullKey, $this->localCache);
}

/**
Expand Down
Loading
Loading