Skip to content

fix(ratchet): read a JSON key's $comment marker on the line below it - #1732

Merged
eldad-caura-ai merged 1 commit into
mainfrom
ratchet-reads-json-comment-markers
Sep 30, 2026
Merged

eldad-caura-ai merged 1 commit into
mainfrom
ratchet-reads-json-comment-markers

Conversation

@ghost

@ghost ghost commented Sep 27, 2026 •

Copy link
Copy Markdown

What broke

The release PR for plugin v2.23.3 (#1722) failed the required Legacy-name ratchet check, and a fixup commit was needed before it could ship.

  • Why: JSON has no comments, so the legacy-name-ok marker for the MEMCLAW_AGENT_ID alias lives in a "$comment" member of the object the key opens. On main that member shares the key's line. release-please re-serialises every JSON file it bumps, which put the member on the line below.
  • What the ratchet saw: it reads markers per line, so the key read as a newly minted, unmarked legacy name, and the marked line read as removed. Nothing had changed except the layout.

Any JSON formatter does the same, so this would come back at every plugin release.

The fix

For JSON files, the engine now joins two lines, with one space:

  • a key that is alone on its line and opens an object, and
  • a "$comment" member directly below it that carries one of this tool's markers.

That joined text is exactly the one-line form, so both layouts read as the same line. The key's marker has to be in its object's first member, directly below the key. A "$comment" further down, or the same layout in a file that isn't .json, does not exempt the key. The module docstring now says where a JSON marker goes.

A "$comment" without a marker is not joined at all. It documents the key, and joining it made every edit to that documentation read as a newly minted line. Adding or rewording one below a branded key failed the gate, where main's engine passes. No JSON file in the tree has such a pair today, so this was latent.

The pair is one line everywhere else too:

  • Counted once. When the comment's reason names the brand as well, git grep also returns the comment line on its own. That counted one alias twice in the exempt-line reports. A comment read with its key is no longer counted again. Only a comment actually joined to a matched key is skipped, so a branded comment below an unbranded key still counts.
  • Listed when it changes. The exempt-line report picks lines by git's diff. When a marker is written below an existing key, git calls only the comment line added, so the key was exempted without being listed. A change to the comment now counts as a change to its key.

The manifest stays as it is. The next plugin release will split the line, and that now changes nothing.

Evidence

  • Real commit: release-please's original chore: release main #1722 commit (9d65875e), checked against its base:

    engine result
    main's fails: plugin/openclaw.plugin.json (1 -> 2)
    this PR's No new lines.

    It still lists the rewritten line under "exempt line(s) written". That is a report, not a failure.

  • The first review's scenarios, where the comment's reason names the brand. Exempt lines reported, on this PR's first revision and now; the gate passes in all four runs:

    scenario written removed
    release-please's move from one line to two 2 → 1 0 → 0
    the reason edited, below its key 1 → 1 2 → 1
  • New tests: eleven, one of them run for both layouts.

    • Five cases for the new behaviour fail against main's engine: a marker directly below its key; a layout change from one line to two; a comment that also names the brand, counted once; a marker added below an existing key, listed; a reworded reason below its key, reported.
    • A reworded reason is reported the same way on the key's line, and that case passes on main too. Rewording a reason is a change to the marker's claim, so both layouts report one line removed and one written.
    • Two pin the fix above: adding, and rewording, an unmarked "$comment" below a branded key mint nothing. They pass on main and failed on this PR's previous revision.
    • Three bound it and pass either way, as they should: a comment further down, a comment with no marker, a non-JSON file.
    • One guards the de-duplication: a branded comment below an unbranded key still fails the gate. It passes either way, and fails against a variant that skips comment lines by layout alone.
  • Mutation check: four mutants each fail exactly their own tests: no de-duplication, no diff mapping, de-duplication by layout alone, and joining any comment.

  • Other checks:

    • the ratchet test suite: 204 passed, 1 skipped;
    • ruff check and ruff format --check on scripts/ and tests/, and mypy on scripts/. Nothing was reformatted, since this file is byte-sensitive;
    • the ratchet on this branch: no new lines;
    • the sentinel: all 35 protected strings survive.

After merging

The org-required check runs the engine pinned at ratchet-stable, which is currently ccd11eb8, identical to main's engine before this PR. Move it to this PR's merge commit, or the next plugin release fails the same way.

🤖 Generated with Claude Code

@ghost
ghost self-requested a review as a code owner September 27, 2026 08:12
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Review by Claude Code

Double-counts a JSON alias when its $comment reason text also names the legacy brand

Severity: Medium
File: scripts/legacy_name_ratchet.py:901-930, 993-995
Problem: _with_json_comment joins a JSON key line with the "$comment" line below it, but git grep still returns the "$comment" line as its own separate match whenever its reason text itself mentions the legacy name (e.g. "$comment": "legacy-name-ok: alias for memclaw compatibility"), so one alias ends up counted/reported twice.

🤖 Claude Code Prompt
In scripts/legacy_name_ratchet.py, `_grep` (around lines 933-996) calls
`_with_json_comment(tree, path, lineno, text)` (defined at lines 921-930) for
every matched line independently. When a JSON key-opening line such as
`"MEMCLAW_AGENT_ID": {` is immediately followed by a `"$comment"` member whose
own reason text also contains the legacy literal (a very natural way to write
an alias justification, e.g. `"$comment": "legacy-name-ok: alias for legacy
memclaw clients"`), `git grep` returns TWO separate records for the same
logical entity:

1. the key line, joined by `_with_json_comment` with the comment line below it
   into one combined "text" (correctly exempt), and
2. the standalone `"$comment"` line itself, which independently contains the
   legacy literal in its own reason text and is therefore returned again as
   its own match, unmodified by `_with_json_comment` (since
   `_JSON_KEY_OPENING_OBJECT.fullmatch` does not match a bare "$comment" line).

Both records get classified via `_kind()` and both are exempt, so they both
land in `Scan.exempt_by_file`/`Scan.by_file` as two different Counter keys
(the joined text vs. the raw comment text) for what is really one alias
declaration. This inflates:
- the "N compat alias(es)" counts printed by `_report_new_exemptions` and
  tallied by `_marker_inventory`,
- the per-file exempt/gated Counters that `_minted`/`_mint_budget` and the
  moved/removed-exemption reports (`_report_removed_exemptions`,
  `_report_excused_moves`) rely on for text-identity matching across trees,
  which can produce spurious "moved"/"removed" entries when the duplicate
  structure changes between base and head (e.g. the key or comment is edited,
  not just relocated).

Fix by deduplicating: when `_with_json_comment` successfully joins a key line
with the comment line at `lineno + 1`, have `_grep` remember that
(path, lineno + 1) was consumed as part of the joined record, and skip
emitting a standalone record for that same (path, line) if `git grep` also
returns it as an independent match later in the same pass. Add a regression
test (in tests/test_legacy_name_ratchet.py) using a `$comment` reason that
itself contains the legacy literal, asserting the report/counts show exactly
one exemption rather than two.

Reviewed by claude-sonnet-5 · cost $1.1457315

@ghost
ghost force-pushed the ratchet-reads-json-comment-markers branch from a805bd8 to 45e07c2 Compare September 27, 2026 08:40
@ghost

ghost commented Sep 27, 2026

Copy link
Copy Markdown
Author

@claude please re-review 45e07c2.

The double count was real. With a $comment reason that names the brand, the exempt-line report listed two lines for one alias, and editing that reason reported two removed. A comment line is now skipped when git grep returns it again, but only when it was actually joined to a matched key, so a branded comment below an unbranded key still counts.

Skipping it alone would have hidden reason edits from the exempt-line report. That report picks lines by git's diff, and the diff names the comment's line, not the key's. The same gap already let a marker added below an existing key go unlisted. _added_lines now counts a changed comment as a change to its key.

Three new tests. Each one fails against a mutant that removes its part of the fix.

@github-actions

Copy link
Copy Markdown
Contributor

🤖 Review by Claude Code

Summary

The PR extends legacy_name_ratchet.py to treat a JSON "$comment" member on the line directly below a matching key as part of that key's text, so the marker convention works in JSON (which has no inline comments). The mechanism is well documented and has solid test coverage for the intended alias-marker scenarios, but the join is performed unconditionally on any "$comment" member — not only ones that actually carry one of this tool's recognized markers — which creates a realistic false-positive/false-negative footgun.

Critical/High Issues

JSON $comment joining is unconditional, causing spurious "new mint" failures on unrelated schema comments

Severity: High
File: scripts/legacy_name_ratchet.py:924-940 (_json_comment_below), scripts/legacy_name_ratchet.py:998-1004 (_grep)
Problem: _json_comment_below merges any "$comment" member below a matching key into that key's counted text regardless of whether the comment contains a recognized marker (legacy-name-ok/-floor/-absent/-deferred), so adding, editing, or removing an ordinary JSON-Schema $comment (a standard, generic keyword, not exclusive to this tool) next to an already-tracked legacy-named key changes the line's compared text and can make the ratchet report a brand-new "mint" for a line whose legacy-name usage never actually changed.

🤖 Claude Code Prompt
In scripts/legacy_name_ratchet.py, `_json_comment_below` (around lines 913-935) joins
any "$comment" JSON member found directly below a key-opening line into that key's
grepped text, and `_grep` (around lines 998-1004) unconditionally performs this join
before computing `_kind(text)`. Because the join happens regardless of whether the
comment text actually contains one of this tool's markers (EXEMPT_MARKER,
FLOOR_MARKER, ABSENT_MARKER, DEFERRED_MARKER), any edit to an ordinary,
marker-unrelated "$comment" field (a standard JSON Schema keyword used broadly for
documentation) sitting below a key whose own name contains the legacy brand will
change the compared "text" for that key between base and head. Since the ratchet's
core algorithm (`_mint_budget`/`_minted` in the same file) fails a file when it gains
text the repo did not have before and there is no byte-identical predecessor
elsewhere to pair the change with as a "move", this will spuriously fail the gate for
a change that has nothing to do with renaming or aliasing work — e.g. someone
tidying up an unrelated "$comment" description under a
"MEMCLAW_LEGACY_PORT"-style key.

