Skip to content

Build/Test Tools: Add types for all dynamic constants in PHPStan. - #14034

Closed
rahultank-spaceo wants to merge 25 commits into
WordPress:trunkfrom
rahultank-spaceo:65817-wp-debug-log-type
Closed

rahultank-spaceo wants to merge 25 commits into
WordPress:trunkfrom
rahultank-spaceo:65817-wp-debug-log-type

Conversation

@rahultank-spaceo

@rahultank-spaceo rahultank-spaceo commented Oct 6, 2026 •

Copy link
Copy Markdown

✅ Committed in r64672 (cd6bc90).


PHPStan treated many install-dependent constants as fixed values. A constant missing from dynamicConstantNames took its value from tests/phpstan/bootstrap.php, such as WP_PLUGIN_DIR as '/var/www/html/wp-content/plugins' or FORCE_SSL_ADMIN as false. A listed constant without a type took its type from the only definition PHPStan saw. For WP_DEBUG_LOG, that was the bootstrap's false, so the is_string( WP_DEBUG_LOG ) checks in wp_debug_mode() and WP_Debug_Data::get_wp_constants() were reported as function.impossibleType.

This PR gives every install-dependent constant an explicit type in tests/phpstan/base.neon, 90 in all:

  • the 52 already listed;
  • FORCE_SSL_ADMIN and COOKIE_DOMAIN;
  • 31 that the bootstrap pinned: the content, plugin, and must-use plugin paths and URLs, ABSPATH, the cookie names and paths, the memory limits, the autosave, cron lock, and filesystem timeouts, the filesystem modes, WP_DEFAULT_THEME, WP_START_TIMESTAMP, and the deprecated path constants;
  • the five salts and keys not yet listed.

Constants that core always defines with the same value (*_IN_BYTES, *_IN_SECONDS, and WP_FEATURE_BETTER_PASSWORDS) stay fixed.

Follow-up to r62965, r63191, and r63356.

How the types are chosen

Each type is what core expects: the values core defines and documents, and that its reads are designed for. Real-world configs often go wider, most commonly by passing getenv() through unconverted, which yields false when the variable is unset and otherwise any string, where 'false' is truthy. The values each constant is defined with were surveyed in real wp-config.php files and hosting templates on GitHub, and in WordPress.org plugins and themes. Where wider values are known, a comment above the entry notes them and how core handles them.

A type is narrower than bool, int, or string only where core guarantees it and PHPStan can confirm that every define() in core fits, since a mismatch is reported as constant.defineValue. As the header comment in base.neon says, when core starts handling a value outside a type, the type should be widened in the same change.

Widening every type to cover real-world values was considered and rejected. PHPStan would then require handling invalid config at every read; WP_LANG_DIR . '/plugins' is an error when WP_LANG_DIR is string|false. That is a separate hardening effort, and it would hide core's contract inside the wider types.

Decisions worth noting

  • WP_DEBUG_LOG is bool|non-falsy-string, not bool|string. Configs do define '' and '0', usually through getenv(), which core treats as disabling logging, and the comment says so.
  • NOBLOGREDIRECT is non-empty-string, though the WordPress MU sample config defined ''. The comment describes what core then does.
  • COOKIEPATH and SITECOOKIEPATH stay string. Core's defaults are never empty, but PHPStan cannot prove that at the define sites, and no read depends on it.
  • WP_POST_REVISIONS stays bool|int. Core treats any negative value as unlimited, so a lower bound of -1 would encode a documentation convention that no read depends on.
  • HEADER_IMAGE_WIDTH and HEADER_IMAGE_HEIGHT stay int. non-negative-int would need clamping in add_theme_support(), and it would flag the pre-3.4 compatibility code in Twenty Ten and Twenty Eleven.
  • FS_CHMOD_DIR and FS_CHMOD_FILE are plain int, since setgid modes exceed 0777.
  • The bootstrap define()s stay. PHPStan does not discover constants that core defines inside a function, and it reports reads of them outside a defined() check as not found; removing the defines produces 188 such errors. For a typed constant the value is ignored, as the bootstrap docblock now explains. Placeholders that contradicted their types now fit them: NOBLOGREDIRECT is '%siteurl%', and the cookie names use core's own prefixes.

Source changes

