Authoritative grammar for every ForgeQL command and clause. Optimized for AI agent consumption — syntax first, advanced patterns second.
- Notation
- Command Syntax
- Universal Clauses
- Operators and Values
- Filterable Fields
- Structured-Text and Config Formats
- Advanced Patterns
- Raw line and file operations (legacy, non-indexed files)
- Onboarding Coach
| Symbol | Meaning |
|---|---|
UPPERCASE |
Keyword — write exactly as shown |
'string' or "string" |
String literal — single or double quotes |
N |
Integer literal |
n-m |
Inclusive line range, e.g. 10-25 |
[ … ] |
Optional element |
( A | B ) |
Choose one |
… |
Repeatable |
CREATE SOURCE 'name' FROM 'url'
REFRESH SOURCE 'name'
USE source_name.branch AS 'alias'
USE source_name.<commit-hash> AS 'alias' -- base the session on an immutable commit (7-40 hex chars)
SHOW SOURCES
SHOW BRANCHES
SHOW COMMITS [clauses] -- this session's commits since its base (newest first);
-- clauses may name `hash` and `subject` only
SHOW VERSIONUSE bases a session on a branch head or, with a 7-40 character hex token in the
branch position, directly on a commit: a local branch of that name is used if one
exists, otherwise the token is resolved as a commit. Every USE response reports
the base_commit the session is ACTUALLY based on (the full hash) — for a fresh
session that is what the base resolved to; for a resumed session or a reused
worktree it is the real checkout, which can differ from the requested base when
the session carries its own commits. Check it: a session can be handed to
another agent by hex and that agent can confirm the exact commit it based on.
If the requested base has moved (e.g. after REFRESH SOURCE), a re-USE of the
same alias rebuilds the session at the new base instead of resuming the old
snapshot; a clean session worktree fast-forwards, one with local work is
preserved and its base_commit tells you where it really stands.
source_name is an unquoted identifier that may contain hyphens (e.g. pisco-code).
branch is an unquoted identifier that may contain hyphens (e.g. main, v1_3_0, line-budget).
alias is your session's worktree name — single/double-quoted or bare (unquoted).
The USE response returns an opaque session_id token (a composite of user,
source, branch, and alias). Store it exactly as returned and pass it verbatim in
every subsequent run_fql call — do not reconstruct it from the alias. If you
lose it, re-issue the same USE command with the same alias: you reconnect to
the same worktree and receive the same token.
CREATE SOURCE, REFRESH SOURCE and USE all read the source configuration — the sidecar <repo-dir>/<source>.forgeql.yaml, or an in-repo .forgeql.yaml — and all three are refused, naming the file and the parse error, when that file exists and does not parse. Absence is not a failure and never was: a source with no configuration file is answered by the in-memory backend, with no columnar index and no verify or run steps. REFRESH SOURCE reads it before fetching, so a refusal means nothing was fetched; CREATE SOURCE can only read it out of the repository it has just cloned, so a refusal there leaves the source registered and the retry succeeds once the file is fixed.
Sessions start automatically on the first USE and persist until the worktree has been
idle — after about 2 hours if it carries no work (no commits over its base and no uncommitted changes), or 48 hours if it does (server-side TTL). There is no explicit disconnect command — multiple
agents can reconnect to the same worktree at any time with the same USE command.
Worktree identity uses a composite key: filesystem directory =
{user}/{source}.{branch}.{alias}, git branch = fql/{user}/{source}/{branch}/{alias}
(under the fql/ namespace).
SHOW VERSION reports the crate version compiled into the running binary (e.g.
0.114.0). It reads no source and needs no active session, so it is the quickest
way for an agent to confirm which build of the engine is answering its queries.
VACUUM [SOURCE 'name'] [KEEP n] [ALL] [APPLY]VACUUM reclaims disk space by deleting stale columnar-cache version
directories (<provider>-v<N> folders under forgeql/overlays and
forgeql/segments) that accumulate every time the enrichment version bumps. It
previews by default — reporting the in-scope directories grouped into
kept/deleted with per-directory sizes and the total reclaimable — and removes
nothing unless APPLY is given.
- Classification keys purely on the parsed
<N>versus the current enrichment version, ignoring the provider prefix (sogit-sha256-v20is treated exactly likegit-sha1-v20). - By default only versions older than the current one are removed; the
current version and any newer ones are kept.
KEEP nretains thennewest older versions;ALLremoves every version including the current one (forcing a re-index). With noSOURCEthe command spans every registered source. - Like
CREATE SOURCE/REFRESH SOURCE,VACUUMis admin-only: blocked over MCP, admin-token over HTTP. The CLI wrapper isforgeql gc [--source NAME] [--keep N] [--all] [--yes], which previews, prompts for confirmation, then applies.
FIND symbols [clauses]
FIND globals [clauses]
FIND usages OF 'symbol_name' [clauses]
FIND callees OF 'symbol_name' [clauses]
FIND files [clauses]clauses: see Universal Clauses.
| Command | Returns |
|---|---|
FIND symbols |
All indexed AST nodes. Use WHERE fql_kind = '...' to narrow. Every row carries a stable node_id, and a real workspace-total usages count of role = 'code' sites on every row EXCEPT a local-scope variable, which carries no usages value at all — its name identifies nothing outside the block declaring it, and the engine does not resolve scoped references, so the number would be the corpus-wide count of the NAME (every ret in a C tree reporting the same figure). Such a row therefore ranks behind every row that has a count under ORDER BY usages in either direction, matches no usages predicate at all — usages = 0 included, so the dead-code recipe no longer sweeps in every local binding — and renders an empty metric column rather than 0. A function PARAMETER is the one local-looking row that still carries its name's count: the scope enricher writes scope only on the declaration kinds its language declares, so a row it never examined is not a row known to be local. ORDER BY usages DESC and WHERE usages > N work on everything else. An exact-name query that finds nothing but whose name is used somewhere returns a hint saying how many code usage sites exist, so "not declared here" and "not here at all" are distinguishable. |
FIND globals |
Shorthand for WHERE fql_kind = 'variable' — file-scope variables, constants, and statics across all supported languages. |
FIND usages OF |
One row per occurrence site of the named symbol (name + path + line + role). Every in-scope file the workspace knows about is read — those that produced symbols and those it tracks by path and size alone — and the line's own text decides what is a site, so the answer is complete over that set and an empty one means those files do not hold the name; outside the set are binary files (a NUL byte near the start, which is also where UTF-32 and mark-less UTF-16 land), files an ignore rule excludes, and files that reached the worktree without passing through ForgeQL — a file skipped for its bytes, and one that exists and could not be opened, are each counted in the response hint with the reason, so an empty answer is never silent about a candidate that went unread — on the indexed backend; the in-memory backend reads no files at all, answering from its index alone, so it counts nothing and its zero carries no such signal; the occurrence postings collected at index time supply the role, not the sites. Covers both identifier references — including ones without call parentheses, such as function-pointer assignments and type positions — and the name written in comment text, a string literal, a build-file argument, or documentation prose, told apart by role (see below); a line nothing recorded is reported as text. An identifier matches on token boundaries, anything carrying a character outside [A-Za-z0-9_] matches literally. Every row carries its file's node_id and rev, so a site is editable where you read it: CHANGE NODE '<node_id>(<line>)' IF REV '<rev>' MATCHING WORD 'old' WITH 'new'. LIMIT counts files, not rows — see below. GROUP BY file gives real per-file counts, GROUP BY role sizes the campaign by kind; combine with IN/EXCLUDE/WHERE/ORDER BY/LIMIT — and IN/EXCLUDE narrow the reading as well as the rows. |
FIND callees OF |
Symbols called from inside the named function body. Alias for SHOW callees OF. |
FIND files |
Files in the worktree. Supports WHERE name = '…' / name LIKE, DEPTH, ORDER BY size, etc. ForgeQL runtime artifacts are hidden from the listing. Lists the same universe FIND usages OF reads — indexed files, files tracked by path and size alone, and files this session touched — with the same exclusions, so a file one answers over is a file the other answers over. |
FIND usagescaps by file, not by row. A usage site is one line of one file, so a cap counted in rows would cut the list mid-file — reporting a file as partly used and dropping the rest of it with no marker. The cap therefore selects whole files:LIMIT 5means "at most the first five files, every site in each" — at most, because the size ceiling below can stop it short — the default cap means the first 20 files, andOFFSETskips whole files so paging never splits one across two pages. Files come back ordered by their first site; sites within a file ascend by line.
totalis the true site count across the whole worktree even under an explicitLIMIT— it is what a rename campaign measures progress against, andtotalgreater than the row count is how you see that files were left out. UnderGROUP BYit counts groups rather than sites, and an explicitLIMITclips it — the aggregates are cut before anything counts them. The default page does not clip it, so onGROUP BY fileatotalabove the row count still means groups were withheld. (FIND symbolsreports the true match count too, except for a bareLIMITwith noORDER BYand forORDER BY namewith a small one, where the scan stops early and nothing counts what it did not read.) A result with files left out arms nofound_rev, so everyFOUNDverb refuses. A second cap bounds the response itself: a hot name can hold hundreds of sites in each of twenty files. Past that ceiling the listing withholds whole files from the tail — file order never changes and no file is ever shown partially — and says so in ahint. The first selected file is always shown complete, however large. When the hint fires, narrow withIN/WHERE, or useGROUP BY filefor per-file counts without the line lists; a largerLIMITdoes not add files past the ceiling.
GROUP BYis exempt: its rows are aggregates, already one per group, and itsLIMITcounts those groups.
Every occurrence carries a
role. A name is written in more places than the compiler resolves it, and a rename campaign has to see all of them.FIND usagestherefore returns several kinds of site under one query, told apart by arolefield:
roleThe name was written in Emitted today codean identifier the grammar resolved — a call, a reference, a type position every indexed language commentcomment text C, C++, Rust, Python, YAML stringa string literal C, C++, Rust, Python configa build- or config-file value CMake, YAML, TOML, JSON docprose in a documentation file Markdown, reStructuredText textthe file's own bytes — the line holds the name, and nothing recorded says what kind of occurrence it is any file; this is what a site looks like where no recorder tokenised the line at all, such as the body of an .rstliteral blockEach role is scoped to the languages listed, so a role-filtered query can come back empty because that container is not classified in that language. The site is found either way — the files are read, so an occurrence is never missing for want of a recorder — it simply arrives as
textrather than under a named role. In a CMake file,configcovers call arguments only: a#comment there is not a config occurrence. In YAML, TOML and JSON it covers scalar values only — a key is not a config occurrence, because a key is already the pair's own name on the symbols side (FIND symbols), and tagging it here would answer "where is this name written?" twice for one byte range. A key nested inside a value is still a key. In Markdown and reStructuredText,doccovers paragraph and heading prose; the text of a fenced or literal code block is notdoc, and a name written there is reported astext.rolefilters and groups like any other field:WHERE role = 'code'narrows to references the compiler sees,GROUP BY rolesizes the campaign by kind. A role is a recorded fact about a container, never a judgement about meaning: the engine says what the name was written in, and never guesses whether a mention refers to your symbol or merely spells it the same way. Two qualifications. Where a name is reached through its parts, the site keeps the role of the posting that shares its line — the two sit on that line together, not necessarily inside the same construct — so the role is the strongest evidence the line carries about the name rather than proof about it. Andtextis backed by no grammar at all, claiming correspondingly less: the line holds the name, and nothing about what the line is.Matching is token-exact for identifier queries:
FIND usages OF 'CONFIG_X'does not matchCONFIG_X_ASYNC, in prose any more than in code. Tokens are[A-Za-z_][A-Za-z0-9_]*runs longer than one character, and each reports the line it is written on — a name buried in a twelve-line comment comes back at its own line, not the line the comment opened on. A language may widen the continuation alphabet: YAML, TOML and JSON add-, soubuntu-latestis one token and is searched whole. A token may contain a widened character but never starts or ends with one, and the start of a token is never widened, so the extra characters can only join a name — they never invent one.A query holding a character outside
[A-Za-z0-9_]— a/, a., a space — is matched as a substring of the stored tokens as well as exactly, soFIND usages OF 'net/core/'reaches every include path under that directory andFIND usages OF 'pm/device_runtime.h'reaches the sites that spell the path in full. This is what makes an include path queryable: a C or C++#include <…>path is recorded as one token holding the whole path, not split at each/, so the path a query names is a substring of the token a file wrote. Substring matching is case-sensitive, like the exact lookup it extends.The test cuts both ways, which is what keeps this tier complete for tokens: only tokens that themselves hold a character outside
[A-Za-z0-9_]are searched, and a token containing your query must hold every character your query does — so no reachable token is skipped. A name written only in that alphabet is matched exactly, soFIND usages OF '256'still means the token256and never widens intosha256.Two further limits on that tier. Candidates are drawn from
codeoccurrences, so a name written in a comment, string or config value is matched exact-only, never as a fragment. And a query under three characters is matched exactly: it is shorter than the index can narrow on, and widening on it would select most of the dictionary.Both tiers answer out of a posting, and a posting exists only where some recorder tokenised the line — so both are bounded by what the index happens to hold, in two ways that compound. A name no recorder stored whole is unreachable by name: where a language does not widen its alphabet,
foo-bar.frozenis stored asfoo-barandfrozen. And a line no recorder tokenised at all is unreachable by anything: the body of an.rstliteral block produces no tokens, so a name written inside one answered zero however plainly the file contained it. Zero reads exactly like "there are none".So the files themselves are read, on every query, and the line's own text decides what is a site. This is the authoritative tier: a site exists wherever the bytes say it does, whether or not anything recorded it, which is what lets an empty answer mean the corpus does not hold the name. The postings are still consulted, but only to label what the bytes found — a site some recorder did see keeps the role it recorded rather than flattening to
text— and for a name split across separators every part contributes to that labelling, not the cheapest one, since which part a site stored varies by language.Every file the workspace knows about is read, not only the ones that produced symbols. A
.gitignore, or a file whose extension no plugin claims, is tracked by path and size alone and holds text like anything else — and one created inside this session, which no committed structure knows about yet, is read from the path the session recorded for it. The set read is exactly whatFIND fileslists, minus ForgeQL's own runtime artifacts, and two things are outside it: a file excluded by.gitignore,.ignoreor.forgeql-ignore, which nothing here enumerates and indexing never adds — unless this session touched it, since a mutation records the path it wrote without consulting any ignore rule: a path no plugin claims is then listed and searched until the next commit and not after, while one whose extension a plugin claims gets a segment instead, and a segment is carried through the commit, so that file stays in both from then on — and one that reaches the worktree without passing through ForgeQL at all, a build step's output say, which is in neither list until it is indexed. That pair is the boundary of "knows about", and it is the same pair on the same terms for both commands. Binary files — a NUL byte near the start, the linegrepdraws — are not searched: an object file or an index blob embeds symbol names, and a site there is bytes no sweep should rewrite. A byte-order mark is believed before that check, so UTF-16 text is read rather than taken for an object file — but only UTF-16, and only where a mark declares it. UTF-16 without a mark cannot be told from a compiled object, and UTF-32 is not decoded at all even when its mark declares it; both are skipped as binary, which is a boundary and not a claim about what those files hold — and the skip is counted in thehint, so a name living only in such a file answers zero with the response saying a candidate went unread. That is the indexed backend; the in-memory one reads no files at all, so it counts nothing and its zero carries no such signal. Everything else is decoded leniently, so a file that is text apart from a stray byte in a legacy encoding still answers on every line that holds the name; a file that exists and cannot be read is counted in the samehint, under its own reason, rather than passed over in silence — as a count, not a list of paths, so thehintsays how much is missing and why but not which file — while one the index lists and the worktree no longer holds is simply skipped — it has no bytes, so nothing about it is missing.A UTF-16 site is found and cannot be rewritten in place. A line boundary there is not a byte boundary, so splicing UTF-8 into it by offset would shift every byte after the edit. Any
CHANGE,INSERTorDELETEtargeting a node or line range in such a file is refused with an error naming the encoding, never attempted, and so is aCOPY LINESorMOVE LINESwhose destination is one — the payload it splices in is UTF-8. That covers replacing the file whole through a node handle: a whole-fileCHANGE NODE '<file_hex>' WITH ...is lowered to a line range like any other, and a line range over UTF-16 does not even reach the last byte, so it is refused too. Replacing every byte at once is safe and is not refused —CHANGE FILE '<path>' WITH ...does it, and is available on non-indexed files, which is what a UTF-16 file in a source tree usually is. For an indexed one, delete the file and write it again —DELETE NODE, thenINSERT NODE FOR, thenINSERT AFTER NODE— or convert it outside ForgeQL and let the reindex pick it up. Reading such a line back is bounded the same way:SHOWrenders the raw bytes, so the decode reaches the site list and not the display.Every tier's sites are merged, none is a fallback for another coming back empty. One corpus stores the same name several ways at once — C keeps
pm/device_runtimeinside a whole include-path token, a Python string a few directories away records it aspmanddevice_runtime, and a literal block in a manual records nothing at all — so each reaches sites the others cannot and only the union is the answer. A site two of them reach is listed once, under the most specific role anything recorded for it.Nothing caps the search. Reading the in-scope files is the cost of every
FIND usages, paid once per query and bounded by how much of the tree is in scope:INandEXCLUDEare the lever, because a file outside their globs can only produce rows the clause pipeline would drop, so it is never opened. The rows are identical either way — only the reading is narrower — which is why scoping a blast-radius query to the subtree you are about to edit is worth doing on a large tree.LIMITandOFFSETpage the delivery, never the search. The onlyhintthis emits counts the files it could not read: an answer short by something specific, never short because the work looked large.Matching follows the shape of the name. An identifier is matched on token boundaries: a letter, digit or underscore in any script continues a token, so
FIND usages OF '256'means the token256and never the digits insidesha256, andk_sleepis not a site insideék_sleep. That is the ruleMATCHING WORDrewrites on, so a find and the sweep it arms agree about where a token starts and ends. A name carrying anything outside[A-Za-z0-9_]is matched literally, separators and all, which is what makes an include path or a dotted name askable at all.A value that no key introduces is still a value: a top-level YAML sequence or JSON array has no
valueedge above it, so it contributes noconfigoccurrences. Config occurrences come from values reached through a key.Non-code roles are a review queue, not an edit list. Comment, string, build-file-argument and documentation-prose occurrences are a judgment call, and an unfiltered
FIND usagesarms them: aCHANGE NODES FOUNDsweep will rewrite the log message, the doc comment, the build flag and the manual page along with the code. That is often exactly right — a rename that leaves its own log strings stale is not finished — and sometimes wrong, so read them before applying, or narrow the armed set withWHERE role = 'code'first.textneeds that reading most of all: it is a raw byte match on a line, with no construct behind it and nothing but the line to say what it belongs to. The engine enumerates and types the occurrences; deciding which ones mean your symbol stays the caller's job.
Use
fql_kindfor all filtering. It is language-agnostic and portable across C++, Rust, and any future language. Rawnode_kindvalues (tree-sitter grammar names) are language-specific, and on the indexed backend every session queries no row stores them at all — soWHERE,ORDER BYandGROUP BYonnode_kindare refused, on every verb that filters rows:FIND symbols,FIND globals,FIND usages,FIND files,FIND callees OF,SHOW outline,SHOW membersandSHOW callees. (The reading verbs are outside that set — see theWHERErow in Universal Clauses.)kindis an alias offql_kindand is answered whereverfql_kindis.
Every FIND also arms FOUND — the set its rows describe — and a complete result carries a
found_rev row: the master rev that gates a bulk mutation over that set. See
FOUND — mutating a whole FIND result.
SHOW body OF 'symbol_name' [DEPTH N] [clauses]
SHOW signature OF 'symbol_name' [clauses]
SHOW outline OF 'file_path' [ALL] [clauses]
SHOW outline OF '<node_id>' [ALL] [clauses]
SHOW members OF 'type_name' [clauses]
SHOW context OF 'symbol_name' [clauses]
SHOW callees OF 'symbol_name' [clauses]
SHOW NODE '<node_id>' [CONTENT | METADATA] [clauses]
SHOW MORE [LAST-k] [HEAD n | TAIL n | n-m] [clauses]| Command | Returns |
|---|---|
SHOW body OF |
Source text of a symbol. Default DEPTH 0: signature only, body replaced by { ... }. DEPTH 1+: progressively reveals nested structure. DEPTH 99: full source. In CSV output the first column is a node-relative 1-based offset (not an absolute line) and the node's id is in the header — so you can CHANGE NODE '<id>' straight from the read. Absolute line numbers are available in format=JSON. A WHERE here is split between the returned lines and the symbol lookup: the fields a line carries — text, marker, line, node_id, rev — filter the lines, and anything else scopes which symbol OF resolved to, so WHERE language = 'rust' picks the Rust definition of a name two languages share. ORDER BY, GROUP BY and HAVING are not split, and are refused unless they name a line field. |
SHOW signature OF |
Declaration line only (return type, name, parameters). It renders one line rather than a row set, so there is nothing here for a predicate to filter: its clause only scopes which symbol is resolved, and a field only a source line carries (text, marker, rev) is refused rather than accepted and ignored. |
SHOW outline OF |
Structural tree of a file. A bare outline lists only structural declarations (functions, classes, structs, enums, traits, unions, namespaces, modules, type aliases, macros); each entry carries a depth so the compact output reads as an indented tree in source order. ALL — or any WHERE — opens the outline to every node, so a filter never searches a smaller tree than the one it is written against. depth counts the ancestors that were listed, so the same node reports a smaller depth in the structural tree than in the full one; it does not vary with which field the predicate names. Passing a <node_id> instead of a file path scopes the outline to that node's subtree. Supports ORDER BY, LIMIT, OFFSET. |
SHOW members OF |
Member declarations of a class/struct/enum: fields, methods, enumerators. Every row carries its node_id and rev, so a member is mutable where you read it. Supports WHERE fql_kind = '...', ORDER BY, LIMIT, OFFSET. |
SHOW context OF |
Surrounding lines of a symbol definition. DEPTH N controls how many context lines (default 5). |
SHOW callees OF |
All symbols called from inside the named function body. A WHERE on name or line filters the call list. path/file is the file the calls sit in — the resolved function's own — so every row carries the same value and it scopes which function OF meant instead of filtering the list. |
SHOW COMMITS |
The commits this session's branch carries since its base, newest first. A commit row carries hash and subject and nothing else, so those are the only names its clauses may use; anything else is refused, naming them. It pages like the FIND verbs — find_limit rows when no LIMIT says otherwise — and its total is every commit the clauses matched, counted before OFFSET and LIMIT cut the page, so a total above the row count means commits the page did not show: older ones held back by a LIMIT, newer ones skipped by an OFFSET. |
SHOW NODE '<id>' |
CONTENT (default) prints the node's source; METADATA returns its FIND NODE record. A node-relative line offset — '<id>(n)' or '<id>(n-m)' — narrows CONTENT to a single line or inclusive range within the node's own span (1-based). |
SHOW MORE |
Pages the session's last buffered output. When a command's output is too large to return inline (e.g. VERIFY build), ForgeQL returns a window and buffers the full output; SHOW MORE retrieves the rest without re-running the command. |
Every SHOW response surfaces each result's node_id (and the CSV off column is node-relative), so you can chain directly into CHANGE NODE without re-reading.
Any command whose output exceeds its inline cap is windowed inline and the full output is buffered server-side (per session). Retrieve the remainder with:
SHOW MORE -- the whole buffered output
SHOW MORE HEAD 40 -- the first 40 lines
SHOW MORE TAIL 40 -- the last 40 lines
SHOW MORE 120-240 -- an explicit 1-based inclusive line range
SHOW MORE WHERE text MATCHES 'error|fail' -- grep the buffer (regex)
SHOW MORE TAIL 80 WHERE text LIKE '%warning%' LIMIT 10Every window form composes with WHERE text (MATCHES regex or LIKE) and
LIMIT/OFFSET; filtering runs over the windowed lines. Each returned line
keeps its original buffer index so a precise follow-up range can be
requested. The buffer is a LAST-n ring (5 slots): a bare SHOW MORE pages the
most recent buffered output (LAST-0), SHOW MORE LAST-1 the one before it —
so a mutation diff survives a subsequent over-cap SHOW/FIND. The ring lives in
the session worktree and is restored by ROLLBACK along with the rest of the
worktree state.
The highest-value use is filtering a long VERIFY build log without re-running
the build: SHOW MORE WHERE text MATCHES 'error|warning'.
Template limitation —
SHOW callees OFdoes not resolve C++ template functions. UseFIND usages OF 'symbol'instead.
Node handles are the way to edit indexed source. Raw line-range and whole-file editing of indexed files is disabled (the engine returns guidance pointing here); the surviving raw-text forms are collected in Raw line and file operations.
-- Every verb that names an EXISTING node takes IF REV. It is not optional.
CHANGE NODE '<node_id>' IF REV '<rev>' WITH 'new_content'
CHANGE NODE '<node_id>(n-m)' IF REV '<rev>' WITH 'new_content'
CHANGE NODE '<node_id>' IF REV '<rev>' MATCHING [WORD] 'old' WITH 'new'
-- MATCHING composes with a line-range-narrowed handle: the sweep touches only
-- occurrences inside lines n-m of the node's current span.
CHANGE NODE '<node_id>(n-m)' IF REV '<rev>' MATCHING [WORD] 'old' WITH 'new'
INSERT (BEFORE | AFTER) NODE '<node_id>' IF REV '<rev>' WITH 'new_content'
DELETE NODE '<node_id>' IF REV '<rev>'
DELETE NODE '<node_id>(n-m)' IF REV '<rev>'
MOVE NODE '<src_id>' IF REV '<rev>' (BEFORE | AFTER) NODE '<dst_id>'
MOVE NODE '<src_id>' IF REV '<rev>' TO '<dir_hex> | <path>'
-- Creation verbs are ungated: a path that does not exist yet has nothing to
-- fingerprint, and appending to a whole-file handle cannot clobber anything.
COPY NODE '<src_id>' TO '<dir_hex> | <path>'
INSERT NODE FOR '<path>' -- create an empty file
INSERT NODE FOR '<path>/' -- create a directory
INSERT AFTER NODE '<file_hex>' WITH '...' -- append at EOF; no rev needed
-- FOUND — every member of the previous FIND result, in one mutation
CHANGE NODES FOUND IF REV '<master>' MATCHING [WORD] 'old' WITH 'new'
DELETE NODES FOUND IF REV '<master>'
MOVE NODES FOUND IF REV '<master>' TO '<dir_hex> | <dir>/'
COPY NODES FOUND TO '<dir_hex> | <dir>/'The handle and its rev always travel together. Every row that hands you a
node_id hands you its rev in the same row — FIND symbols, FIND files,
SHOW outline, SHOW members, SHOW NODE, FIND NODE — and every mutation
hands back the new handle and its new rev, so a follow-up edit on the same node
needs no re-read. You never have to fetch a rev; you already have it.
Why it is required. A handle is stable: it survives edits, insertions, even re-parenting, and it never silently comes to mean a different node. That is exactly what makes the gate necessary. An agent can carry a handle across dozens of commands and come back to it, and the handle will still resolve — but the code underneath may have moved. A rev is the SHA-256 of the node's whole span, so an edit to any child changes the enclosing node's rev too. Nothing else can tell you that the node you remember is not the node that is there.
When a node is removed, its handle is retired — it never transfers to a
look-alike. Deleting a node, emptying it with CHANGE … WITH '', blanking it
in a CHANGE NODES FOUND sweep, or moving it out of a file all free that node's
ordinal. On the reindex that follows, the freed handle is retired rather than
reassigned to a surviving sibling — so a stale handle to a removed construct
fails loudly with node_not_found instead of quietly resolving to a look-alike.
This is what upholds the guarantee above between two byte-identical siblings:
they share a rev, so IF REV alone cannot tell them apart, and only retiring the
removed one keeps its handle from silently coming to mean the survivor.
A stale rev is refused with rev_mismatch, which hands back the node's current
rev, line range, and source — enough to re-target without another read. That
includes a rev read before the file was rewritten outside ForgeQL: the command
re-indexes the file before it compares, so the refusal names the rev the node
has now, not the one the stale index held. Two cases the rev cannot see: a file
deleted outside ForgeQL — its handles still resolve from the old rows, and the
edit then fails on the missing bytes rather than on the rev — and a rewrite that
keeps both the file's size and its mtime, which the gate's precheck takes for the
bytes it last verified, so a rev read before it still matches until either moves.
| Variant | Effect | Gate |
|---|---|---|
CHANGE NODE … WITH … |
Replace the node's entire source span | IF REV |
CHANGE NODE '<id>(n-m)' WITH … |
Replace only lines n–m within the node (node-relative offset) | IF REV |
CHANGE NODE … MATCHING … |
Replace pattern occurrences inside the node's span only — a range-narrowed handle '<id>(n-m)' scopes the sweep to those lines |
IF REV |
INSERT BEFORE|AFTER NODE … WITH … |
Insert new lines around the node | IF REV |
DELETE NODE … |
Delete the node's source span (or lines n–m within it) | IF REV |
MOVE NODE '<src>' (BEFORE|AFTER) NODE '<dst>' |
Relocate the node's bytes to the anchor — one atomic plan, no read round-trip | IF REV |
MOVE NODE '<src>' … TO '<dst>' |
Move or rename: <dst> is a directory handle (keeps the basename) or a path. A whole-file source is unlinked, not emptied |
IF REV |
COPY NODE '<src>' TO '<dst>' |
Same addressing, source stays put | none — it creates |
INSERT NODE FOR '<path>' |
Create an empty file (trailing slash: a directory) and return its handle and rev — the one verb that takes a path, because the path does not exist yet | none — it creates |
INSERT … NODE '<file_hex>' WITH … |
Prepend at BOF / append at EOF of a whole file | none — it cannot clobber |
CHANGE NODES FOUND MATCHING … |
Sweep the replacement across every member of the previous FIND, in one plan | IF REV (master) |
DELETE NODES FOUND |
Delete every member | IF REV (master) |
MOVE NODES FOUND … TO '<dir>' |
Move every member into a directory, each keeping its basename; two members sharing a basename are refused, never merged | IF REV (master) |
COPY NODES FOUND TO '<dir>' |
Same, sources stay put | none — it creates |
FIND is the set-selection syntax. A query with precise filters already names the set, so the
bulk verbs address it as FOUND rather than carrying a second glob grammar. The rows a FIND
returned are saved in the session, and a complete result carries a master rev — a hash over
every member's (handle, rev):
FIND usages OF 'oldName' -- rows + found_rev: h9c…
CHANGE NODES FOUND IF REV 'h9c…' MATCHING 'oldName' WITH 'newName'
FIND files IN 'legacy/**' WHERE extension = 'c' -- rows + found_rev: h4b…
MOVE NODES FOUND IF REV 'h4b…' TO 'archive/'Quote the master rev in IF REV and the mutation runs only if not one member has moved since you
looked — the set-level extension of the per-node IF REV contract. It is re-derived from the live
members at mutation time, so a rev cached at FIND time proves nothing about now. Unlike a
directory's membership rev, it covers content as well, because CHANGE NODES FOUND edits
content.
The master rev is reported as found_rev: a top-level found_rev field in
format=JSON, and the found_rev metadata row in the default CSV. A FIND that
armed no set — a GROUP BY aggregate, or a result truncated by its LIMIT —
carries no found_rev, and every FOUND verb then refuses.
Every member is mutated in one plan: one boundary diff, one UNDO step, never half-applied.
| Rule | Why |
|---|---|
A handle contributes its whole span; a FIND usages row contributes its one line |
A symbol row means the function; a usage row means the call site. A usages row displays its file's handle so you can edit the site directly, but it contributes only that line to FOUND — a sweep over it never touches the rest of the file |
| A truncated FIND issues no master rev, and every FOUND verb then refuses | FIND usages showing 20 files of 500, swept, would rename 20 files' worth and report success. Widen the LIMIT and look again |
Any FIND replaces FOUND; any mutation clears it |
A mutation shifts line numbers, so the set no longer points at what you saw |
A GROUP BY result clears it |
An aggregate row is a count with a filename on it — it addresses nothing |
| A rev mismatch hands back no new rev | The set moved; the only safe recovery is to re-run the FIND and see what it looks like now |
DELETE/MOVE/COPY NODES FOUND need handles |
Usage sites are lines, not nodes — arm them with FIND files or FIND symbols |
IF REV is mandatory for CHANGE/DELETE/MOVE NODES FOUND, absent from COPY NODES FOUND |
Destroying N things you cannot see is the one mistake the diff cannot catch afterwards; a copy creates and destroys nothing |
Each refusal above comes back as a structured self-healing payload you match on
by tag — no_found_set, found_truncated, or found_refused (see the IF REV
self-healing payloads above) — never an opaque string.
The set is written to .forgeql-foundset in the worktree, so it survives a server restart between
the FIND and the mutation. It is re-gated against live revs on use, so restoring it can only
re-offer a target — never authorise a stale one.
Relocation, not re-authoring. MOVE NODE lifts the node's bytes verbatim and splices them at
the anchor — the delete and the insert land in one atomic plan, so the file is never briefly
missing the node and a failure leaves nothing half-moved. No read round-trip: you never have to
SHOW NODE it, hold the text, and re-INSERT it yourself.
-- reorder two functions in the same file
MOVE NODE '<runq_add>' BEFORE NODE '<thread_runq>'
-- lift a helper into another file (the anchor decides where)
MOVE NODE '<helper>' AFTER NODE '<last_include>'Src and dst may be in different files. The response carries new_node_id: re-parenting changes
parent_ordinal, so the moved node earns a fresh handle.
The payload is spliced verbatim, and the source-side removal absorbs the trailing blank
separator, exactly like DELETE NODE — repeated moves out of one file do not accumulate blank
lines. A lines_removed slightly larger than the node's span is this absorption, not a clobber.
Line-addressed MOVE LINES and offset sub-ranges ('<id>(n-m)') stay byte-exact.
The engine does not re-indent (P1). On an indentation-sensitive format the seam is real: a node
lifted from inside a block keeps its original leading whitespace. That is deliberate — guessing the
right indent is exactly the kind of "smart" the engine refuses to be. The boundary diff shows the
seam; close it yourself with CHANGE NODE '<new_id>(1-n)'. Where you want to control the indent
from the start, INSERT + DELETE inside a transaction remains the better tool.
Moving a node into itself (an anchor inside the moved span) is refused rather than silently corrupting the file.
INTO is deliberately not offered: "first child of a container" has no mechanical definition
that holds across languages, and the engine will not guess one.
Every WITH 'content' form accepts a heredoc block as the replacement text, and so do COMMIT MESSAGE and the arguments of VERIFY build, RUN and JOB START:
-- Replace a whole node
CHANGE NODE '<node_id>' WITH 'fn run(buf: &mut [u8]) { buf.fill(0); }'
-- Splice one line inside a node (node-relative offset)
CHANGE NODE '<node_id>(3)' WITH 'let mut total: u64 = 0;'
-- Insert a new item immediately after a node
INSERT AFTER NODE '<node_id>' WITH 'fn helper() -> u32 { 42 }'
-- Delete a node, guarded by its content rev
DELETE NODE '<node_id>' IF REV 'h0123456789abcdef'| Heredoc rule | Detail |
|---|---|
| Opening tag | <<TAG immediately after WITH — tag must be all-uppercase (e.g. RUST, CODE, END) |
| Closing tag | Must appear on its own line with no leading whitespace, matching the opening tag exactly |
| Body | May contain any characters — single quotes, double quotes, embedded ForgeQL keywords — without escaping. One exception: a line that is itself all-uppercase is read as a closing tag, so a body containing a bare TODO or NOTE line ends early and the statement is refused for a tag mismatch. Choose a tag, or indent such a line. |
| Purpose | Prefer over '…' when the replacement contains single quotes (Rust char literals, lifetimes, C-style string escapes) |
Every indexed symbol has a stable node handle — a node_id of the form
n<segment>.<ordinal> (e.g. nb1be37eea3f0.0124). <segment> is a hash prefix
of the file path; <ordinal> is a per-file counter assigned in source order.
Node ids are content-addressed per file: they survive line drift, unrelated edits
elsewhere in the file, and re-parse — the drift-proof way to target code. Read
once, then mutate by handle instead of by absolute line. That holds across a
rewrite made outside ForgeQL too — a formatter, a build step, an editor: the next
command that names a node, a symbol or the file itself (SHOW NODE, SHOW LINES,
SHOW outline of a file or a handle — a directory or glob outline lists many
files and is not checked — SHOW body/context/signature/callees/members,
FIND NODE, every node-addressed mutation, the FOUND sweeps) checks the file
against its indexed content first, re-indexes that one file when they differ and
answers the current lines — a read or FIND NODE says so in a hint; a
mutation carries no hint, its boundary diff shows the current lines, a rev read
before the rewrite is refused with rev_mismatch where the node's bytes
changed, a node whose bytes did not change is edited where it now is, and a
form that quotes no rev — an EOF append to a whole-file handle, COPY NODE … TO, COPY NODES FOUND TO — is checked the same way but has nothing to refuse,
so it writes from the
re-indexed bytes without announcing it. The
check costs one repeat of the lookup the verb itself runs — a gated command
resolves its symbol or handle twice, on the same index tier, scanning nothing
new, and once more per file it re-indexes, since the naming is repeated until a
pass re-indexes nothing (a re-index can change which file a symbol resolves to)
— plus one stat per named file, a content hash the first time the session
names it and whenever its size or mtime moved, and a single-file re-index when
the hash differs; mapping an answer's lines to handles then hashes the file once
more, uncached, so a read that renders handles and a mutation's boundary diff
pay a second hash of that file. A rewrite that keeps both size and mtime is not
seen until either moves; a FIND over the corpus, which names no file, is not
checked, so its rows can carry a stale line for such a file until that file is
read or edited; and UNDO and ROLLBACK are not checked either — they restore
whole files from ForgeQL's own snapshots over whatever a rewrite left since. The
check reaches only what the stale index can still name: a symbol the rewrite
introduced or renamed resolves to nothing, so a read by that symbol answers "no
symbol matches" with nothing re-indexed until the file is read by handle or by
path; a file created outside ForgeQL has no index to be stale and stays
unindexed until ForgeQL itself writes it or the next attach rebuilds the index —
a reconnect re-indexes only the tracked files git diff HEAD lists, never an
untracked one; a file deleted — or made unreadable — outside ForgeQL keeps its
rows until ForgeQL next writes or re-indexes it, is never taken as verified, and
a read of it fails on the missing bytes; and a re-index that does not leave the
file fresh refuses the command with its reason rather than answering from the
old rows. The in-memory backend (a source with no .forgeql.yaml) stores no
per-file content id, so it answers from its table until ForgeQL next writes or
re-indexes the file — the ordinal handles it prints on SHOW rows do not resolve
through SHOW NODE/FIND NODE anyway.
Comments — including doc comments — index as their own addressable nodes
(comment, and runs of adjacent comments as comment_block), separate from the
item they document, so a doc comment can be edited without touching the code
below it. An item's span and rev fold in its contiguous leading attributes
(#[...]), so an IF REV guard protects the attributes along with the item.
FIND NODE '<node_id>' -- metadata: name, kind, line, end_line, rev, nav
SHOW NODE '<node_id>' [CONTENT | METADATA] -- source (default) or the FIND NODE record
CHANGE NODE '<node_id>' WITH '...' -- replace the whole node
INSERT (BEFORE | AFTER) NODE '<node_id>' WITH '...'
DELETE NODE '<node_id>' IF REV '<rev>'
MOVE NODE '<src_id>' (BEFORE | AFTER) NODE '<dst_id>' -- relocate; source removal absorbs trailing blanksFIND symbols, FIND files, SHOW outline, SHOW members, and the CSV form of
SHOW body all surface node_ids — each with its rev — so a handle you can
actually mutate is one read away.
The ordinal is what makes a handle point inside a file. Drop it and the handle addresses the file itself — or a directory, since a file and a directory can never share a path:
FIND files IN 'src/**' -- every row carries node_id + rev
FIND NODE '<hex>' -- kind = file | dir, plus its rev
SHOW NODE '<hex>' -- read the whole file (buffered)
SHOW NODE '<hex>(12-40)' -- read lines 12–40 of it
SHOW outline OF '<hex>' -- outline the file, or list a directory
INSERT BEFORE NODE '<hex>' WITH '...' -- prepend at BOF
INSERT AFTER NODE '<hex>' WITH '...' -- append at EOF (works on a 0-byte file)
CHANGE NODE '<hex>' IF REV '<rev>' WITH '...' -- overwrite the whole file
DELETE NODE '<hex>' IF REV '<rev>' -- delete the file; a dir deletes its subtree<hex> is the same path fingerprint the node form uses (≥ 12 hex chars), so
FIND files hands you a handle you can act on without a second lookup.
IF REV is mandatory on the destructive whole-path forms — whole-file
DELETE and CHANGE, and a whole-file MOVE source. A node edit can be
reviewed and corrected afterwards; deleting a file or overwriting all of it
leaves nothing to re-read. The rev is you proving you are acting on what you
actually saw. SHOW and INSERT BEFORE/AFTER create or read, so they are
ungated.
A file rev is the SHA-256 of its bytes. A directory rev is a membership XOR over the paths of every file underneath it, at any depth: it moves when the subtree gains, loses, or renames a file, and deliberately does not move when a file's content changes. That is what a recursive delete needs to be gated on — that you saw the current membership, not that you read every byte. Content staleness is the per-file rev's job.
A handle addresses something that exists. Creation and renaming are the two operations that cannot start from one — the destination has no fingerprint yet — so they take a path:
INSERT NODE FOR 'src/new_module.rs' -- create an empty file, returns its n<hex>
INSERT NODE FOR 'docs/' -- trailing slash: create a directory
MOVE NODE '<hex>' IF REV '<rev>' TO 'src/renamed.rs' -- rename (source is unlinked)
MOVE NODE '<hex>' IF REV '<rev>' TO '<dir_hex>' -- move into a directory
COPY NODE '<hex>' TO 'api/v2/' -- copy, keeping the basename
COPY NODE '<hex>.<ord>' TO 'src/extracted.rs' -- lift one node into a new fileThe TO argument is a directory handle (the source keeps its basename) or a
path: a trailing slash — or an existing directory — means "into here",
anything else is the full destination. The destination is never clobbered; if it
exists, the command is refused. MOVE with a whole-file source is destructive
(the source file is removed) and takes the mandatory IF REV; COPY only
creates, so it is ungated. Both return the destination's node_id — the handle
is path-derived, so a move earns a new one, while the rev is unchanged (same
bytes).
INSERT NODE FOR replaces the old file-creation idiom (COPY LINES 1-1), and
the pair INSERT NODE FOR '<path>' → INSERT AFTER NODE '<hex>' WITH '…' is
the create-then-write bootstrap. Note that git does not track empty directories:
one created with a trailing slash exists on disk and is listed by FIND files,
but it will not survive a commit/clone round-trip until a file lands in it. The
engine will not invent a .gitkeep for you.
Files created inside a transaction are removed by ROLLBACK. (They are
untracked until COMMIT stages them, so git reset --hard used to walk straight
past them and leave them behind.)
A node_id may carry a 1-based line offset inside the node's own span, so you can target one line (or a range) of a node without computing absolute numbers:
SHOW NODE '<id>(2)' CONTENT -- the node's 2nd line
SHOW NODE '<id>(2-4)' CONTENT -- the node's 2nd–4th lines (inclusive)
CHANGE NODE '<id>(2)' WITH '...' -- splice the node's 2nd line
CHANGE NODE '<id>(2-4)' WITH '...' -- splice the node's 2nd–4th linesOffsets are inclusive and 1-based; an offset past the node's last line is a hard
error. The CSV off column from SHOW body is exactly this offset, so you can
copy '<id>(off)' straight into a CHANGE NODE. (Offsets apply to CONTENT
only — SHOW NODE '<id>(n)' METADATA is rejected.)
Each node carries a content rev handle (h<16-hex>) reported by FIND NODE.
Guard a mutation with IF REV to make it a no-op when the node changed since you
read it:
CHANGE NODE '<node_id>' IF REV 'h0123456789abcdef' WITH '...'The edit applies only when the node's current rev matches; otherwise it is rejected without touching the file. A rejected guard returns a self-healing payload so you can re-target without another read:
{
"error": "rev_mismatch",
"node_id": "<id>",
"expected": "<the rev you passed>",
"current_rev": "<the node's actual current rev>",
"line_start": 10,
"line_end": 14,
"current_content": "…the node's current source…",
"reindexed": "re-indexed <path>: the file changed on disk outside ForgeQL after being indexed …"
}For a large node the current_content is elided to its first 24 and last 8
lines with a note giving the omitted-line count and a SHOW NODE '<id>'
pointer, so a stale-rev refusal on a whole-file node no longer dumps the entire
file into the error. The rev, line range, and head/tail are enough to re-target
most edits; SHOW NODE '<id>' reads the full source when you need it. The
reindexed field is present only when the command re-indexed the file first
because it had changed on disk outside ForgeQL — a formatter, a build step, an
editor — and names that file: the mismatch is then that rewrite, not a phantom.
The structured rejections — the FOUND refusals and a mutation's
node_not_found — carry the field the same way when a re-index preceded them;
a read's node_not_found (SHOW NODE or FIND NODE on a handle the rewrite
removed) is a plain node_id not found message and gets the same notice as a
trailing sentence instead, as does every other unstructured refusal.
The bulk NODES FOUND verbs refuse in the same self-healing form — each returns
a JSON object you match on by its error tag, carrying a suggested_next
string that names the recovery:
no_found_set— no FIND has armed a set this session; run a FIND first.found_truncated— the arming FIND was capped byLIMIT, so no master rev was issued; re-run it with aLIMITthat covers the whole result.found_refused— a bulk mutation ran without the mandatoryIF REV; re-run the FIND to read the master rev off its response, then quote it.
A handle that resolves to nothing returns {"error": "node_not_found", …} in
the same form from a mutation; on the indexed backend a read — SHOW NODE or
FIND NODE on a handle the index no longer holds — reports it as a plain
node_id not found: <id> message instead. Over MCP — stdio and HTTP alike —
every one of these structured rejections comes back as an error-flagged
(isError) tool result whose text is the JSON payload, not a buried protocol
error, so the agent parses and acts on it exactly like an ordinary result.
Mutations are mechanical: the engine splices exactly the bytes you supply and
never auto-corrects syntax — no comma fixing, no { } wrapping, no
re-indentation. What it does instead is show you exactly what happened. Every
successful mutation returns:
| Field | Meaning |
|---|---|
new_node_id |
The node's current handle after the edit (an edit can change a node's identity) |
lines_written |
Number of source lines the edit wrote |
lines_removed |
Number of original source lines the edit overwrote — the destructive-edit signal: a large value on a small edit means you clobbered more than intended (e.g. a CHANGE NODE on a node whose span covers a whole folded body) |
| boundary diff | A compact diff including the unchanged context lines directly above and below the change, so a seam the splice created (a missing separator, an unbalanced brace) is visible immediately |
structural_errors |
Present only when the edit left a touched structured-text file unparseable under a strict, format-native parser (JSON, YAML, TOML and XML today): the file path, the parser's diagnostic with line/column, and whether the file parsed cleanly before this edit. The engine flags the break; the repair is yours. A missing JSON comma or a reshaped YAML indent is caught here even though it leaves no top-level error region for tree-sitter to report. |
Every present line of the diff (added + context) carries an inline
node_id(offset) handle, so a follow-up correction is a copy-paste
CHANGE NODE '<id>(off)' WITH '…' — no re-read round-trip. Read the diff after
every mutation: if it shows a seam, the fix is yours to issue; the engine
will not issue it for you.
Structural validation is detection, never repair — like every other field
here. A strict parser is asked whether each touched file still parses, before the
edit and after it, and the verdict is reported with the parser's own message.
Formats with no strict validator, and the .jsonc dialect (whose comments a JSON
parser would wrongly reject), are never flagged.
UNDO
UNDO LAST-nEvery mutation snapshots the pre-edit bytes of the files it touched into a
per-session undo ring (10 slots deep). UNDO restores the most recent
mutation's pre-edit state — it means LAST-0 on every call and advances no
cursor, so repeating it addresses that same slot again rather than walking
further back; UNDO LAST-n reaches the slot n mutations further back and is
how you step — it rewrites the files THAT mutation touched, so it reverses the
last n+1 mutations only where they touched the same files, and a newer edit to
a different file survives it and needs its own call. A restore
that rewrites bytes reindexes the touched files and invalidates the commit gate
exactly like a forward mutation; a call that finds every file already holding
the slot's bytes writes nothing, reindexes nothing, leaves the gate as it was,
and answers applied: false with an empty files_changed and a note saying so
— so a bare UNDO retried after a timeout is safe, and a repeat is never
reported as a second restore. That note claims only what was checked, that every
file the slot covers already holds its pre-edit bytes: a snapshot entry of zero
bytes cannot tell "was empty" from "did not exist", so undoing a mutation that
CREATED an empty file reports nothing rewritten while the file remains.
UNDO with nothing yet mutated in the
session is an error, not an empty success. The ring lives in the session
worktree, is excluded from commits, and dies with the worktree.
BEGIN TRANSACTION 'name'
COMMIT MESSAGE 'message'
ROLLBACK [TRANSACTION 'name']| Command | Effect |
|---|---|
BEGIN TRANSACTION |
Create a named git checkpoint. Dirty state is auto-committed first. Checkpoints stack — multiple BEGIN calls push; ROLLBACK pops. |
COMMIT MESSAGE |
Stage all changes and create a git commit. Also accepts a heredoc body (COMMIT MESSAGE <<MSG … MSG) for multi-line messages. ForgeQL runtime files are auto-excluded and file deletions are staged correctly. |
ROLLBACK |
Revert to the most recent checkpoint, or to a named one (discards later checkpoints). |
Commit gate: verify steps in .forgeql.yaml may set commit_gate: true.
When set, COMMIT is refused until that step has passed since the most recent
mutation — any edit after a pass re-blocks the commit until the step is re-run.
Several steps may be gated; every gated step must pass. A commit can therefore
never record an unvalidated tree.
VERIFY build 'step' ['arg' | <<TAG … TAG]…
RUN 'template' ['arg' | <<TAG … TAG]…
JOB START 'step' ['arg' | <<TAG … TAG]…
JOB STATUS '<job-id>'
JOB LIST| Command | Effect |
|---|---|
VERIFY build |
Run a named step from .forgeql.yaml verify_steps and wait for it. The command executes on the background job pool — the engine is never blocked while it runs — but the response is synchronous: success + output, exactly as before. If the run outlives the step's timeout_secs, the response degrades to a job_started row with the id to poll. Does not auto-rollback on failure. Steps may declare typed positional params (params: [{ name: target, type: ident }]); each $name in the step's command is substituted after arity and type validation, so a value can never inject shell syntax. Any argument may be written as a heredoc (<<TAG … TAG) instead of a quoted literal — necessary for prose, since a quoted argument cannot contain the quote that delimits it and there is no escape for either. |
RUN |
Run a named allowlisted command template from .forgeql.yaml run_steps, waiting the same way as VERIFY build. ident args substitute into the command; string args bind to the subprocess stdin and are never spliced into the shell. Any argument may be written as a heredoc (<<TAG … TAG) instead of a quoted literal — necessary for prose, since a quoted argument cannot contain the quote that delimits it and there is no escape for either. |
JOB START |
Run a verify step as a detached background job — returns a job id immediately instead of blocking the request. Use for long test gates. Accepts the same typed positional args as VERIFY build, heredocs included. A commit_gate: true step run this way satisfies the commit gate when the job completes — unless an edit happened while it ran, in which case the gate stays blocked (the run tested stale sources). |
JOB STATUS / JOB LIST |
Poll one job's state and output, or list all jobs. Polling also folds finished gate jobs into the commit gate. Responses carry a hint row with the next step (poll again, or the SHOW MORE grep recipe on failure). |
Background jobs run through a bounded worker pool: at most
FORGEQL_MAX_CONCURRENT_JOBS jobs execute at once (default 2) and the rest
wait Queued in a FIFO queue, starting automatically as slots free — a burst of
JOB START builds is throttled instead of exhausting machine memory.
VERIFY/RUN/JOB STATUS output that exceeds the inline cap is buffered; page or
grep it with SHOW MORE — e.g. SHOW MORE WHERE text MATCHES '^error|-->'
triages a compiler log without re-running the build.
.forgeql.yaml may be in the repo root or in the directory directly above it (sidecar, outside the tracked tree). A source with no such file is answered by the in-memory backend, with no columnar index and no verify or run steps — that is the designed fallback. A file that exists and does not parse is refused instead, naming the file and the parse error: it used to be read as absence, and one mistyped value then gave a session that answered USE with success, symbols_indexed 0, rows carrying no node_id or rev, and "add it under run_steps:" for a step the file plainly declared:
workspace_root: .
verify_steps:
- name: test
command: "cmake --build build && ctest --test-dir build"
timeout_secs: 120
commit_gate: true # COMMIT refused until this passes after the last
# edit; several gated steps must ALL pass
weight: medium # JOB scheduler cost: light | medium | heavy, or
# explicit {cores: 4, memory_mb: 4096, max_seconds: 600}
summary: # inline output window; full log kept for SHOW MORE
direction: tail # head | tail
lines: 40
- name: build-one # typed args: VERIFY build 'build-one' 'core_b1'
command: "cmake -U PROJECT -D PROJECT=$project -B build && cmake --build build"
params:
- name: project
type: ident # ident = [A-Za-z0-9_.-]+, substituted for $project;
# string = bound to stdin, never spliced
weight: heavy
run_steps: # allowlisted templates for RUN '<name>' ['arg']…
- name: grep-cache
command: "grep -m1 $key $FORGEQL_BUILD_DIR/CMakeCache.txt"
params:
- name: key
type: ident
timeout_secs: 30
line_budget:
initial: 3000 # starting allowance
ceiling: 9000 # hard ceiling; budget never exceeds this
recovery_base: 200 # lines credited per recovery (halved on repeats)
recovery_window_secs: 30
warning_threshold: 250 # warn agent when budget falls below this
critical_threshold: 50 # critical state — caps SHOW LINES output
critical_max_lines: 20 # max lines returned in critical state
idle_reset_secs: 120 # auto-delete budget file after idle gap; 0 = neverSteps and templates are frozen at USE, so a later edit cannot tamper with a
command the gate will run.
Every VERIFY/RUN/JOB subprocess receives the session environment contract:
FORGEQL_SESSION_ID (full token), FORGEQL_SOURCE, FORGEQL_BRANCH,
FORGEQL_ALIAS, FORGEQL_WORKTREE (absolute path of the session worktree —
scripts must build this tree, never a hardcoded checkout), and
FORGEQL_BUILD_DIR (a per-worktree build directory so concurrent sessions
never share build artifacts, e.g. cargo --target-dir $FORGEQL_BUILD_DIR).
Typed parameters: a step declares positional parameters in its params
list; the call site passes values in the same order and the engine validates
count and type before running anything.
verify_steps:
- name: build-one
command: "cmake -U PROJECT -D PROJECT=tresos/$project -B $FORGEQL_BUILD_DIR && cmake --build $FORGEQL_BUILD_DIR"
params:
- name: project
type: ident
run_steps:
- name: annotate
command: "tee -a $FORGEQL_BUILD_DIR/notes.txt"
params:
- name: note
type: stringVERIFY build 'build-one' 'core_b1'
RUN 'annotate' 'free text; quotes & spaces are fine'An ident argument must match [A-Za-z0-9_.-]+ and replaces every $name
occurrence in command — no shell metacharacter can pass validation, so a
value can never inject shell syntax. A string argument is never placed
in the command line: all string args are newline-joined in declared order and
bound to the subprocess stdin. Wrong arity or a malformed value fails
before the command starts.
Line budget: when line_budget is present, each session tracks how many source
lines the agent has consumed. Budget status (remaining/ceiling (delta)) is returned
in every MCP response via the line_budget metadata field. Budget files are persisted
to .budgets/{source}@{branch}.json under the ForgeQL data directory. Expired files
are auto-deleted on the next USE via sweep_expired().
Result budget: one FIND symbols may build about 1.34 million result
rows — a 2 GiB budget divided by the working cost of a row, plus the one segment
being materialised when it trips, since the bound is tested once per segment
rather than once per row. Past that the query is refused, never truncated: a
partial answer that does not announce itself is the silent false negative the
completeness guarantee exists to prevent, so the error names the remedy instead
— narrow the scan with IN 'path/**' or a more selective WHERE.
LIMIT k usually completes instead, at any k, with or without an ORDER BY
and with or without an OFFSET, because the page is chosen over compact row
views read from the segment columns and only LIMIT + OFFSET rows are ever
built: every segment is still scanned, tested and counted, so the answer is the
true page over the whole corpus with a total counting every row that matched.
A view is 48 bytes against about 1,600 for the row it stands for, so the same
2 GiB expresses about 44.7 million of them — which is why the bound a bounded
page now meets is the one on what it delivers rather than the one on what it
searches. That route wants no GROUP BY and no HAVING, no two segments of the
index built from one source path, and two conditions that are not the same
condition. Every field the WHERE names must be one each segment the query
selects either answers from a column or holds no column for at all — and no
MATCHES/NOT MATCHES operator is, whatever field it names, a regex being
compiled once for a batch of built rows rather than once per row. Absence is an
answer: where no column holds the field, the row that segment would build
carries it in neither its struct nor its enrichment map, so both readings agree
on nothing and the predicate is false for every row of that segment — !=,
NOT LIKE and NOT MATCHES as much as =, LIKE and MATCHES, a missing value
failing an operator rather than passing it. That segment contributed nothing before either; what has gone
is the rows built to find out, and with them the rule that one segment lacking
an enrichment column took the page off row views for every segment that had it.
A segment carrying an enrichment column named after a struct-backed field bars
nothing either: the built row reads such a name from its own struct under one
kind of operator and from that column under the other, and a view follows it to
whichever of the two the built row would have read. What does bar a field is
body and role, which are written onto a row
after its columns are read — body out of the file as the row is materialised,
role by the read pass that finds an occurrence site — so for those two a
missing column is not a missing value, and both the WHERE and the ORDER BY
wait for the row. The ORDER BY field asks a different question, not a weaker
one: it need only be one a row view reads the same way the row it would build
reads it, which is every field except the five a built row can come to carry
from outside its columns — usages, node_id and count, plus body and
role. A shadow is no bar to ordering, because the ranking reads a struct-backed
name from the fixed column whatever a same-named enrichment column holds; and a
field a segment simply does not carry is no bar either, both readings agreeing it
is absent, which is what makes ordering on an enrichment field reachable at all.
Where it declines, a running top-K trim over built rows still holds the working
set to a few thousand rows for k no greater than 1000 with no OFFSET, no
GROUP BY and no HAVING; and where neither applies, every matching row is
built and this budget is what refuses the scan.
With no ORDER BY the ordering is the (name, line, path, fql_kind) tie-break
the pipeline sorts by anyway, so a bare LIMIT k asks for the k smallest rows
under it rather than for the first k the scan reaches. That tie-break ends in
fql_kind, completing the duplicate-collapse key, so two rows the answer tells
apart never compare equal: the page is fully decided by the ordering, on every
run and at every k.
Outside all of that nothing is trimmed and every matching row is materialised —
unless the ascending name stream claims the clause-free shape (no WHERE, no
IN/EXCLUDE, unique source paths, the asked-for rows within the result
budget; a session's uncommitted edits are merged into the stream from their own
sorted name indexes rather than declining it), which reads limit + offset keys
of the name index instead, and unless the grouping is one the index counts
rather than scans — GROUP BY fql_kind, GROUP BY file,
and a GROUP BY on an enrichment field the segments post per value, each under
the gates named below, none of which materialises a row at all —
and the scan can be refused where the old fetch cap
let it complete with a wrong answer. A HAVING is deliberately excluded from every
page chosen before the whole answer is in hand — it runs after the
page is cut, so a query carrying one is refused here rather than answered from a
page chosen before the predicate ran. The same is true where two segments of the
index were built from one source path: a segment collapsing its own duplicates
is then not the whole collapse, nothing is trimmed, and the scan is refused.
One caveat, and it is now confined to the one place that still stops reading
early: duplicate rows are collapsed after the name-index streams, so where
enough rows agreeing on name, fql_kind, path and line sit inside the window
a stream read, its page could come back shorter than k. A stream now declines
such a page and hands the query to the full scan, so the shape survives only as
the reason that hand-back exists. No other site carries it — the page cut from
row views, the running trim over built rows and the bounded choice a segment
makes over its own rows all collapse duplicates before they shed anything, which
crates/forgeql-core/tests/topk_trim_before_dedupe.rs now enforces rather than
reproduces.
A bare LIMIT is no longer a lesser form. It used to bound the scan by
truncating it — an OFFSET paged past rows that were never fetched, and total
was the returned row count rather than the number that matched, because nothing
counts what was never read. Four expect_fail cases in
crates/forgeql/tests/golden/clause_pipeline.json pinned that, and they are now
enforced. Both forms report the true size of the answer, the trim counting the
rows it discards. The name streams report it too: their total is the sum of
the per-segment deduplicated row counts stored at overlay build time (a
kind-filtered stream reads its bitmap cardinality), and on an index holding two
segments built from one source path — where those stored counts would
double-count — the streams decline and the full scan answers. A bare LIMIT k
with no ORDER BY rides the same ascending stream, since the default ordering
starts with name, so the shortest orientation query reads k keys of the
name index rather than every row of the corpus. FORGEQL_FIND_MAX_ROWS
overrides both bounds, in rows, and 0 disables them: one variable, because
both count rows, and two defaults, because a row costs about 1,600 bytes once
built and 48 while it is only carried. Setting it therefore tightens the carried
bound by about 33x more than it tightens the built one.
Separately, the candidate row IDs a scan holds before it builds anything have
their own bound — about 537 million, the same 2 GiB against four bytes a row ID
rather than 1,600 a row. FORGEQL_FIND_MAX_ROW_IDS overrides it and 0
disables it. A LIMIT does not shrink that set: it bounds what is delivered and
built, never what is searched.
The row budget is roughly 3.7x lower than the five million rows it replaced, so a
scan that used to complete can now be refused — a reachability change, not a
restatement. The case to watch is a GROUP BY no fast path accepts: its answer
is a handful of rows, but it materialises every matching row to get there. Three
groupings are counted from the index instead — GROUP BY fql_kind, GROUP BY file, and a GROUP BY on an enrichment field the segments post per value — and
all three want no uncommitted rows in the session, no two segments of the index
built from one source path, and a HAVING/ORDER BY that names only count or
the grouped field, since a group row counted this way carries those two and
nothing else and a predicate on any other name would be false on every group.
One that names anything else is answered by the scan, and this budget applies
there too.
What they make of a WHERE is the gate you will actually meet, and it is not the
same for all three. A stored cardinality counts a value over whole segments and
cannot be narrowed to the subset a predicate selects, so
WHERE fql_kind = 'function' GROUP BY naming is scanned like any other grouping
— and on a large enough corpus refused — and GROUP BY fql_kind refuses a
WHERE outright for the same reason. GROUP BY file is the exception: its
groups are the segments themselves, so fql_kind = '<value>' and
name = '<value>' are intersected against them and still ride the counted
route. Those two are the whole admitted set, and the reason is that their
postings are the two intersected with each segment's canonical rows at build —
the kind postings always were, the name postings now are — so counting them
counts answer rows. A name PATTERN is admitted at no literal length: a counted
path never opens a row, so whatever a tier proposes is what gets counted, and
the trigram tier only proposes — a literal under three bytes leaves every row a
candidate, and a literal the tier answers still proposes a superset. Such a
query is answered
by the scan, which decides each row by reading it — an ordinary scan, so this
budget applies to it and on a large corpus it can be refused where the counted
route returned a number; narrow it with IN/EXCLUDE, since a LIMIT cannot
help a GROUP BY. The counts it returns are the counts a WHERE line >= 0
beside the same grouping returns.
The enrichment one asks for two things beyond the WHERE: the field must
have survived the overlay's per-field value budget, and none of the segments the
query actually selects may store its column without posting it — a segment the
globs exclude is not asked, since its rows are in neither the counts nor the
total. IN and EXCLUDE are welcome throughout — a segment is one
source path, so the globs select whole segments — and they narrow the reading as
well as the answer.
For a counted grouping — whichever of the three answered — counted or
scanned gives the same groups and the same sizes: the stored
cardinalities are drawn from the same collapsed rows the scan dedupes to, and
the rows the grouped field says nothing about are the one group keyed by the
empty string either way. What differs is what the delivered row says about
itself: a counted group row is named by the grouped value, where a scanned one
is the first row of its group and reports that row's own name, path and line —
so a kind group reads comment counted and reads the first comment's text
scanned. The page order follows from that, wherever the clause does not decide
it: with no ORDER BY, or where an ORDER BY count ties, the two routes sort
the same groups differently, and under the 20-row default that changes which of
a wide field's groups the page holds, though never total. Write
ORDER BY count DESC and the field, not the page, decides.
The row budget is enforced wherever result rows accumulate without a bound of
their own: the FIND symbols scan over the on-disk index, the union of a
session's uncommitted rows into that scan's answer, the FIND usages site
list on both backends (on the on-disk one it is checked between matching
tiers, so the peak can overshoot the bound by one tier's finds), and the
in-memory backend's scan, trimmed or not — an armed trim holds the retained
window to a few multiples of the LIMIT, which keeps any small page well
clear of the budget and refuses a LIMIT so large that even its trimmed
window outgrows it. The FIND usages site list is the one place the budget
still has to hold the whole answer, because the cut that follows selects whole
files out of it and so cannot bound what is searched; what it no longer holds
is a result row per site — the file selection runs over the sites and only the
ones inside the page are built. FIND files carries no row budget: it does
build a row per workspace entry before any clause runs, but that row is a file
entry and the count is the workspace's file count rather than its symbol count,
and its response is bounded by a 20-row page of its own with an honest total.
That page is a constant rather than the configurable find_limit every other
verb defaults to; the two agree unless a deployment retunes one of them.
EXPORT PATCH -- everything this session committed over its base branch
EXPORT PATCH LAST n -- the last n source-touching commitsWrites the session's commits as git am-ready mbox files under
.forgeql-patches/ in the worktree and returns them inline: a header row
with the exported range, one row per file (absolute path, size, sha256),
then the concatenated patch text (windowed — page with SHOW MORE). Copy a
small patch straight from the response, or fetch the files from the worktree
path; either way, verify the sha256 with sha256sum before git am.
ForgeQL runtime files (.forgeql-* at any depth) are excluded from every
patch, so the export is safe mid-transaction: checkpoint commits that touch
only runtime files produce no patch at all, and a commit mixing source with
runtime files exports only its source part — the series still applies in
order with git am. LAST n counts source-touching commits, so checkpoints
never consume the count. Uncommitted worktree edits belong to no commit and
are never exported; the response says so in a hint when any exist.
The counterpart for uncommitted work is SHOW DIFF.
SHOW DIFF -- file map + hunks for every uncommitted change
SHOW DIFF STAT -- the file map alone (cheapest; no hunk text)
SHOW DIFF [clauses] -- IN / EXCLUDE / WHERE / ORDER BY / LIMIT
SHOW DIFF OF '<commit>' -- diff a commit against its first parent (STAT + clauses too)SHOW DIFF OF '<commit>' reviews a committed hash — the diff of that commit
against its first parent — from any session of the same source, since the commit
lives in the shared repository. STAT and clauses apply exactly as for the
pending diff. Without OF, SHOW DIFF shows the session worktree's uncommitted
changes.
The session worktree's uncommitted diff against HEAD, returned inline.
EXPORT PATCH covers committed work only, so this is the way to see a change
that has not been committed yet — in particular for a pre-commit reviewer
agent, which may have no filesystem access to the worktree at all.
The response leads with the file map — one row per changed file
(status, added, removed, file) — and then the unified-diff text.
Untracked files are included, rendered as whole-file additions: a review
that could not see newly added files would miss the most important part of most
changes. ForgeQL runtime files (.forgeql-* at any depth) are excluded, as in
EXPORT PATCH.
Clause targets. Every clause applies to the per-file rows — path / file,
name, status, added, removed, changed — except WHERE text, which
filters the diff's own lines, exactly as it does for SHOW body and
SHOW NODE. Line filtering runs before the inline cap, so grepping a
50 000-line diff costs no more than grepping a 50-line one.
Output routes through the SHOW MORE ring: the file map arrives inline and the
hunks page from the top.
-- a reviewer's triage, in three cheap queries
SHOW DIFF STAT -- what changed at all?
SHOW DIFF STAT IN 'crates/forgeql-core/**' -- was the engine touched?
SHOW DIFF STAT IN 'doc/**' -- did the docs move with it?
-- then read only what matters
SHOW DIFF IN 'crates/forgeql-lang-text/**'
SHOW DIFF WHERE text MATCHES '^\+.*(unsafe|unwrap)'
SHOW MORE HEAD 40Every command accepts these clauses, and each then either APPLIES a clause or REFUSES it — none is silently ignored. What each verb refuses is stated in the rows below: the line-answering verbs (SHOW body, SHOW context, SHOW signature, SHOW NODE, SHOW LINES, SHOW MORE) refuse ORDER BY, GROUP BY and HAVING; SHOW NODE, SHOW LINES, SHOW MORE and SHOW COMMITS refuse IN and EXCLUDE; every verb but SHOW body, SHOW context and FIND files refuses DEPTH; and CHANGE FILE refuses the block entirely, since it reads none of it.
Multiple WHERE clauses combine with implicit AND. AND is an accepted synonym for a repeated WHERE (e.g. WHERE a = 1 AND b > 2).
Engine applies clauses in this fixed pipeline order, regardless of written order:
IN → EXCLUDE → WHERE → GROUP BY → HAVING → ORDER BY → OFFSET → LIMIT
[WHERE field operator value] …
[HAVING field operator value]
[IN 'glob']
[EXCLUDE 'glob']
[ORDER BY field [ASC | DESC]]
[GROUP BY field]
[LIMIT N]
[OFFSET N]
[DEPTH N]| Clause | Purpose |
|---|---|
WHERE |
Filter rows. Repeatable (implicit AND); AND is an accepted synonym for a repeated WHERE. Works on all field types including dynamic and enrichment fields. A field no row of any shape can answer is refused wherever it is written — node_kind, and the names belonging to another verb's rows: size, depth, extension/ext (FIND files), signature (SHOW signature), marker (a SHOW line row), declaration (SHOW members). The refusal names the field and where it is answered; it is never a zero-row answer, because zero rows is a claim about the corpus and an error is a fact about the query. The same test reaches a value wherever the ENGINE, not the corpus, owns the set of values a field can take — fql_kind, whose vocabulary a language plugin maps its grammar onto rather than extends, and role on FIND usages, minted by the read pass that finds the site — so WHERE fql_kind = 'impl' is refused naming every accepted kind instead of matching nothing, while where the CORPUS owns the set the empty answer is the fact about the code and stays an answer, so guard_kind = 'ifdef' on a corpus holding no such guard still returns nothing rather than an error. The value check reads = and != only, because a pattern names no value: fql_kind LIKE '%_block' is untouched. Beyond that the verbs divide by how many consumers the clause has. FIND symbols, FIND globals, FIND usages, FIND files, SHOW outline, SHOW NODE, SHOW LINES, SHOW MORE, SHOW COMMITS and SHOW DIFF resolve no symbol, so any other field their rows do not carry is refused too and the message lists the ones they do. SHOW members, SHOW callees, FIND callees OF, SHOW body and SHOW context also address a symbol, so their WHERE is split between the two: a predicate the returned rows carry filters those rows, and one they do not carry scopes the lookup instead — SHOW members OF 'Foo' WHERE language = 'cpp' picks the C++ Foo and answers with its members. No predicate is dropped; the one kind that reaches both is a field the rows carry whose value comes from the resolved symbol rather than the row, which today is path on a callees row (every call sits in the resolved function's own file, so it names which function rather than filtering the calls). IN and EXCLUDE scope the lookup the same way on the verbs whose rows have no file of their own — a members row and a source line — instead of filtering those rows to nothing. When nothing satisfies the lookup half the answer is no symbol 'Foo' matches WHERE … — a fact about the workspace, worded so it cannot be mistaken for a refusal. SHOW signature is the one verb with no row set at all: it renders a single line, so its clause can only scope the lookup and a field only a line row carries (text, marker, rev) is refused there. The one thing these verbs cannot tell you is that a name is unknown rather than unmatched: only the indexed FIND verbs see which enrichment columns exist, so on a SHOW a misspelt field reads as a lookup that matched nothing. |
HAVING |
Filter after GROUP BY aggregation. Operates on count, which the grouping pass writes — so count is answerable here and in ORDER BY, and refused in a WHERE, which runs before it. Refused entirely on the verbs that answer with source lines, which never group, alongside ORDER BY and GROUP BY. |
IN |
Restrict to files matching glob pattern. On a verb that names a symbol and whose rows carry no file of their own — SHOW members, SHOW body, SHOW context, SHOW signature — it scopes which symbol was resolved rather than filtering those rows, which it could only ever empty. SHOW NODE, SHOW LINES, SHOW MORE and SHOW COMMITS have rows with no path and resolve no name either, so a glob there has nothing to scope and nothing to match and is refused. |
EXCLUDE |
Remove files matching glob pattern. Repeatable — every EXCLUDE clause applies; a row is dropped when any pattern matches its path. Scopes the lookup, or is refused, on exactly the verbs the IN row names, and on the same grounds. |
ORDER BY |
Sort results. Default ASC. On FIND symbols the field must be one a symbol row resolves — name, fql_kind/kind, node_id, path/file, language/lang, line, usages, count — or an enrichment field or stored extra column (numeric values like shadow_count, escape_count sort numerically). Anything else is refused rather than tied and returned in name order: text/content and node_kind both fall here, as do the FIND files fields size, depth and extension/ext, and signature, marker and declaration, which belong to other verbs' rows. Unlike WHERE it is never split between the rows and a lookup, because no lookup reads it — so on SHOW members and SHOW callees the field must be one the returned rows carry, and SHOW members OF 'Foo' ORDER BY language is refused even though WHERE language = 'cpp' is accepted. The verbs that answer with source lines — SHOW body, SHOW context, SHOW signature, SHOW NODE, SHOW LINES, SHOW MORE — answer in source order and sort not at all, so any ORDER BY there is refused rather than accepted and ignored, which with a LIMIT would have paged from the wrong end. |
GROUP BY |
Aggregate by field. Adds count to each group. Narrower than ORDER BY, because grouping keys a row through its string fields only: on FIND symbols the accepted set is name, fql_kind/kind, path/file, language/lang, node_id, plus any enrichment field or stored extra column. line, usages and count are numeric and are refused; so is any name a symbol row cannot resolve, which would otherwise report one group named by the empty string holding every row. Not split between the rows and a lookup either, on the same grounds as ORDER BY, and refused outright on the line-answering verbs for the same reason. The key column is labelled with the spelling you wrote — GROUP BY file heads it file — while the grouping itself runs on the canonical field, so an alias and its canonical name always produce the same groups. Grouping on the kind is the one case with no key column: the compact layout already groups a symbol listing by kind, so GROUP BY fql_kind and GROUP BY kind both render in that layout. Three groupings are counted from the index rather than scanned — fql_kind, the path, and an enrichment field the segments post per value — and a group row from that route is named by the grouped value and carries count and nothing else: no path, line, node_id or rev, where a scanned group row is the first row of its group, named by that row, and carries all four. On all three the counts and the groups are the scan's, the group of rows the grouped field says nothing about included; what differs is that naming, and the order of a page no ORDER BY decides which follows from it, while total is the same either way. A HAVING or ORDER BY naming a field a group row does not carry is answered by the scan on all three, rather than evaluated against rows that cannot answer it and returned as an empty set — and a query rerouted that way is an ordinary scan, so the result budget can refuse it on a large corpus. The gates deciding which route answers are in Result budget above. |
LIMIT |
Maximum rows returned. Implicit cap of 20 when omitted on FIND and on SHOW COMMITS. On FIND usages without GROUP BY the unit is files, not rows: the cap selects whole files and every site of a selected file is returned. |
OFFSET |
Skip N rows (pagination). On FIND usages without GROUP BY it skips whole files, so a page never splits one file. |
DEPTH |
Read by exactly three verbs: SHOW body (collapse depth), SHOW context (how many context lines) and FIND files (directory tree depth). Refused on every other verb, because nothing there reads it — including SHOW outline, whose rows carry a depth column of their own, so DEPTH 2 looked like a request for a depth-limited tree and returned the whole one. |
| Operator | Meaning |
|---|---|
= |
Exact equality |
!= |
Not equal |
LIKE |
SQL wildcard: % = any sequence, _ = any single char (case-insensitive) |
NOT LIKE |
Negated LIKE |
MATCHES |
Regex match (Rust regex crate syntax, case-sensitive by default; use (?i) for case-insensitive) |
NOT MATCHES |
Negated regex match |
> >= < <= |
Numeric comparison |
A row that does not carry the field fails every operator naming it, so a
negation is not a way to reach the rows that lack it: WHERE naming != 'x',
NOT LIKE and NOT MATCHES all return only rows that have a naming to
differ from. That does not hold for the four fields declaring a stamp-only
default (has_todo, has_escape, has_shadow, is_recursive): a function
row in a language the enricher ran on carries no column and still resolves to
'false', so it answers != 'true', NOT LIKE 'tru%' and NOT MATCHES 'true'
as well as = 'false'. The other exception is a pattern operator handed a
value it
cannot use: NOT LIKE or NOT MATCHES with a non-string value passes before
any field is read. A regex that does not compile is not in that set — it is
refused, on MATCHES and NOT MATCHES alike. != is not in that set either
and fails on a missing value whatever its value type.
| Value syntax | Type |
|---|---|
'text' |
String (single-quoted) |
"text" |
String (double-quoted) |
bare_value |
Unquoted string — alphanumeric, _, :, -, ., / (where quoting is optional) |
42 |
Integer |
-10 |
Signed integer |
true / false |
Boolean (reserved) |
Quoting rules: CHANGE … MATCHING and COMMIT MESSAGE require explicit quotes
(content may contain spaces). CHANGE FILE paths require explicit quotes for mutation
safety. All other positions accept bare values or either quote style.
Applies to: FIND symbols, FIND usages OF, FIND callees OF
| Field | Type | Description |
|---|---|---|
name |
string | Symbol name |
fql_kind |
string | Universal kind: function, class, struct, enum, variable, field, and the rest. The engine owns this set — a language plugin maps its grammar onto these names rather than adding to them — so a kind outside it is refused naming every accepted one, not answered with a zero. One value has two spellings: a row nothing maps is stored with the empty kind and RENDERED by SHOW outline as unknown. Both are inside the set, both are served, and both answer the same rows and the same total — the predicate is spelled to the stored value before it reaches a reader, the empty kind has a posting like every other kind, and a row whose kind is empty reports it as the value it is, so a scan's NOT MATCHES keeps that row too. An empty answer on this field is a fact about the corpus, for these two values as for every other, on the verbs whose rows carry a kind at all — a FIND usages row is one line of one file and carries none, so this field is accepted there and matches nothing whichever spelling is written, exactly as it did before those two values were served. |
language |
string | Language name: cpp, rust, python, etc. |
path |
string | Relative file path (also used by IN/EXCLUDE globs) |
line |
integer | 1-based start line |
usages |
integer | Workspace-total count of role = 'code' sites only, aggregated from the reference index at index time — on every row EXCEPT a local-scope variable, which carries no usages value at all, because its name identifies nothing outside the block declaring it and the engine does not resolve scoped references, so the number would be the corpus-wide count of the NAME. ORDER BY usages DESC and WHERE usages > N are real queries, not heuristics, on every row that has a value; a row that has none matches NO usages predicate (= 0 included, so the dead-code recipe no longer sweeps in every local binding), ranks behind every valued row under ORDER BY usages in either direction, and renders an empty metric column rather than 0. A function PARAMETER is the one local-looking row that still carries its name's count — the scope enricher writes scope only on the declaration kinds its language declares, so a row it never examined is not a row known to be local. The count deliberately excludes every non-code occurrence — comment, string, build-file-argument and documentation-prose sites today — so it is smaller than the total of FIND usages OF for the same name: this column ranks symbols by how much code depends on them, not by how often the name is written. |
role |
string | On FIND usages rows only: what the name was written in — code, comment, string, config, doc and text (see the role table above). WHERE role = 'code' narrows to references the compiler resolves; GROUP BY role sizes a rename by kind. Those six, plus the empty string the renderer prints where a site carries no role at all, are the whole set — anything else is refused naming them rather than answered with a zero, the same treatment fql_kind gets and for the same reason: the engine owns this value set, not the corpus. != is read the same way as =. The empty role only ever appears on the in-memory backend, which tags no site: there every role = … filter is empty, so an empty answer to one is a fact about the backend rather than about the corpus. |
Filtered-field projection: when a WHERE clause targets a non-core field —
numeric, string, or boolean (e.g. WHERE has_assignment_in_condition = 'true',
WHERE member_count > 10) — that field's value is projected into the output
rows, so the value you filtered on is always visible in the result.
Applies to: SHOW outline OF
| Field | Type | Description |
|---|---|---|
name |
string | Symbol name |
fql_kind / kind |
string | Universal kind (e.g. function, class). A node no language maps is stored with no kind at all and rendered here as unknown — not as its raw tree-sitter name — so WHERE fql_kind = 'unknown' on an outline matches what the outline printed. On a symbol row the same node reads as the empty kind: GROUP BY fql_kind groups it under the empty name. Both spellings are accepted values and both select the same rows on every verb, this one included — an equality is spelled to the stored value before it reaches a reader, and the outline's rendered unknown is spelled to match it — so WHERE fql_kind = '' and = 'unknown' answer the same outline rows and the same FIND total. A LIKE or MATCHES carries a pattern rather than a value and is matched against the spelling the verb rendered. A kind that is neither is refused naming every accepted one, because the engine and not the corpus owns this set. Rows are printed under fql_kind; kind filters and sorts the same column. |
path / file |
string | Relative file path |
line |
integer | 1-based start line |
depth |
integer | Nesting depth in the structural tree (0 = top-level). Filterable and sortable. |
node_id |
string | Stable node handle (present once the file has been indexed/reindexed). |
Applies to: SHOW members OF
| Field | Type | Description |
|---|---|---|
fql_kind / kind / type |
string | Member kind (field, method, enumerator). Rows are printed under fql_kind; the other two filter and sort the same column. |
text / declaration / name |
string | Declaration text |
line |
integer | 1-based line number |
Applies to: FIND files
| Field | Type | Description |
|---|---|---|
path / file |
string | Relative file path. A directory row ends in / (src/) — that trailing slash is the only marker, so WHERE path LIKE '%/' lists directories and NOT LIKE excludes them. |
name |
string | Bare file name (e.g. Kconfig, CMakeLists.txt). Works with =, LIKE, MATCHES. |
extension / ext |
string | Extension without . (empty for extension-less files and directories) |
size |
integer | File size in bytes; for a directory, the total bytes of the files beneath it, any depth. A directory row exists when at least one file lies beneath it — an empty directory (only a session can create one; git cannot commit it) is addressable by its handle but not listed. |
depth |
integer | Directory depth from workspace root |
count |
integer | Directory rows only: the number of files beneath, any depth. Carried in format=JSON; the compact CSV schema is unchanged. |
node_id |
string | The path's bare-hex handle (n<hex>) — on every path row, so a listed file or directory is actionable without a second lookup |
rev |
string | Version stamp for the path: a file's is the SHA-256 of its bytes, a directory's is a membership XOR over the paths underneath it. Pass it to IF REV. |
has_error |
"true" / "false" |
The file did not parse as its declared language — it holds at least one error_scope = 'root' region. This is the .c that is not really C, or the JSON with an unbalanced brace. |
error_count |
integer | Number of root regions in the file |
parse_coverage |
integer | Percent of the file's bytes tree-sitter parsed (0–100) |
All three are derived on demand. They cost an index scan, so they are computed only when a
clause names them — a plain FIND files never pays for it, and each column appears in the output
only when you asked about it. An unpopulated entry matches neither has_error = 'true' nor
= 'false', so a query that never asked can never be misread as a clean bill of health.
An error row is not damage. tree-sitter parses C without running the preprocessor, so it
cannot know that an unknown identifier in declaration-specifier position is a macro:
static ALWAYS_INLINE void f(void) yields an ERROR beside the return type while f itself
indexes perfectly as a function with correct boundaries. Zephyr holds 21 681 such regions —
16 480 of them nested inside a node that indexed fine — and essentially none of them is
damage. That is why has_error counts only root regions (207 in Zephyr): a signal that fires
on idiomatic kernel C is not a signal. Use error_scope for the raw picture and parse_coverage
for magnitude.
Triage a repository before mutating anything in it:
FIND files WHERE has_error = 'true' -- files that did not parse at all
FIND files WHERE parse_coverage < 50 ORDER BY parse_coverage ASC -- mostly-unparsed files
FIND symbols WHERE fql_kind = 'error' WHERE error_scope = 'root' -- the regions themselvesThe engine reports where the parse broke and passes no judgement; it never repairs anything (P1) — it hands you a handle and you do the repair:
FIND symbols WHERE fql_kind = 'error' WHERE error_scope = 'root' -- get the node_id
SHOW NODE '<id>' -- read the region
CHANGE NODE '<id>' WITH '…' -- repair it yourselfRagged CSV rows and duplicate JSON keys are deliberately not errors — they parse fine. They surface through block-group splitting instead.
Applies to: SHOW DIFF
One row per changed file in the session worktree's uncommitted diff.
| Field | Type | Description |
|---|---|---|
path / file |
string | Path relative to the worktree root |
name |
string | Bare file name |
status |
string | A added (incl. untracked), M modified, D deleted, R renamed, T typechange |
added |
integer | Count of + lines in this file's hunks |
removed |
integer | Count of - lines in this file's hunks |
changed |
integer | added + removed — sort by it to find the biggest edits |
WHERE text … does not filter these rows: it filters the diff's own source
lines instead (see Source Line Fields), so a SHOW DIFF
can select files by path or size and grep their hunks in one statement.
Applies to: SHOW body OF, SHOW LINES n-m OF, SHOW context OF, SHOW NODE, and SHOW DIFF (where it filters the diff's own lines)
| Field | Type | Description |
|---|---|---|
text |
string | Line content (supports LIKE, MATCHES, =) |
line |
integer | 1-based line number |
marker |
string | Prefix marker (e.g. +, - in diff output) |
node_id |
string | Stable handle of the innermost node containing the line |
rev |
string | Edit fingerprint of that node — feeds a mutation's IF REV |
Filtering runs before the implicit DEFAULT_SHOW_LINE_LIMIT cap, so the full function body is searched even when not all lines are returned.
Applies to: SHOW callees OF
| Field | Type | Description |
|---|---|---|
name |
string | Called symbol name |
path / file |
string | File containing the call |
line |
integer | 1-based line number of the call |
Auto-extracted from tree-sitter grammar. Queryable with WHERE without recompiling.
| Field | Availability | Description |
|---|---|---|
type |
C/C++ | Return type text |
value |
C/C++ | Initial value (preproc_def, init_declarator) |
declarator |
C/C++ | Full declarator with pointer/reference qualifiers |
parameters |
C/C++ | Parameter list text |
If a field does not exist on a row, WHERE evaluates to false (SQL NULL semantics).
Numeric coercion — dynamic fields are stored as strings. WHERE value >= 1000 parses the stored text as an integer; if parsing fails, the predicate silently evaluates to false.
Computed at index time. Queryable with WHERE like any other field.
Naming convention for enrichment fields:
| Prefix | Meaning | Example |
|---|---|---|
is_ |
Intrinsic property of the symbol itself | is_recursive, is_exported, is_const, is_magic |
has_ |
The symbol's body contains something | has_shadow, has_escape, has_fallthrough, has_cast |
_count |
Numeric count (often paired with has_ or is_) |
shadow_count, cast_count, recursion_count, param_count |
Rule of thumb:
is_Xdescribes what a symbol is;has_Xdescribes what it contains. For example, a functionis_recursive(it calls itself) andhas_shadow(variables inside it shadow outer ones).
| Field | Applies to | Description |
|---|---|---|
naming |
all named symbols | camelCase, PascalCase, snake_case, UPPER_SNAKE, flatcase, other |
name_length |
all named symbols | Character count of symbol name |
| Field | Applies to | Description |
|---|---|---|
comment_style |
comment |
doc_line (///), doc_block (/** */), block (/* */), line (//) |
has_doc |
function |
"true" if preceded by a doc comment |
| Field | Applies to | Description |
|---|---|---|
num_format |
number |
dec, hex, bin, oct, float, scientific |
is_magic |
number |
"true" unless the literal is the immediate child of a declaration initializer or enumerator, which only C and C++ configure — int x = 3; is exempt, int x = 3 * 2; is not — or is a zero index whose immediate parent is the plugin's configured subscript node, which holds in C, Rust and Python (arr[0], dp[0][0]). The value itself grants no exemption and #define bodies index no numbers at all. Known defect: the subscript exemption does not fire under the C++ grammar — .cpp, .cc, .cxx, .h, .hpp, .hxx, .ino — which interposes a node between the index and the subscript, so arr[0] reads as magic there, including in the headers of a pure-C project. Repairing it will change these values |
num_suffix |
number |
Type suffix: u, l, ll, ul, ull, f, ld |
suffix_meaning |
number |
Semantic meaning of suffix: unsigned, long, float, etc. |
has_separator |
number |
"true" if contains digit separators |
num_value |
number |
Raw text of the literal |
| Field | Applies to | Description |
|---|---|---|
condition_tests |
if, while, for, do |
Number of boolean sub-expressions |
paren_depth |
if, while, for, do |
Max parentheses nesting |
condition_text |
if, while, for, do |
Normalized condition skeleton — operands (the nouns) are alpha-renamed to a, b, … for shape comparison, while operators are kept verbatim because the operator is the signal: &&, ||, !, the comparisons, the bitwise ops, and the assignment = (the =-for-== smell) all survive, so x==5||x==6 → a==b||a==c and if ((x = a + b) > 0) → ((a=b)>c). The one exception is value-only arithmetic (+ - * / %) on the right of an assignment, which folds into a single operand (x = a + b → a=b). NOT raw source text. Grammars without a condition field (CMake, Make, C++ range-for) name rows by the construct's raw first line instead. |
has_catch_all |
switch |
"true" if switch has a catch-all case |
catch_all_kind |
switch |
Kind of catch-all (e.g. "default") when present |
for_style |
for |
"traditional" or "range" |
has_assignment_in_condition |
if, while, for |
"true" if condition contains = (not ==) |
mixed_logic |
if, while, for |
"true" if && and || appear at the same top-level without explicit parentheses (MISRA Rule 12.1) |
dup_logic |
if, while, for, do |
"true" if condition contains duplicate sub-expressions in &&/|| chains |
branch_count |
function |
Total control-flow branch points |
enclosing_fn |
if, switch, for, while, do |
Name of the containing function — enables SHOW body OF directly from a CF-enrichment query result |
| Field | Applies to | Description |
|---|---|---|
increment_style |
increment |
"prefix" or "postfix" |
increment_op |
increment |
"++" or "--" |
compound_op |
compound_assignment |
+=, -=, *=, /=, %=, &=, |=, ^=, <<=, >>= |
operand |
compound_assignment |
Left-hand side text |
shift_direction |
shift_expression |
"left" or "right" |
shift_amount |
shift_expression |
Right-hand operand text |
operator_category |
increment, compound_assignment, shift_expression |
"increment", "arithmetic", "bitwise", "shift" |
| Field | Applies to | Description |
|---|---|---|
lines |
function, struct, class, enum |
Line span |
param_count |
function |
Parameter count |
return_count |
function |
return statement count |
goto_count |
function |
goto statement count |
string_count |
function |
String literal count |
throw_count |
function |
throw statement count |
member_count |
struct, class, enum |
Member/enumerator count |
is_const |
function, variable |
"true" if const present |
is_volatile |
function, variable |
"true" if volatile present |
is_static |
function |
"true" if static |
is_inline |
function |
"true" if inline |
is_override |
function |
"true" if override |
is_final |
function |
"true" if final |
visibility |
field (class members) |
"public", "private", "protected" |
| Field | Applies to | Description |
|---|---|---|
cast_style |
cast |
"c_style" (named C++ casts not indexed in tree-sitter-cpp 0.23) |
cast_target_type |
cast |
Target type text |
cast_safety |
cast |
"safe", "moderate", or "unsafe" |
has_cast |
function |
"true" if the function body contains any cast expressions |
cast_count |
function |
Number of cast expressions in the body |
| Field | Applies to | Description |
|---|---|---|
has_repeated_condition_calls |
function |
"true" if same call in 2+ conditions |
repeated_condition_calls |
function |
Comma-separated function names |
null_check_count |
function |
Count of null-check patterns |
duplicate_condition |
if, while, for, do |
"true" if same condition skeleton exists elsewhere in function |
| Field | Applies to | Description |
|---|---|---|
scope |
variable |
"file" (top-level) or "local" (inside function/block) |
storage |
variable |
"static", "extern", or absent |
binding_kind |
variable |
"function" or "variable" |
is_exported |
variable, function |
"true" for file-scope declarations without static storage (C/C++) or pub functions (Rust) |
| Field | Applies to | Description |
|---|---|---|
body_symbol |
field (methods) |
Qualified name linking to out-of-line definition (e.g. Class::method) |
member_kind |
field |
"method" or "field" |
owner_kind |
field |
fql_kind of enclosing type (e.g. class, struct) |
Produced by the indexing walk rather than an enricher, because it needs the ancestor chain and an ancestor walk per node would be quadratic on the wide arrays config files contain.
| Field | Applies to | Description |
|---|---|---|
key_path |
rows in a format that nests pair inside pair — JSON and YAML |
Dotted chain of enclosing pair keys, with the row's own key appended when the row is itself a pair — e.g. manifest.defaults.remote. Sequence position is never encoded: jobs.clang-build.steps.uses covers every step. Absent on rows with no pair ancestor, so code-language rows never carry it. TOML and INI carry only the row's own key, because their hierarchy level is an object (a [table], a [section]) rather than a nested pair — two opt-level keys under different [profile.*] tables are not yet told apart |
key_path is what tells otherwise identical keys apart. A Zephyr west manifest
holds twelve pair rows all named remote; only one of them is the default.
-- The manifest default, not the eleven per-project overrides
FIND symbols WHERE key_path = 'manifest.defaults.remote' IN 'west.yml'
-- Every container image reference in a workflow, at any job
FIND symbols WHERE key_path LIKE 'jobs.%.container.image' IN '.github/workflows/**'Data-flow enricher that measures how far local variable declarations are from their first use. Excludes parameters, globals, and member variables.
| Field | Applies to | Description |
|---|---|---|
decl_distance |
function |
Sum of (first-use line − declaration line) for locals with distance ≥ 2 |
decl_far_count |
function |
Count of local variables whose first-use is ≥ 2 lines after declaration |
has_unused_reassign |
function |
"true" when a local is reassigned before its previous value was read (dead store) |
Detects local variables that escape their declaring function — via return, address-of (&), or pointer/array aliasing.
| Field | Applies to | Description |
|---|---|---|
has_escape |
function |
"true" if any local escapes; "false" answers every function the enricher examined and did not mark — which in this field means C, C++ and Rust only, the languages declaring an address-of operator, since without one the enricher returns before reading anything and a Python, CMake or Makefile function answers neither value. One of only four fields whose negative is answerable at all; the other stamp-only booleans (has_fallthrough, is_const, is_async, …) still answer nothing for it |
escape_count |
function |
Number of distinct escaping locals |
escape_vars |
function |
Comma-separated names of escaping locals |
escape_tier |
function |
Severity: 1 (return), 2 (address-of), 3 (pointer/array alias) |
escape_kinds |
function |
Comma-separated escape mechanisms (e.g. "return,address_of") |
Detects variables declared in inner scopes that shadow an outer-scope variable or parameter of the same name.
| Field | Applies to | Description |
|---|---|---|
has_shadow |
function |
"true" if any inner variable shadows an outer one; "false" answers every function the enricher examined and did not mark — C, C++, Python and Rust only. This enricher reads no language capability, unlike the other three; what confines it is the grammar, since it walks the function's body node and a CMake function or Makefile rule carries no such node, so the walk never starts and those rows answer neither value. One of only four fields whose negative is answerable at all; the other stamp-only booleans (has_fallthrough, is_const, is_async, …) still answer nothing for it |
shadow_count |
function |
Number of shadowing declarations |
shadow_vars |
function |
Comma-separated names of shadowed variables |
Note —
#ifdefblocks: The ShadowEnricher uses structural guard exclusivity (guard_group_id+guard_branch) to suppress false positives from#ifdef/#elsesiblings. Variables declared in opposite arms of the same guard group are not reported as shadows.
Detects function parameters that are never referenced in the function body.
| Field | Applies to | Description |
|---|---|---|
has_unused_param |
function |
"true" if any parameter is unused |
unused_param_count |
function |
Number of unused parameters |
unused_params |
function |
Comma-separated names of unused parameters |
Detects switch/case statements where a non-empty case falls through to the next without break or return. Empty cases (intentional grouping) are not flagged.
| Field | Applies to | Description |
|---|---|---|
has_fallthrough |
function |
"true" if any case falls through |
fallthrough_count |
function |
Number of fallthrough cases |
Detects direct (single-function) self-recursion. Does not detect mutual recursion (A→B→A).
| Field | Applies to | Description |
|---|---|---|
is_recursive |
function |
"true" if the function calls itself; "false" answers every function the enricher examined and did not mark — which in this field means C, C++, Python and Rust only, the languages declaring a call expression, so a CMake function or Makefile rule answers neither value. One of only four fields whose negative is answerable at all; the other stamp-only booleans (has_fallthrough, is_const, is_async, …) still answer nothing for it |
recursion_count |
function |
Number of self-call sites in the body |
Detects TODO, FIXME, HACK, and XXX markers in comments inside a function — its body, and any comment attached directly to the function node, which is where a grammar puts a comment written as the first line of the body or between the signature and the body. Three comments are outside that region: one preceding the function, which is its doc comment; one between a decorator and the definition it decorates, which belongs to the wrapper even though the row's span folds back over it; and any style the language does not declare as its comment kind, so in Rust // is scanned and /* */ is not. Word-boundary-aware matching avoids false positives.
| Field | Applies to | Description |
|---|---|---|
has_todo |
function |
"true" if a marker comment inside the function is found — its body or a comment attached directly to it, never its doc comment, never one between a decorator and the definition, and only in the raw comment kind the language declares (in Rust, // and not /* */). "false" answers every function the enricher examined and did not mark — which in this field means C, C++, Python and Rust only, the languages declaring a comment kind, so a Makefile rule or CMake function answers neither value because the scan returned before reading its text. One of only four fields whose negative is answerable at all; the other stamp-only booleans (has_fallthrough, is_const, is_async, …) still answer nothing for it |
todo_count |
function |
Total number of marker occurrences, over the same region as has_todo |
todo_tags |
function |
Comma-separated, sorted unique tags found over that same region (e.g. "FIXME,TODO") |
Locates a tree-sitter ERROR region and records how much of the file it consumed. Position and
size only — the engine passes no judgement on whether the region is "bad" (P1).
An ERROR on its own is a poor danger signal. tree-sitter parses C without running the
preprocessor, so static ALWAYS_INLINE void f(void) produces an ERROR beside the return type
while f still indexes correctly as a function. Zephyr holds 21 681 error regions; 16 480 are
nested, and only 207 are root.
| Field | Applies to | Description |
|---|---|---|
error_scope |
error |
"root" — the ERROR is the file: nothing parsed (a .c that is not really C). "file" — loose at top level, nothing named owns it (usually a file-scope macro the parser could not model). "nested" — inside a node the language could name, so an indexed symbol still owns the span and its boundaries are intact. |
error_bytes |
error |
Byte length of the region. Only outermost ERRORs are emitted, so spans never overlap and per-file sums are exact — this is what parse_coverage is derived from. |
FIND symbols WHERE fql_kind = 'error' WHERE error_scope = 'root' -- files that did not parse
FIND symbols WHERE fql_kind = 'error' GROUP BY error_scope ORDER BY count DESCTags every symbol inside a C/C++ #ifdef/#if/#elif/#else block with the
guard condition that controls its compilation. Preprocessor guards are injected
into every indexed row by collect_nodes() — declarations, comments, control
flow and expressions alike, since a row is inside whatever conditional region the
walk is inside. That is what lets a guard be paired with is_magic,
has_catch_all or a control-flow field: asking which magic numbers ship, or
which switch-without-default is compiled only under a config, is a single
query. All seven fields are queryable via WHERE, ORDER BY, and GROUP BY.
Two kinds of row are outside that. An attribute guard (guard_kind = "attribute", e.g. Rust #[cfg]) attaches only to the item it annotates, because
that is its scope — it does not govern a region, so expression rows inside the
item carry no attribute guard. And a block row (any block-group kind:
comment_block, array_block, include_block, macro_block, import_block,
type_alias_block) is a synthetic span rather than a walked node, and carries
no guard.
A region ends at its own closing directive, located by scanning the directives
themselves rather than by trusting where the parser ended the guard node. The
two differ whenever a construct swallows the closing directive — the C idiom
#ifdef __cplusplus / extern "C" { / #endif does exactly that, and the
parser then runs the node on to the next close, which would place the rest of
the header inside a group it never belonged to. Where the directives do not
balance, the parser's span is used unchanged.
| Field | Applies to | Description |
|---|---|---|
guard |
all walked rows | The condition controlling compilation, whitespace-normalised. On an #elif/#else arm it is the accumulated !(c₀) && … && cₖ, not the arm's own condition (e.g. "defined(CONFIG_SMP)", "!X", "Y && X"). The field is not the kind: WHERE guard = '…' selects rows inside a guarded region, while WHERE fql_kind = 'guard' selects the #ifdef/#if/#elif directive itself |
guard_defines |
all walked rows | Comma-separated symbols that must be defined for this branch |
guard_negates |
all walked rows | Comma-separated symbols that must be undefined for this branch |
guard_mentions |
all walked rows | All symbols mentioned in the condition (superset of defines + negates) |
guard_group_id |
all walked rows | Opaque u64 identifying the #ifdef/#if block; all arms of it share the same ID. Derived from the file path and the position of the opening directive, so the same content yields the same ID in any checkout and across restarts — the number itself carries no meaning and is not an ordinal |
guard_branch |
all walked rows | Ordinal within the group: 0 = if, 1 = first elif/else, 2 = second, … |
guard_kind |
all walked rows | "preprocessor" (C/C++ #if family) | "attribute" (Rust #[cfg]) | "heuristic" (a pattern-matched if, e.g. Python if TYPE_CHECKING:) |
"All walked rows" excludes the synthetic block kinds named above: a block row
(comment_block, array_block, include_block, macro_block,
import_block, type_alias_block) spans its members rather than being walked
as a node, and carries no guard field at all.
Guard field decomposition rules:
| Source | guard |
guard_defines |
guard_negates |
guard_mentions |
|---|---|---|---|---|
#ifdef X |
"X" |
"X" |
"" |
"X" |
#ifndef X |
"!X" |
"" |
"X" |
"X" |
#if defined(A) && defined(B) |
"defined(A) && defined(B)" |
"A,B" |
"" |
"A,B" |
#else of #ifdef X |
"!X" |
"" |
"X" |
"X" |
Nested #ifdef X inside #ifdef Y |
"Y && X" |
"Y,X" |
"" |
"Y,X" |
#elif defined(B) after #if defined(A) |
"!defined(A) && defined(B)" |
"B" |
"A" |
"A,B" |
#else after #if defined(A) / #elif defined(B) |
"!defined(A) && !defined(B)" |
"" |
"A,B" |
"A,B" |
#else of #if defined(A) || defined(B) |
"!(defined(A) || defined(B))" |
"" |
"A,B" |
"A,B" |
#if A || B nested inside #ifdef Y |
"Y && (A || B)" |
"Y" |
"" |
"Y" |
An arm is reached only when every arm before it was false, so it carries their
negations. This is what makes the negative-form query above answer correctly on a
chain: without it, the #else of a three-arm chain would claim only the last
arm's condition is false, and match builds the arm never compiles in.
Two limits on that decomposition, both deliberate. Negating a condition that
mixes && yields a disjunction, where no identifier is individually required
either way — such an arm contributes nothing to guard_defines/guard_negates
and the raw guard text remains the record. And a conjunct holding a top-level
|| is parenthesised before joining, because && binds tighter: A && B || C
would parse as (A && B) || C, a weaker predicate than the source.
guard is whitespace-normalised — a directive continued across lines with \
groups with the same condition written on one.
Example queries:
-- All code that REQUIRES CONFIG_BT — exact membership.
-- `(^|,)` and `(,|$)` pin the flag to a whole element of the set, so this
-- does NOT also return code guarded by CONFIG_BT_HCI.
FIND symbols WHERE guard_defines MATCHES '(^|,)CONFIG_BT(,|$)'
-- All code compiled when CONFIG_BT is ABSENT
FIND symbols WHERE guard_negates MATCHES '(^|,)CONFIG_BT(,|$)'
-- All code that MENTIONS CONFIG_BT (either direction)
FIND symbols WHERE guard_mentions MATCHES '(^|,)CONFIG_BT(,|$)'
-- Any flag whose name STARTS with CONFIG_BT, deliberately including
-- CONFIG_BT_HCI and friends
FIND symbols WHERE guard_mentions MATCHES '(^|,)CONFIG_BT'
-- Unconditionally compiled code only
FIND symbols WHERE guard = ''
-- Count symbols per guard define
FIND symbols GROUP BY guard ORDER BY count DESC= on a set-valued field means the WHOLE joined value, not membership.
guard_defines, guard_negates and guard_mentions each hold a
comma-joined set, and every operator compares against that joined string:
guard_defines = 'CONFIG_BT' matches only a row guarded by CONFIG_BT and
nothing else, never one guarded by CONFIG_BT && CONFIG_SMP (whose value is
CONFIG_BT,CONFIG_SMP). Use the MATCHES '(^|,)…(,|$)' form above for
membership — it is exact, and it is served by the same index as =, so it
costs no more. LIKE '%CONFIG_BT%' also works but is a substring test: it
matches CONFIG_BT_HCI too, which is occasionally what you want and more
often not.
Structural exclusivity: Two symbols with the same guard_group_id and
different guard_branch are definitively mutually exclusive — they are in
opposite arms of the same #ifdef block. The ShadowEnricher and
DeclDistanceEnricher use this fact to eliminate false positives.
The ID is stable across runs, restarts and checkouts, so it is safe to record one and come back to it. It is not stable across an edit to the file: the opening directive moves, and so does the ID. Compare IDs to group rows, never to identify a group over time.
Enriches macro_call rows with macro definition metadata and best-effort
single-level expansion text. Registered after TodoEnricher in the enricher
pipeline. Requires a MacroTable populated during the two-pass indexing
pipeline.
| Field | Applies to | Description |
|---|---|---|
macro_def_file |
macro_call |
Source file of the resolved macro definition |
macro_def_line |
macro_call |
1-based line of the definition |
macro_arity |
macro_call |
Parameter count ("0" for object-like macros) |
macro_expansion |
macro_call |
Best-effort single-level expansion text |
expanded_reads |
macro_call |
Local variable names read in expanded text |
expanded_has_escape |
macro_call |
"true" if expanded text contains &local escape |
expansion_depth |
macro_call |
Expansion nesting depth (currently always "1") |
expansion_failed |
macro_call |
"true" when macro resolution fails |
expansion_failure_reason |
macro_call |
Reason for failure (e.g. "definition not found") |
Supported languages: C/C++ (CppMacroExpander) and Rust (RustMacroExpander for macro_rules!).
Structured-text and configuration files are indexed like code: every element
gets a stable node_id and the same commands apply — FIND symbols,
SHOW NODE, CHANGE NODE, INSERT BEFORE/AFTER NODE, DELETE NODE. A single
FIND sweep returns Makefile rules, CMake calls, and C functions side by side.
| Format | Files | Indexed as |
|---|---|---|
| XML family | .xml, .arxml (AUTOSAR), .xdm/.epc/.epd (EB tresos), .ecuc, .odx |
Every element is a nested node, named by the cascade below |
| Vector CAN | .dbc |
BO_ messages as object; SG_ signals nested as field; VAL_TABLE_/VAL_ as enum; attributes as pair; EV_ as variable |
| TOML | .toml (Cargo.toml), .lock (Cargo.lock) |
Each pair under its key; each [table]/[[table-array]] by its name/id/key member or header key |
| JSON / YAML | .json, .jsonc, .yaml, .yml |
object/array/pair/comment. A pair is named by its key. A container is named by an identifier-like member (name/id/key/title/alias), else by its key-set skeleton — its sorted keys, comma-joined (uses, name,run) — so a mapping with no name is still addressable. An array/sequence is named after the key of its nearest ancestor pair (steps). A comment is named by its own raw text, so a note such as # do not rename is findable by name and owns a handle like any other node; in YAML a name written inside a comment is additionally recorded as a role = 'comment' occurrence, which JSON does not do. Names never encode a position: a slot-based name would follow the slot rather than the node, and two siblings would trade node_ids when reordered. A run of 8+ adjacent array siblings collapses into one array_block, and in YAML a run of 2+ adjacent comments collapses into one comment_block (below). |
| INI | .ini, .cfg, .editorconfig, .gitconfig |
[section] as object; key = value nested as pair |
| Kconfig | Kconfig (any casing, no extension), *.kconfig |
config X / menuconfig X as macro named X — the definition site of a build flag, which becomes a preprocessor macro in the generated header. Every flag reference is a usage site, so depends on X, select X and if X all answer FIND usages OF 'X'. menu and if carry no row, so an outline lists the flags a file defines and nothing else. Vendor-suffixed files (Kconfig.stm32) are not claimed — the suffix is open-ended. |
| justfile | justfile (any casing, with or without dot) |
Recipes as function; := assignments and alias as variable; set as pair; mod as namespace |
| Make | Makefile/makefile/GNUmakefile, *.mk |
Rules as function named by target list; assignments as variable; define as macro; ifeq/ifdef as if |
| CMake | CMakeLists.txt, *.cmake |
function()/macro() definitions; every command call as call_statement; if/foreach/while as nested control flow |
| Markdown | .md |
Sections, headings, paragraphs, tables, code blocks — each addressable |
| reStructuredText | .rst, .rest |
Sections by title; paragraphs/list items by text snippet; directives as macro_call |
Well-known extensionless file names (justfile, Makefile, Kconfig,
.editorconfig, …) are matched by lowercased file name, leading dot stripped.
XML element naming cascade — each element is named by the first rule that applies:
- An identifier-like attribute:
name,id,key,title, oralias(case-insensitive). - The text of a
SHORT-NAMEchild element (AUTOSAR containers). - The last
/-segment of aDEFINITION-REFchild's text — AUTOSAR ECUC parameter and reference values become findable by their parameter name (e.g.…/CanIfPublicCfg/CanIfPublicTxBuffering→CanIfPublicTxBuffering). - The tag name — anonymous wrapper elements stay addressable as
INSERTanchors.
Attributes are not indexed as separate rows; edit them through their element's node. The practical effect: ECU-configuration formats that normally require GUI tooling can be queried by parameter name and edited by node handle:
FIND symbols WHERE name = 'CanIfPublicTxBuffering' IN 'config/**'
SHOW NODE '<node_id>'
CHANGE NODE '<node_id>(2)' WITH ' <VALUE>true</VALUE>'A run of adjacent same-kind siblings collapses into a single synthetic,
childless block node spanning the whole run. The block is the members'
sibling, never their parent; it exists so a whole run can be read, copied,
moved or deleted with one handle. Blank lines between members do not break a run
(they are not tree nodes). Configured per language via block_groups.
| Language | Members | Block kind | Min run | Split by |
|---|---|---|---|---|
| Rust | comment |
comment_block |
2 | comment style — a /// doc run and a // line run form separate blocks |
| Rust | import |
import_block |
2 | — a run of use declarations |
| C / C++ | comment |
comment_block |
2 | comment style — a /* paragraph and a // run form separate blocks |
| C / C++ | import |
include_block |
2 | — a run of #include directives |
| C / C++ | macro |
macro_block |
2 | — a run of #defines (object-like and function-like share the kind) |
| C++ | type_alias |
type_alias_block |
2 | — a typedef/using run (both map to type_alias) |
| Python | comment |
comment_block |
2 | — (Python has a single comment style) |
| Python | import |
import_block |
2 | — import and from statements share a kind, so a mixed run is one block |
| JSON | array |
array_block |
8 | — |
| YAML | comment |
comment_block |
2 | — (YAML has a single comment style, so runs are not split) |
Members keep their own rows and node ids; the block is added, nothing is
hidden. Its display label is the first member's snippet plus the run length
(["g01_name_eq_stopped", "FIND symbols W… (×201)).
A run is scanned over named siblings, so members separated by anonymous
punctuation still group: JSON array elements are separated by , tokens, and
walking raw siblings would break every run at the first comma.
Why JSON needs it. A JSON document with no keys anywhere — an array of arrays
of strings, e.g. a test corpus — can be named by nothing, so it indexes to zero
rows and is invisible to every FIND, SHOW and CHANGE. Block grouping makes
it addressable: the run becomes one node, and its members are reachable by
node-relative offset.
-- A block is not a structural declaration, so the DEFAULT outline omits it.
-- `ALL` (or an explicit `WHERE fql_kind`) surfaces it:
SHOW outline OF 'crates/forgeql/tests/corpus.json' ALL
-- 2 | array_block | ["g01_name_eq_stopped", "FIND symbols W… (×201)
SHOW NODE '<block>' WHERE text MATCHES 'g07_' -- grep inside; filtering runs before the cap
CHANGE NODE '<block>(42)' WITH ' ["g01_new", "FIND …"],'
DELETE NODE '<block>(40-52)' -- drop a contiguous run of entriesNo new verbs: '<id>(n)' and '<id>(n-m)' offsets already do the work.
Applies to every language, not just structured text.
When tree-sitter cannot parse a span it recovers and produces an ERROR node.
Those regions are now indexed as addressable rows with fql_kind = 'error', so a
broken file is no longer silently, partially indexed.
-- triage BEFORE mutating: is the file already broken?
FIND symbols WHERE fql_kind = 'error' GROUP BY file ORDER BY count DESC
FIND symbols WHERE fql_kind = 'error' IN 'config/**'
-- then read and repair by handle
SHOW NODE '<id>'
CHANGE NODE '<id>' WITH '…'- Only the outermost damage is emitted — a nested
ERRORwould report one wound as several. - Zero-width
MISSINGtokens are not emitted: a row spanning no bytes could be seen but not read or repaired, and a row you cannot act on is worse than no row. - The row's name is the first line of the unparseable text, capped at 60 chars.
The engine maps the damage; it never repairs it. SHOW DIFF's boundary diff,
lines_removed, and this kind are the same move: make the agent see, then let
the agent decide. Note that real-world corpora carry more damage than you would
expect — tree-sitter-c cannot fully parse Zephyr's macro-heavy C, and error is
a top-11 kind by count in its kernel/ tree.
These patterns show ForgeQL capabilities that are non-obvious or combine multiple features.
SHOW body defaults to DEPTH 0 (signature only). Incrementally reveal structure without reading full source:
-- Step 1: signature only — understand the interface
SHOW body OF 'PiscoCode::process'
-- Step 2: top-level branches visible — see the control flow
SHOW body OF 'PiscoCode::process' DEPTH 1
-- Step 3: full source when needed
SHOW body OF 'PiscoCode::process' DEPTH 99-- Unreferenced functions (skip test files)
FIND symbols
WHERE fql_kind = 'function'
WHERE usages = 0
EXCLUDE 'tests/**'
ORDER BY path ASC
-- Unreferenced macros in headers
FIND symbols
WHERE fql_kind = 'macro'
WHERE usages = 0
IN 'include/**'
-- Dead code behind guards (unreferenced guarded functions)
FIND symbols
WHERE fql_kind = 'function'
WHERE guard != ''
WHERE usages = 0
EXCLUDE 'test/**'
ORDER BY lines DESC
-- Symbol distribution (spot bloated files)
FIND symbols
GROUP BY file
HAVING count >= 20
ORDER BY count DESC-- All code gated on a specific config option. These fields hold a
-- comma-joined SET, so `=` would mean "guarded by this and nothing else";
-- `(^|,)…(,|$)` is the exact membership test.
FIND symbols WHERE guard_defines MATCHES '(^|,)CONFIG_BT(,|$)'
-- Code compiled only when a feature is ABSENT
FIND symbols WHERE guard_negates MATCHES '(^|,)CONFIG_SMP(,|$)'
-- Large functions in #else branches (often forgotten)
FIND symbols
WHERE fql_kind = 'function'
WHERE guard_branch = '1'
ORDER BY lines DESC
LIMIT 15
-- Recursive functions behind guards
FIND symbols
WHERE is_recursive = 'true'
WHERE guard != ''
ORDER BY recursion_count DESC
-- Guard distribution by kind
FIND symbols
WHERE guard != ''
GROUP BY guard_kind
HAVING count >= 1
ORDER BY count DESC-- Functions longer than 50 lines (refactoring candidates)
FIND symbols
WHERE fql_kind = 'function'
WHERE lines >= 50
ORDER BY lines DESC
-- Complex conditions (4+ sub-tests)
FIND symbols WHERE condition_tests >= 4
-- Switch without default
FIND symbols
WHERE fql_kind = 'switch'
WHERE has_catch_all = 'false'
-- Mixed && / || without grouping parentheses
FIND symbols WHERE mixed_logic = 'true'
-- Assignment in condition (likely bug)
FIND symbols WHERE has_assignment_in_condition = 'true'
-- Magic numbers
FIND symbols WHERE is_magic = 'true'
-- C-style casts (modernization targets)
FIND symbols WHERE cast_style = 'c_style'
-- Functions with goto
FIND symbols WHERE goto_count >= 1
-- Duplicated conditions within same function
FIND symbols WHERE duplicate_condition = 'true'
-- Duplicate logic within a single condition (copy-paste bugs)
FIND symbols WHERE dup_logic = 'true'
-- Functions with repeated conditional calls (extract-variable opportunity)
FIND symbols WHERE has_repeated_condition_calls = 'true'
-- Variables declared far from their first use (move declaration closer)
FIND symbols
WHERE fql_kind = 'function'
WHERE decl_far_count >= 3
ORDER BY decl_distance DESC
-- Dead stores (value written but never read before overwrite)
FIND symbols
WHERE fql_kind = 'function'
WHERE has_unused_reassign = 'true'
-- Regex search: functions whose name ends with _impl
FIND symbols
WHERE fql_kind = 'function'
WHERE name MATCHES '_impl$'
-- Source lines containing TODO/FIXME (case-insensitive)
SHOW body OF 'PiscoCode::run' DEPTH 99
WHERE text MATCHES '(?i)TODO|FIXME'Note —
WHERE text/WHERE contentscope: These predicates are only valid on commands that return source lines:SHOW body,SHOW LINES, andSHOW context. Using them onFINDqueries (symbols, usages, files) will return a clear error instead of silently producing 0 results.
Tip — exclude test directories: Enrichment queries on large codebases can be noisy if the results include test harnesses, mocks, and generated test code. Add
EXCLUDEclauses to focus on production code:FIND symbols WHERE has_assignment_in_condition = 'true' EXCLUDE '**/testsuite/**' EXCLUDE '**/tests/**' EXCLUDE '**/test/**'
SHOW outline and SHOW members support the full clause pipeline including WHERE, over the fields their own rows carry — an outline row carries name, fql_kind (or kind), path/file, line and depth; a members row carries fql_kind/kind/type, text/declaration/name and line. SHOW outline names a file, so its whole clause only filters and anything outside that list is refused. SHOW members names a symbol, so its WHERE is split: the fields above filter the members, and a field they do not carry — language, say — scopes which symbol OF resolved to. Its ORDER BY, GROUP BY and HAVING are not split and are held to the list above:
-- Only enum declarations in a header
SHOW outline OF 'include/config.h'
WHERE fql_kind = 'enum'
-- Only function definitions in outline
SHOW outline OF 'src/PiscoCode.cpp'
WHERE fql_kind = 'function'
ORDER BY line ASC
-- Only field members of a class (skip methods)
SHOW members OF 'PiscoCode'
WHERE fql_kind = 'field'
-- Paginate a large outline
SHOW outline OF 'include/PiscoCode.h'
LIMIT 10 OFFSET 20-- Which files reference this symbol the most?
FIND usages OF 'PiscoCode::process'
GROUP BY file
ORDER BY count DESC
-- What does this function call?
SHOW callees OF 'PiscoCode::process'
-- Top 10 most-referenced functions
FIND symbols
WHERE fql_kind = 'function'
ORDER BY usages DESC
LIMIT 10A rename is a composition of usage sites, not a text substitution: enumerate the
sites, then issue a targeted CHANGE NODE per site. Each statement executes
independently — the agent sees every result (and every diff) and decides whether
to proceed.
-- 1. Checkpoint
BEGIN TRANSACTION 'rename-process'
-- 2. Blast radius — one row per occurrence SITE (includes non-call references)
FIND usages OF 'PiscoCode::process' GROUP BY role ORDER BY count DESC
FIND usages OF 'PiscoCode::process' GROUP BY file ORDER BY count DESC
FIND usages OF 'PiscoCode::process' WHERE role = 'code' LIMIT 50
-- 3. For each site: read the enclosing node, splice the reference by handle
SHOW NODE '<node_id>' WHERE text LIKE '%process%'
CHANGE NODE '<node_id>(off)' WITH ' PiscoCode::run(sample);'
-- …repeat per site; each response's diff confirms the splice
-- 4. Verify the build
VERIFY build 'test'
-- 5a. Success → commit
COMMIT MESSAGE 'rename PiscoCode::process to PiscoCode::run'
-- 5b. Failure → rollback
ROLLBACK TRANSACTION 'rename-process'-- Phase 1
BEGIN TRANSACTION 'phase-1-rename'
-- …rename sweep as above…
VERIFY build 'test'
COMMIT MESSAGE 'rename OldName to NewName'
-- Phase 2
BEGIN TRANSACTION 'phase-2-add-param'
CHANGE NODE '<declaration_node_id>'
WITH 'void NewName::run(Buffer& buf, int flags);'
VERIFY build 'test'
-- Phase 2 failed — roll back only phase 2; phase 1 commit preserved
ROLLBACK TRANSACTION 'phase-2-add-param'SHOW body in CSV form surfaces the node's node_id (in the header) and a node-relative off column, so you can edit by handle without computing absolute line numbers:
-- Read the function; the CSV header carries its node_id, the off column is node-relative
SHOW body OF 'PiscoCode::process' DEPTH 99
-- Rewrite it by handle — drift-proof, no line numbers to recompute
BEGIN TRANSACTION 'rewrite-process'
CHANGE NODE '<node_id>'
WITH 'void PiscoCode::run(Buffer& buffer) {
for (auto& sample : buffer) {
sample = this->pipeline.apply(sample);
}
}'
VERIFY build 'test'
COMMIT MESSAGE 'rewrite PiscoCode::run'-- Large files (potential split candidates)
FIND files
WHERE size > 100000
ORDER BY size DESC
LIMIT 10
-- Non-source files in src/
FIND files IN 'src/**'
WHERE extension NOT LIKE 'cpp'
WHERE extension NOT LIKE 'h'
-- Directory tree 2 levels deep
FIND files DEPTH 2In MCP mode the default output is compact CSV — token-efficient grouped format.
Pass format=JSON for full structured JSON.
All compact output follows a uniform 2-column structure:
"op",total_count
"group_key","[field1,field2,...]"
"group_value_a","[v1,v2],[v3,v4]"
"group_value_b","[v5,v6]"
"tokens_approx",NFIND symbols — grouped by fql_kind:
"find_symbols",8
"fql_kind","[name,path,line,usages]"
"function","[encenderMotor,src/motor_control.cpp,12,7],[apagarMotor,src/motor_control.cpp,28,5]"
"class","[MotorControl,include/motor_control.hpp,5,2]"When a WHERE or ORDER BY targets an enrichment field, the last column
shows that field's value instead of usages, and the schema line names the
field. Any predicate does this, not only a numeric one: WHERE has_doc = 'false' puts false there. Four fields — has_todo, has_escape,
has_shadow, is_recursive — are written onto a row only when they hold, and
a row answering one of them by its declared default shows that default, not the
absence of a stored value. The cell is EMPTY when nothing answers the field
for that row — a cmake function has no has_todo either way, so
ORDER BY has_todo leaves its cell blank rather than printing a number
belonging to another column. An empty cell means "this field does not apply
here", never zero and never a rendering fault:
-- FIND symbols WHERE member_count > 10
"find_symbols",3
"fql_kind","[name,path,line,member_count]"
"class","[Serial_Protocol,src/Serial_Protocol.h,24,17],[Button,src/buttons.h,31,12]"
"struct","[MpptState,src/SolarCharger.h,57,11]"FIND usages — raw sites collapsed per file and role, with the file's handle and rev so a site is editable from the listing (file,role,node_id,rev,[lines]). A file whose occurrences span more than one role renders one row per role; the LIMIT still counts files, and a selected file always renders all of its roles:
"find_usages","encenderMotor",4
"file","role","node_id","rev","[lines]"
"src/motor_control.cpp","code","n3f2a91c4e05b","h9c1d4e77a2b30f81","45,89"
"src/motor_control.cpp","comment","n3f2a91c4e05b","h9c1d4e77a2b30f81","44"
"include/motor_control.hpp","code","n8b70d2ae61ff","h4e6620bb17ac9d35","34"FIND usages OF … GROUP BY file — per-file counts (file,count, the same aggregate the JSON count field carries):
"find_usages","encenderMotor",2
"file","count"
"src/motor_control.cpp",2
"include/motor_control.hpp",1SHOW outline — grouped by kind, comments compressed to len:N:
"show_outline","include/types.hpp"
"fql_kind","[name,line]"
"comment","[len:18,1],[len:23,55]"
"type_alias","[int16_t,17],[int32_t,18]"SHOW members — grouped by kind:
"show_members","MotorControl","include/motor_control.hpp"
"type","[declaration,line]"
"field","[uint16_t rpm_setpoint;,28],[bool is_locked;,51]"
"method","[void setRPM(uint16_t);,35]"SHOW body / lines / context — 2 columns (line, text):
"show_body","convertByte2Volts","src/adc.cpp","42-44"
"line","text"
42,"float convertByte2Volts(uint8_t raw) {"
43," return raw * 3.3f / 255.0f;"
44,"}"SHOW signature — single flat row:
"show_signature","setPeakLevel","src/signal.cpp",125,"void setPeakLevel(int level)"SHOW callees — grouped by file:
"show_callees","setPWMDuty"
"file","[name,line]"
"src/pwm_driver.cpp","[writePWM,189]"
"src/timer.cpp","[updateTimer,405]"FIND files — 2 flat columns. error_count and/or parse_coverage are appended only when
the query names them; a plain FIND files stays at two columns and pays nothing:
"find_files",142
"path","size"
"src/motor_control.cpp",12847Mutations, transactions, and source ops keep their JSON format (already small).
The commands in this chapter operate on raw byte ranges and never touch the
index. They are not the way to edit indexed source — CHANGE FILE on an
indexed file is disabled and returns guidance pointing at the node commands
(CHANGE NODE, INSERT … NODE, DELETE NODE): a node handle survives edits
that shift line numbers; a line range does not. What remains legitimate here:
- editing files ForgeQL does not index (fixtures, generated output, plain text);
- file scaffolding —
COPY LINESto seed a brand-new file,MOVE LINESto relocate content across files; - deleting a file (
CHANGE FILE '<f>' WITH NOTHING— works on indexed files too;ROLLBACKrestores it).
SHOW LINES n-m OF 'file_path' [clauses]Returns a verbatim 1-based line range. Combine with WHERE text MATCHES/LIKE to
grep within the range before the output is returned.
CHANGE (FILE | FILES) file_list MATCHING 'old_text' WITH 'new_text'
CHANGE (FILE | FILES) file_list LINES n-m WITH 'new_content'
CHANGE (FILE | FILES) file_list LINES n-m WITH NOTHING
CHANGE FILE 'file_path' WITH 'new_full_content'
CHANGE FILE 'file_path' WITH NOTHING| Variant | Effect |
|---|---|
MATCHING … WITH … |
Replace all literal occurrences across matched files |
LINES n-m WITH '…' |
Replace a specific line range |
LINES n-m WITH NOTHING |
Delete a specific line range |
WITH '…' |
Replace entire file content (creates the file if absent) |
WITH NOTHING |
Delete the file — the removal is staged on COMMIT; ROLLBACK restores it |
All variants are refused on indexed source files — with guidance to use the
node commands instead — except the whole-file WITH NOTHING deletion: naming a
file explicitly for removal is not raw-text editing, and the returned diff shows
the deleted content.
file_list is one or more comma-separated single-quoted globs; FILE and FILES
are interchangeable. Every WITH 'content' form also accepts a heredoc block
(WITH <<TAG … TAG, tag all-uppercase on its own line) when the replacement text
contains quotes.
CHANGE FILE '<path>' WITH '…' is also the one way to rewrite a UTF-16 or
UTF-32 file. Every other write is refused on one (see below); this variant
replaces every byte at once, so it leaves no half of the file in the old
encoding, and it is not refused. For an indexed file — where CHANGE FILE is
itself refused — delete it and write it again.
COPY LINES n-m OF 'src' TO 'dst' [AT LINE k]
MOVE LINES n-m OF 'src' TO 'dst' [AT LINE k]Copies (or moves) source lines n..=m into dst before line k; the range is
appended when AT LINE k is omitted. COPY leaves src untouched; MOVE
deletes the range from src after inserting. Same-file moves are atomic. A
purely numeric TO destination is rejected (write TO '<path>' AT LINE k, not
TO 3).
Encodings. Both verbs, and every node- or line-scoped CHANGE, INSERT
and DELETE, are refused with an error naming the encoding when the file
they would write — for COPY/MOVE LINES that means either end — declares
UTF-16 or UTF-32 with a byte-order mark. A line boundary there is not a byte
boundary, so splicing UTF-8 in at an offset found by scanning for 0x0A would
shift every byte after the edit. FIND usages reads UTF-16 text, so a site in
one can be found and cannot be rewritten in place; CHANGE FILE '<path>' WITH '…' replaces every byte and is the way to convert one.
ForgeQL ships an optional onboarding coach: a decoupled add-on that feeds an agent short, just-in-time hints about the ForgeQL protocol as it works, so an agent can become fluent without reading this reference up front. It is a temporary bridge for models not yet natively fluent in ForgeQL; it never inspects, transforms, or "fixes" your source — it teaches the protocol only.
How a hint arrives. When the coach emits a hint, it rides the same response as the command that triggered it — never a separate message:
- JSON output: a top-level
"coach"field alongside the result. - CSV / text output: a trailing
coach: <text>block.
At most one hint rides any response, and it is purely advisory — nothing about a command's result or error changes when a hint is present or absent.
When it fires — corrective hints. The coach teaches first from failures, because an error is concrete evidence of a protocol gap. A hint may accompany:
- a rejected mutation or read (an
IF REVmismatch, or an unresolved node handle); - a bulk
NODES FOUNDverb that could not proceed (no armed FIND, a truncated FIND, or a missing masterIF REV); - a statement that failed to parse (with a nearest-verb correction).
When it fires — proactive hints. As you work, the coach also surfaces the
next protocol skill you have not yet used — connect, then locating and
filtering, then reading and DEPTH, then editing by handle and the IF REV
contract. These are rarer and self-pacing:
- They follow the session. A read-leaning session is taught reading and query
skills; a session that is editing is taught the mutation contract, with
IF REVsurfaced ahead of enrichment trivia. - They go quiet once you are fluent. A skill drops out of the rotation once you have used it recently, so an agent already fluent in everything relevant sees nothing at all.
- Two wasteful reading patterns each draw a one-time nudge: reading a file in
many small adjacent
SHOW LINESranges, or hitting the line cap again and again without paging.
Turning it off. Set FORGEQL_COACH=0 (or off, false, no) in the
server's environment to disable the coach entirely; the hot path is then
untouched and no coach field ever appears. The coach is also absent by default
for library embedders and the test suites.