Fix this by only treating the "$comment" line as belonging to the key when its
content actually resolves to one of this tool's markers — e.g. check
`_kind(comment) is not None` (or `EXEMPT_RE`/`DEFERRED_RE` match on the comment text)
inside `_json_comment_below` before returning the comment line, so an unrelated
"$comment" is left as ordinary standalone text (matched only if it independently
contains the legacy pattern) rather than being fused into the key's line and
changing its identity for the base/head text comparison. Add regression tests
mirroring the existing repo() fixture pattern (not a brand-new file) that: (1) commit
a legacy-named key with no comment, then in a later change add an unrelated
non-marker "$comment" below it, asserting the gate still passes with no reported
mint; and (2) commit a marked key+comment pair, then edit only unrelated wording in
an already-marked comment while keeping the marker literal untouched, asserting no
spurious "exempt line(s) removed"/"written" churn is reported for content that carries
no marker at all.

Reviewed by claude-sonnet-5 · cost $1.6007505

@ghost
ghost force-pushed the ratchet-reads-json-comment-markers branch 2 times, most recently from 095be4d to ded93f6 Compare September 27, 2026 11:02
@ghost

ghost commented Sep 27, 2026

Copy link
Copy Markdown
Author

@claude please re-review ded93f6.

The unconditional join was real. Adding or rewording an unmarked "$comment" below a branded key failed the gate, where main's engine passes. _json_comment_below now returns the comment only when it carries one of this tool's markers (EXEMPT_RE or DEFERRED_RE). The join and the diff mapping both go through it, so they cannot disagree. Two new tests cover your case (1): they pass on main and failed on the previous head.