None of these change runtime behavior:

  • (string) casts on the preg_replace() results that define COOKIEPATH, SITECOOKIEPATH, and PLUGINS_COOKIE_PATH, which PHPStan types as string|null. The fixed pattern cannot fail.
  • The include.fileNotFound ignore in src/wp-admin/press-this.php is removed. With WP_PLUGIN_DIR no longer a literal path, PHPStan does not check that include, so the ignore no longer matched.
  • tests/phpunit/includes/bootstrap.php defines the memory limits as '-1' instead of the int -1. wp_convert_hr_to_bytes() trims its input to a string, so it reads both the same.

Testing instructions

  1. Run composer phpstan. It reports no errors, and tests/phpstan/baselines/function.impossibleType.neon no longer has the two is_string() entries.
  2. Optionally, at level 10, analyse the files that use these constants with and without the base.neon changes. No new errors appear. A few existing ones go away, and some change only their text, such as non-falsy-string in place of '/var/www/html/wp…'.

Trac ticket: https://core.trac.wordpress.org/ticket/65817

Use of AI Tools

AI assistance: Yes
Used for: Initial draft of the change and this description; the change and the verification runs were reviewed and confirmed by me in a local environment.

Later commits, which expand the change to all dynamic constants, survey the values each constant is defined with, and revise this description, were made with Claude Code (Claude Opus 5.5) and reviewed by @westonruter.


This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.

🤖 Generated with Claude Code

WP_DEBUG_LOG can be a log file path since WordPress 5.1, but PHPStan inferred it as bool from the bootstrap definition. This caused false function.impossibleType errors for the is_string() checks in wp_debug_mode() and WP_Debug_Data.

See #65817.
@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

Core Committers: Use this line as a base for the props when committing in SVN:

Props rahultank, westonruter.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

Comment thread tests/phpstan/base.neon Outdated
Every entry in dynamicConstantNames now declares its type, so the list uses one style. WP_DEBUG_LOG is narrowed to bool|non-falsy-string.

See #65817.
westonruter and others added 4 commits October 20, 2026 17:11
Resolve the conflict in `tests/phpstan/base.neon` by keeping the typed
`dynamicConstantNames` map from this branch while applying trunk's list
changes: `ENFORCE_GZIP` is dropped (retired along with script and style
concatenation), and `LOGGED_IN_KEY` and `LOGGED_IN_SALT` are added as
`'string'` (from the Secrets API).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`preg_replace()` is typed as returning `string|null`, which the `'string'`
type now configured for these dynamic constants does not accept. The
`null` return only happens on a regex error, which cannot occur for this
fixed pattern, and since `[^/]+` stops at the first slash the appended
trailing `/` always survives. The cast reflects that without widening the
constants' type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Core defines `UPLOADBLOGSDIR`, `WP_LANG_DIR`, and `WPMU_PLUGIN_DIR` as
non-empty paths and concatenates them into other paths, and documents
`NOBLOGREDIRECT` as a redirect URL (or the `%siteurl%` sentinel), so these
become `non-empty-string`. None of them is guarded against an empty value,
so the narrower type makes no existing check look redundant.

`WP_DEVELOPMENT_MODE` becomes the union of the values core documents, the
ones `wp_get_development_mode()` accepts: `'core'`, `'plugin'`, `'theme'`,
`'all'`, and `''`.

`WPLANG` and `WP_SITEURL` stay `string`, since core explicitly checks them
against `''`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each type in `dynamicConstantNames` reflects what core expects. Survey
real wp-config.php files, hosting and Docker templates, and WordPress.org
plugins and themes, and note where they are known to define wider values,
along with how core handles them.

The most common source is a config that passes `getenv()` through without
converting it, which yields `false` when the variable is unset and
otherwise a string, where `'false'` is truthy. Other examples are `1` for
the request-context flags, numeric strings for the legacy header image
sizes and `WP_POST_REVISIONS`, and `''` or `'0'` for `WP_DEBUG_LOG`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The changes in this pull request can previewed and tested using a WordPress Playground instance.

WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser.

Some things to be aware of

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

westonruter and others added 18 commits October 20, 2026 19:56
A constant listed under `dynamicConstantNames` still needs a define() in the
PHPStan bootstrap, or PHPStan reports it as not found, but the value given
there is ignored in favor of the type configured in `base.neon`. Say so in
the bootstrap's docblock, so the placeholder values aren't mistaken for the
source of the types.

Also change the `NOBLOGREDIRECT` placeholder from `''` to the `%siteurl%`
sentinel, so it no longer contradicts its `non-empty-string` type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The note on the salt and key constants is about configs that read
environment variables in general, so describe it that way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Core defines it as `30` and treats `0` as disabling the trash. A negative
number of days is meaningless: the REST API reports items as not
trashable, while `wp_scheduled_delete()` computes a cutoff in the future
and purges all trash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Core only ever writes non-empty values for these: setup and the network
setup screen generate 64-character values, and the sample config uses a
placeholder phrase. An empty salt is tolerated rather than expected, since
`wp_salt()` and `wpdb::placeholder_escape()` treat a falsy value as
missing. Note that some configs nonetheless define `''`.

`non-falsy-string` would go too far, as it would make those truthiness
checks look redundant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`true` or `-1` keeps every revision, `false` or `0` keeps none, and a
positive integer keeps that many. Core converts `true` to `-1` in
`wp_revisions_to_keep()`, and `wp_save_post_revision()` treats any
negative count as unlimited, but `-1` is the documented value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This reverts commit 4f64676.

Core never checks for `-1` specifically: `wp_save_post_revision()` treats
any negative count as unlimited, and `wp_revisions_enabled()` only checks
against `0`. A lower bound of `-1` encodes a documentation convention that
no read depends on, so keep `bool|int`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both types were narrowed without a comment, though real configs define
values outside them. The WordPress MU sample config defined
`NOBLOGREDIRECT` as `''`, which `maybe_redirect_404()` treats as no
redirect but `ms_load_current_site_and_network()` passes through as an
empty `Location` header. Some sites define `WP_DEVELOPMENT_MODE` as `true`,
which `wp_get_development_mode()` treats as `''` while Site Health shows the
raw value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Not every typed dynamic constant needs one: PHPStan discovers constants
that core defines at the top level of a file, such as `MULTISITE`, and a
read inside a defined() check is never reported as not found. Explain that
the defines are for constants core only defines inside a function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both vary by install, but PHPStan took their values from the bootstrap,
treating `FORCE_SSL_ADMIN` as always `false` and `COOKIE_DOMAIN` as always
`''`. Core defines `FORCE_SSL_ADMIN` from the scheme of the `siteurl`
option, and `COOKIE_DOMAIN` as `''`, or as the network's cookie domain on a
subdomain multisite network.

Configs still commonly define `COOKIE_DOMAIN` as `false`, which was core's
own default until r58011, and pass `FORCE_SSL_ADMIN` through from
`getenv()`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`wp_scheduled_delete()` is not the only place an empty
`EMPTY_TRASH_DAYS` fatals: `_wp_delete_orphaned_draft_menu_items()`
multiplies it by `DAY_IN_SECONDS` too. And the `WP_USE_THEMES` comment
read as if core treated `0` as true, when only the string `'false'` is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Multiplying an int by an empty string throws a TypeError on PHP 8, but on
PHP 7.4 it only warns about a non-numeric value and evaluates to 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The types describe what core expects, so a guard against a value outside
one is reported as impossible. Note that such a guard should widen the
type in the same change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PHPStan bootstrap pinned every cookie name and path to `''`, though
each is derived per install by `wp_cookie_constants()` or
`ms_cookie_constants()`, and may be defined earlier by `wp-config.php`,
`sunrise.php`, or a must-use plugin.

The cookie names become `non-empty-string`: core builds each one as a
prefix plus `COOKIEHASH`, and `setcookie()` throws on an empty name.
`COOKIEHASH` stays `string`, since core defines it as `''` when there is
no `siteurl`. `ADMIN_COOKIE_PATH` and `PLUGINS_COOKIE_PATH` are `string`,
like `SITECOOKIEPATH`, from which multisite derives the former.

`PLUGINS_COOKIE_PATH` gets the same `(string)` cast on `preg_replace()`
as `COOKIEPATH`, which cannot fail for this fixed pattern, and the
bootstrap placeholders for the cookie names become core's own prefixes,
so they no longer contradict their types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PHPStan bootstrap pinned `ABSPATH`, the content, plugin, and
must-use plugin directories and URLs, and the theme paths to fictional
literals, though each depends on the install. Core always defines them
non-empty: the entry points define `ABSPATH` from `__DIR__`, and the
others are concatenations onto it or onto the `siteurl` option.

`TEMPLATEPATH` and `STYLESHEETPATH` stay `string`, as core defines them
from `get_template_directory()` and `get_stylesheet_directory()`, which
return a plain `string`.

With `WP_PLUGIN_DIR` no longer a literal, PHPStan cannot resolve the
Press This include to a missing file, so its `include.fileNotFound` ignore
no longer matches and is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PHPStan bootstrap pinned these to core's defaults, though sites
routinely override them, and `WP_START_TIMESTAMP` and the memory limits
are computed at runtime.

The memory limits are `string`, as core defines them from `ini_get()` or
shorthand notation like `'64M'` and passes them to
`wp_convert_hr_to_bytes()` and `ini_set()`. The autosave interval, cron
lock timeout, and filesystem timeouts are `positive-int`, since a value
below 1 changes behavior: autosaves fire on every change or stop, cron
runs overlap, and on PHP 8 `ftp_connect()` and `ftp_set_option()` throw.
The filesystem modes are plain `int`, as core computes them with
`fileperms()`, and setgid modes exceed `0777`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The comments on `ABSPATH` and `WP_START_TIMESTAMP` explained why their
values were fixed rather than derived at runtime: so error messages, which
quoted the values, stayed the same across machines and runs. Both are now
typed dynamic constants, so PHPStan ignores the values and messages quote
the configured types instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The PHPUnit bootstrap defined `WP_MEMORY_LIMIT` and `WP_MAX_MEMORY_LIMIT`
as the int `-1`, outside their `string` type. Core reads both through
`wp_convert_hr_to_bytes()`, which trims its input to a string anyway, and
`ini_set()`, so `'-1'` behaves identically. With core's own code no longer
contradicting the type, the comments documenting the mismatch go away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Core treats all eight salts and keys alike: `wp_salt()` reads each of them,
setup and the network setup screen generate them together, and the repair
screen checks them together. Only three were typed, though, so the
recovery mode cookie service compared `AUTH_KEY` as untyped right beside
a typed `AUTH_SALT`. Give `AUTH_KEY`, `SECURE_AUTH_KEY`,
`SECURE_AUTH_SALT`, `NONCE_KEY`, and `NONCE_SALT` the same type and note.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@westonruter westonruter changed the title Build/Test Tools: Allow WP_DEBUG_LOG to be a string in PHPStan. Build/Test Tools: Add types for all dynamic constants in PHPStan. Oct 21, 2026
@westonruter

Copy link
Copy Markdown
Member

🤖 Comment by Claude Opus 5.5

The PR description and title have been updated for the current scope. Here is how the PR has changed since it was opened, followed by possible follow-ups.

Changes from the original PR

  • Scope. Originally only WP_DEBUG_LOG was given a type. Now every install-dependent constant has an explicit type in tests/phpstan/base.neon, 90 in all: the 52 already listed in trunk, FORCE_SSL_ADMIN and COOKIE_DOMAIN, 31 constants that tests/phpstan/bootstrap.php had pinned to fixed values, and the five salts and keys not yet listed.

  • WP_DEBUG_LOG is bool|non-falsy-string instead of bool|string, with a comment that configs also define '' or '0' (both disable logging) and 'false'.

  • How types are chosen. Each type is what core expects, and a comment above an entry notes wider values known from real wp-config.php files, hosting templates, and WordPress.org plugins and themes. The header comment says to widen a type when core starts handling a value outside it.

  • Narrower types where core guarantees them:

    • non-empty-string for the paths and URLs, the salts and keys, the cookie names, NOBLOGREDIRECT, and UPLOADBLOGSDIR
    • the documented values for WP_DEVELOPMENT_MODE
    • non-negative-int for EMPTY_TRASH_DAYS
    • positive-int for AUTOSAVE_INTERVAL, WP_CRON_LOCK_TIMEOUT, and the filesystem timeouts

    No new errors appear at level 10 on the 322 files that use these constants.

  • Source changes, none affecting runtime behavior:

    • (string) casts on preg_replace() for COOKIEPATH, SITECOOKIEPATH, and PLUGINS_COOKIE_PATH in default-constants.php
    • removal of the stale include.fileNotFound ignore in press-this.php
    • '-1' in place of the int -1 for the memory limits in tests/phpunit/includes/bootstrap.php
  • tests/phpstan/bootstrap.php:

    • The docblock now explains that the defines are still needed, since PHPStan does not discover constants that core defines inside a function, but that their values are ignored for typed constants.
    • Placeholders that contradicted their types now fit them.
    • The comments justifying the fixed ABSPATH and WP_START_TIMESTAMP values were removed, since PHPStan no longer uses those values.
  • Trunk merge. The conflict in base.neon was resolved by dropping ENFORCE_GZIP, which was retired along with script concatenation, and adding LOGGED_IN_KEY and LOGGED_IN_SALT from the Secrets API.

Possible follow-ups

These core bugs were found while surveying how the constants are defined, and are not addressed here:

  • A WP_DEBUG_LOG of 'false', easily defined through getenv(), makes wp_debug_mode() log errors to a file named false (load.php:629).
  • An EMPTY_TRASH_DAYS of '' throws a TypeError on PHP 8 where core multiplies it by DAY_IN_SECONDS, in wp_scheduled_delete() and _wp_delete_orphaned_draft_menu_items(). A negative value makes wp_scheduled_delete() purge all trash, while the REST API's > 0 checks call items not trashable.
  • A NOBLOGREDIRECT of '' makes a subdomain network send an empty Location header for an unknown site (ms-load.php:432).
  • About ten reads check only defined(), so define( 'X', false ) counts as true: DOING_CRON in cron.php:916 and default-filters.php:418; DOING_AJAX and XMLRPC_REQUEST in cron.php:949, admin-bar.php:1474, and wp-cron.php:33; XMLRPC_REQUEST and REST_REQUEST in load.php:647 and post.php:8447; WP_REPAIRING in functions.php:1839; and WP_INSTALLING in general-template.php:5350, pluggable.php:3075, and translation-install.php:187.
  • update-core.php:1839 passes FS_CHMOD_FILE as the third argument of $wp_filesystem->copy(), which is $overwrite, not $mode. A baseline entry in argument.type.neon currently hides it.
  • The CUSTOM_TAGS docblock in kses.php:49 documents array[]|false, but the constant is a bool flag.

There is also an open question about a type: AUTOSAVE_INTERVAL is positive-int, because core's PHP reads mishandle 0; the Customizer then autosaves on every change. The bundled block editor script, though, treats 0 as turning autosave off. If that counts as support, the type should be non-negative-int.

wporg-sync pushed a commit that referenced this pull request Oct 21, 2026
PHPStan treated many install-dependent constants as fixed values. Those missing from `dynamicConstantNames` took their placeholder value from the PHPStan bootstrap, and listed ones without a type took the type of their only visible definition. For `WP_DEBUG_LOG` that definition was `false`, so the `is_string()` checks for a log file path in `wp_debug_mode()` and `WP_Debug_Data` were reported as impossible.

Every install-dependent constant now has an explicit type, including those the bootstrap had pinned, such as the content and plugin paths, the cookie names, the memory limits, and the timeouts. Each type is what core expects: the values it defines and documents, and that its reads are designed for. Where real-world configurations are known to define wider values, most often by passing `getenv()` through unconverted, a comment notes them along with how core handles them. Widening the types to cover such values instead would require handling invalid configuration at every read.

The bootstrap definitions remain, since PHPStan does not discover constants that core defines inside a function, but their values no longer affect the analysis. The `preg_replace()` results that define the cookie paths are cast to strings to fit their types, and the PHPUnit bootstrap now defines the memory limits as strings to match theirs.

Developed in #14034.
Follow-up to r61699, r62965, r63191, r63356.

Props rahultank, westonruter.
See #65817.


git-svn-id: https://develop.svn.wordpress.org/trunk@64672 602fd350-edb4-49c9-b593-d223f7449a82
wporg-sync pushed a commit to WordPress/WordPress that referenced this pull request Oct 21, 2026
PHPStan treated many install-dependent constants as fixed values. Those missing from `dynamicConstantNames` took their placeholder value from the PHPStan bootstrap, and listed ones without a type took the type of their only visible definition. For `WP_DEBUG_LOG` that definition was `false`, so the `is_string()` checks for a log file path in `wp_debug_mode()` and `WP_Debug_Data` were reported as impossible.

Every install-dependent constant now has an explicit type, including those the bootstrap had pinned, such as the content and plugin paths, the cookie names, the memory limits, and the timeouts. Each type is what core expects: the values it defines and documents, and that its reads are designed for. Where real-world configurations are known to define wider values, most often by passing `getenv()` through unconverted, a comment notes them along with how core handles them. Widening the types to cover such values instead would require handling invalid configuration at every read.

The bootstrap definitions remain, since PHPStan does not discover constants that core defines inside a function, but their values no longer affect the analysis. The `preg_replace()` results that define the cookie paths are cast to strings to fit their types, and the PHPUnit bootstrap now defines the memory limits as strings to match theirs.

Developed in WordPress/wordpress-develop#14034.
Follow-up to r61699, r62965, r63191, r63356.

Props rahultank, westonruter.
See #65817.

Built from https://develop.svn.wordpress.org/trunk@64672


git-svn-id: http://core.svn.wordpress.org/trunk@63822 1a063a9b-81f0-0310-95a4-ce76da25c4cd
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

3 participants