I have not adopted case (2). A reworded reason in a marked comment is still reported, as one line removed and one written. The reason is the marker's claim, and a reworded reason on the key's own line is reported the same way on main today. A new test runs both layouts and asserts the same report. The one-line case passes on main as well, so this keeps the two layouts consistent rather than adding churn.

@github-actions

Copy link
Copy Markdown
Contributor

🤖 Review by Claude Code

Consistent with the design. Given the depth of analysis, the implementation appears correct and well-tested. I did not find genuine correctness or security defects introduced by this diff.

Claude Code Review ✅ No issues found.


Reviewed by claude-sonnet-5 · cost $1.0647167499999999

@ghost
ghost force-pushed the ratchet-reads-json-comment-markers branch from ded93f6 to 84782f7 Compare September 27, 2026 21:16
JSON has no comments, so a key's legacy-name marker can only live in a
"$comment" member of the object the key opens. Kept on the key's own
line, it does not survive a formatter. release-please re-serialises every
JSON file it bumps and puts that member on the line below, so its release
PR for plugin v2.23.3 (#1722) read as minting MEMCLAW_AGENT_ID, and the
required check blocked it until a fixup commit put the marker back.

The gate now joins a JSON key that is alone on its line, opening an
object, to a "$comment" member directly below it that carries a marker,
with one space. That is exactly the text of the one-line form, so both
layouts read as the same line, and a release PR that only re-indents the
key changes nothing. The marker still has to sit in that object's first
member, directly below its key: a "$comment" further down, or the same
layout outside a .json file, does not exempt the key. A "$comment"
without a marker is not joined at all. It documents the key, and joining
it would make every edit to that documentation read as a new line.

The pair is one line everywhere else too. When the comment's reason
names the brand as well, git grep also returns the comment on its own,
which counted one alias twice in the exempt-line reports; it is now read
only with its key. And the exempt-line report picks lines by git's diff,
which calls only the comment line added when a marker is written below
an existing key, so that key was exempted without being listed. A change
to the comment now counts as a change to its key.

On release-please's original #1722 commit (9d65875) the engine on main
fails with "plugin/openclaw.plugin.json (1 -> 2)"; this one reports no
new lines. It still lists the rewritten line under exempt lines written,
which is a report, not a failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Eldad Caura <eldad@caura.ai>
@eldad-caura-ai
eldad-caura-ai force-pushed the ratchet-reads-json-comment-markers branch from 84782f7 to 1b88baf Compare September 30, 2026 20:58
@github-actions

Copy link
Copy Markdown
Contributor

Claude Code Review — skipped: PR author 'eldad-caura-ai' is not a public member of the 'caura-ai' org

@eldad-caura-ai
eldad-caura-ai merged commit a196921 into main Sep 30, 2026
14 checks passed
@eldad-caura-ai
eldad-caura-ai deleted the ratchet-reads-json-comment-markers branch September 30, 2026 21:10
@caura-deploy-bot caura-deploy-bot Bot mentioned this pull request Sep 30, 2026
eldad-caura-ai added a commit that referenced this pull request Oct 2, 2026
…change deletes (#1785)

## What

A line in a file the change **adds** now counts as moved only if a file
the change **deletes** held the same text. Deletions from files that
survive no longer pay for it, so the line is charged as new even when
the repo-wide count of its text didn't rise.

- New and deleted files come from `git diff --name-status --no-renames`.
A renamed file is a deletion plus an addition, so it inherits from its
own source however heavily it was edited, with no similarity threshold
involved.
- New files are visited first. Otherwise an existing destination of the
same text could spend the budget on what is really the move, and both
files would fail.
- The failure text explains the rule. A failing new file whose text the
repo already had is labelled `(0 -> 1, new file: inherits only from
deleted files)`.

## Why

This closes the "new prose" gap from the rebrand close-out (action 7:
treat old names in new files as new, even when the same text exists
elsewhere).

Copies already failed, because the repo-wide count goes up. What was
left was the move excuse. A new page or module that repeated a common
branded line, such as an install command or a URL, passed whenever the
same change deleted an identical line anywhere else. `_minted`'s
docstring calls that coincidence undecidable by counts. For new files it
no longer has to be decided.

## The cost

A line moved into a new file out of a file that **stays** (a split or an
extract) now fails. #882 excused that because no marker reason described
a move. For a new file, either a marker states the case truthfully
(`legacy-name-ok`, `legacy-name-floor`), or the line is unrenamed debt
being written into new prose. Whole-file renames, including heavily
edited ones, still pass.

## Testing

- **Suite:** `tests/test_legacy_name_ratchet.py` passes, 210 tests, run
the way the required workflow runs it (a two-file checkout, Python 3.13,
`pytest>=9.1.1,<10.0`).
- **Five new tests**, each confirmed to fail when the part of the rule
it guards is removed:
- `test_a_move_into_a_new_file_from_a_file_that_stays_is_an_addition`:
fails if the rule is removed.
- `test_a_new_file_inherits_from_the_file_this_change_deletes`: fails if
new files can't inherit.
- `test_a_deleted_line_is_inherited_once`: fails if inheritance doesn't
use up the deleted line.
- `test_a_new_file_takes_the_charge_not_an_existing_destination` (both
sort orders): fails without the new-files-first ordering.
- **Eight existing tests** modelled "a move between existing files" with
a destination the change created. They now commit the destination first,
which is what their docstrings describe. Their assertions are unchanged.
- **Replay:** I replayed the last 40 first-parent commits of `caura`
main and `caura-enterprise` dev under the old and the new script. Every
commit gets the same verdict. A spot check confirmed both versions
really ran.
- **Lint and gates:**
  - `ruff check` (repo config) is clean on both files.
- `ruff format --check --isolated` passes on the script
(`test_the_engine_stays_formatter_stable`).
- The legacy-name ratchet finds no new lines, and the do-not-touch
sentinel reports all 35 strings survive.

## Rollout

The org-wide required check runs the engine from `ratchet-stable`, which
is at `ccd11eb8` (2026-09-16). That branch also lacks #1732, which
landed on 2026-10-01. Neither change applies to other repos until
`ratchet-stable` is moved forward after this merges. It's a
fast-forward, since `ratchet-stable` is an ancestor of main.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: eldad-caura <eldad@caura.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
erni-a pushed a commit that referenced this pull request Oct 3, 2026
🤖 I have created a release *beep* *boop*
---


<details><summary>backend: 3.21.0</summary>

##
[3.21.0](backend-v3.20.1...backend-v3.21.0)
(2026-10-03)


### Features

* **contradiction:** add a per-tenant switch to turn contradiction
detection off (SIDE-58)
([#1761](#1761))
([8ce0fff](8ce0fff))
* **search:** per-request recall_boost/entity_boost opt-outs on REST
/search ([#1763](#1763))
([9a6eb4e](9a6eb4e))
* **stats:** report pending background work and a settled flag on GET
/memories/stats ([#1768](#1768))
([5ea8f80](5ea8f80))


### Bug Fixes

* apply the same identity, trust, fleet and visibility rules across
write paths, lifecycle and audit
([#1775](#1775))
([b58759f](b58759f))
* **audit:** give the audit flusher's storage-slot acquire its own
budget (oss-0927-m-04)
([#1741](#1741))
([ac33b26](ac33b26))
* **ci:** isolate review tools from runner credentials
([#1784](#1784))
([5bc489e](5bc489e))
* **ci:** validate review-memory authors before model capture
([#1783](#1783))
([2273911](2273911))
* **embed:** record the re-embed give-up that reported success
(oss-0924-m-05) ([#1733](#1733))
([d9ee8a5](d9ee8a5))
* **enrich:** record the enrichment give-up that reported success
(oss-0927-m-02) ([#1736](#1736))
([94a142b](94a142b))
* **events:** refuse a forge dry_run instead of silently running for
real ([#1737](#1737))
([2e10e33](2e10e33))
* **graph:** preserve relations with surviving evidence
([#1787](#1787))
([37f8f15](37f8f15))
* **graph:** validate relation errors and tenant-scope overlap seeds
([#1782](#1782))
([17da42f](17da42f))
* **interview:** accept adapter streams and bind them to their agent
([#1788](#1788))
([3930964](3930964))
* lifecycle dedup cadence, bulk write ordering, fresh reads, skill fleet
scope, PII policy on edits
([#1772](#1772))
([f5983a0](f5983a0))
* lifecycle, storage, write-path and configuration reliability
([#1776](#1776))
([adca395](adca395))
* **lifecycle:** settle the embed-backfill topic on one spelling
([#1739](#1739))
([586b249](586b249))
* **llm:** refuse anthropic structured output instead of silently faking
it (oss-0915-m-01)
([#1742](#1742))
([fab8174](fab8174))
* low-batch (oss-0909-l-02, l-03, l-04)
([#1744](#1744))
([77abe9e](77abe9e))
* **plugin:** align tool parameters and document requests with REST
([#1779](#1779))
([b3ef98d](b3ef98d))
* **plugin:** bound credential provisioning and reject API redirects
([#1780](#1780))
([8bf503d](8bf503d))
* **plugin:** honor auto-write opt-out for conversation persistence
([#1778](#1778))
([ad8f8c3](ad8f8c3))
* **plugin:** preserve keystone truncation and normalize tool results
([#1781](#1781))
([1adae88](1adae88))
* **plugin:** refuse to send the API key over plain HTTP to non-loopback
hosts (oss-0917-m-01)
([#1745](#1745))
([4981f45](4981f45))
* **ratchet:** a new file inherits old-name lines only from files the
change deletes ([#1785](#1785))
([71b8883](71b8883))
* **ratchet:** read a JSON key's $comment marker on the line below it
([#1732](#1732))
([a196921](a196921))
* respect memory visibility in lifecycle passes, keep distinct entities
apart, bound Google LLM calls
([#1771](#1771))
([bed4935](bed4935))
* **search:** caller-named top_k beats profile/tenant default top_k
([#1764](#1764))
([47c2796](47c2796))
* **search:** honour explicit top_k on recent_context; expose retrieval
strategy header ([#1762](#1762))
([b6dde15](b6dde15))
* **settings:** let a null unset a search.default_profile knob
([#1765](#1765))
([0e6f4ab](0e6f4ab))
* **storage:** compile the stats breakdown filter without psycopg bind
casts ([#1790](#1790))
([11d5d13](11d5d13))
* **tasks:** record the three known-open give-ups; exclude the audit one
(oss-0927-m-03) ([#1746](#1746))
([2f6c247](2f6c247))
* **tests:** let unit-marked tests run without a database
([#1747](#1747))
([51542cb](51542cb))
* tighten fleet command validation, installer URL handling and settings
storage ([#1769](#1769))
([f96768c](f96768c))


### Dependencies

* bump the uv-minor-patch group across 4 directories with 9 updates
([#1770](#1770))
([0194ce6](0194ce6))
* update sqlalchemy[asyncio] requirement from &lt;2.1,&gt;=2.0.51 to
&gt;=2.0.51,&lt;2.2 in /core-storage-api
([#1758](#1758))
([1e9d73f](1e9d73f))


### Documentation

* **embedding:** stop telling operators the nightly sweep repairs
unembedded rows (oss-0927-h-01)
([#1740](#1740))
([b6cc308](b6cc308))
* **embedding:** the gateway's None is not queued for a backfill sweep
(oss-0927-m-01) ([#1735](#1735))
([5e1179e](5e1179e))
* update Eldad's GitHub handle to
[@eldad-caura-ai](https://github.com/eldad-caura-ai)
([#1767](#1767))
([d148bb6](d148bb6))
</details>

<details><summary>plugin: 2.23.4</summary>

##
[2.23.4](plugin-v2.23.3...plugin-v2.23.4)
(2026-10-03)


### Bug Fixes

* apply the same identity, trust, fleet and visibility rules across
write paths, lifecycle and audit
([#1775](#1775))
([b58759f](b58759f))
* **plugin:** align tool parameters and document requests with REST
([#1779](#1779))
([b3ef98d](b3ef98d))
* **plugin:** bound credential provisioning and reject API redirects
([#1780](#1780))
([8bf503d](8bf503d))
* **plugin:** honor auto-write opt-out for conversation persistence
([#1778](#1778))
([ad8f8c3](ad8f8c3))
* **plugin:** preserve keystone truncation and normalize tool results
([#1781](#1781))
([1adae88](1adae88))
* **plugin:** refuse to send the API key over plain HTTP to non-loopback
hosts (oss-0917-m-01)
([#1745](#1745))
([4981f45](4981f45))
* tighten fleet command validation, installer URL handling and settings
storage ([#1769](#1769))
([f96768c](f96768c))
</details>

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Signed-off-by: release-please[bot] <release-please[bot]@users.noreply.github.com>
Co-authored-by: caura-deploy-bot[bot] <265395343+caura-deploy-bot[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant