General engineering and process notes for AI coding agents (Codex, Cursor, Claude Code, Gemini CLI, GitHub Copilot) working on the lsFusion IntelliJ plugin. Feature-level lore stays in commit messages, code, and per-agent memory — this file is for durable cross-cutting process knowledge.
Single-module Gradle project (Kotlin DSL).
src/— plugin sources (Java + Kotlin).src/com/lsfusion/lang/LSF.bnf— Grammar-Kit BNF defining the lsFusion language parser.src/com/lsfusion/lang/LSF.flex— JFlex lexer source.src/com/lsfusion/lang/psi/references/— PSI reference contracts;references/impl/holds implementations.gen/— auto-generated parser, lexer, and PSI classes. Never edited by hand.META-INF/plugin.xml— plugin manifest; the<version>element is the source of truth for the artifact version.resources/— bundled icons, message bundles, etc.
The lsFusion platform (Java server, ANTLR3 grammar, docs) lives at ../platform/. A platform syntax change usually requires more than one plugin edit:
LSF.bnf— add or update the parser rules.LSF.flex— when the change introduces a new keyword or literal form, add it to the lexer's keyword/literal list (otherwise the lexer won't recognize the new token even if the BNF accepts it).- Parser/lexer regeneration via
./gradlew generateLsfParser generateLsfLexersogen/matches the new sources. - Annotator / inspection support — wire into
LSFReferenceAnnotatorper the Code conventions section, when the change adds new references, deprecates an old form, or warrants a highlight.
Coordinate commits across the two repos (cross-link in commit messages, or close the same GitHub issue from both sides).
./gradlew build # full build; generates parser/lexer first
./gradlew generateLsfParser # regenerate just the parser from LSF.bnf
./gradlew generateLsfLexer # regenerate just the lexer from LSF.flex
./gradlew generateMigrationParser
./gradlew generateMigrationLexer
./gradlew runIde # launch IntelliJ sandbox with the plugin loaded
Toolchain: Java 21 JDK (required by IntelliJ Platform 2025.3). Source and bytecode level are pinned to javaVersion in build.gradle.kts (currently 21) — check that file rather than hard-coding here.
After editing LSF.bnf or LSF.flex, regenerate before the next build or sandbox launch so gen/ matches the sources. gen/ is gitignored — don't stage it.
./gradlew runIde launches an IntelliJ sandbox with the plugin loaded — open or create a .lsf file to exercise highlighting, references, completion, annotations. Sandbox state lives under .intellijPlatform/sandbox/ (outside build/, so ./gradlew clean keeps it); deleting it forces a clean re-init.
No headless test rig — UI verification is interactive in the sandbox. No test sourceSet is currently configured either: main maps everything under src/ (and gen/) as production code, so a file at src/test/... would ship in the plugin artifact. To add unit tests (PSI walkers, reference resolution helpers), first wire a dedicated test sourceSet pointing at a directory that doesn't overlap with src/ (e.g. a sibling tests/), then run ./gradlew test.
- Short imperative subject, ≤72 chars. Suffix
(closes #NN)for plugin issues, or(lsfusion/platform#NNNN)when cross-linking to a platform issue that drove the change. - Body explains why. The diff shows what.
- Never amend or force-push commits that may have been pulled by others. Always create a new commit on top.
- Never pass
--no-verifyunless the user explicitly requests it. If a pre-commit hook fails, fix the underlying issue and create a new commit. - When the change is AI-assisted, include a
Co-Authored-By:trailer naming the model. - Stage new files (
git add <path>) at creation, not at commit time. Pending work then shows asA/Mingit statusinstead of??, and can't silently fall out of the commit. (gen/andbuild/are gitignored and stay unstaged.) - Before each commit, run
git status,git diff, andgit diff --cached. Stage only files relevant to the change; never sweep up unrelated working-tree edits. - For non-trivial changes, record in the commit body which verifications ran (parser/lexer regen, Gradle build, sandbox launch, manual highlighting/completion check) and which were skipped and why. Be precise:
./gradlew buildis a build/package verification — it becomes a test verification only if Gradle actually discovered and ran tests (and notestsourceSet is currently wired, so./gradlew testhas nothing to run by default). Don't write "tests passed" unless JUnit tests actually ran. - Commit messages are published with the repo — nothing machine-local or session-local. Strip: absolute paths (
E:/...,C:/Users/...), usernames/hostnames, agent tooling, network/regional circumstances, names of the private project tested against, session narrative (clicks, dialogs, debug history), local git mechanics (shelf/stash, unpushed hashes), work-session dates, log excerpts with machine paths, untracked promises (state the fact instead). Keep what explains the code: the why, invariants, trade-offs, public build numbers, issue refs. Test: still useful years later to a maintainer who knows nothing of your machine or session. - "Verified:" is a few lines of facts — what was checked and the result, environment described generically (e.g. "a real lsFusion project (~174k files)"), no step-by-step recipes. Before committing, scan the draft for drive-letter paths and usernames.
Use a HEREDOC so newlines and trailers are preserved:
git commit -m "$(cat <<'EOF'
Subject line (lsfusion/platform#NNNN)
Body paragraph explaining the why.
Co-Authored-By: <model name> <agent-noreply-email>
EOF
)"
No maintained version or backport branches; the release line is master. Feature branches for work-in-progress are fine and merge back into master.
Pushing a <version> bump to master is a release. One Jenkins run publishes it to both distribution channels:
- JetBrains Marketplace (plugin/7601). Every update goes through JetBrains review; Marketplace users get it once it is approved.
lsfusion.orgdownload / IDE auto-update channel.exe/ext/lsfusion-idea-plugin.zip, the fileIDE → Settings → Plugins → Manage Plugin Repositories → https://www.lsfusion.org/...pulls. Uploaded as soon as the Marketplace accepts the upload, without waiting for the review.
- GitHub push webhook → Jenkins job
buildAndUploadPluginTrigger(freestyle, watches any branch) → triggers thebuildAndUploadPluginpipeline (vars/buildAndUploadPlugin.groovyin thelsfusion/jenkinsshared library), which always buildsmaster. - It compares
<version>fromMETA-INF/plugin.xmlwith the newest update listed byhttps://plugins.jetbrains.com/api/plugins/7601/updates; an update still in review is not listed. If they match, it logsVersion <X.Y.Z> matches the latest version in Marketplace. Skipping build.and succeeds — pushes that don't bump are no-ops. - Otherwise it runs
./gradlew buildPluginand./gradlew publishPlugin.publishPlugindepends onverifyPlugin, which checks the plugin against therecommended()IDEs, the newest EAP included, and fails on compatibility problems and on internal or override-only API usages — what Marketplace review rejects. Then:- the upload is accepted: the zip goes over FTP to lsfusion.org, and Slack #jenkins gets the change-notes bullets;
- the Marketplace answers
already contains version: an earlier run uploaded this version and it is still in review, so every push until the approval ends here. The build succeeds with the description<X.Y.Z>: already uploaded, not approved yet; nothing is uploaded, no Slack message; - any other failure (verification, a rejected token, network): nothing reaches either channel, the build fails, and Slack gets Gradle's "What went wrong" text. The version stays unpublished, so the next push or a manual run of
buildAndUploadPluginretries it.
- Open
META-INF/plugin.xmland patch-bump<version>X.Y.Z</version>to the value that already appears in the<b>Version X.Y.Z</b>header inside the current<change-notes>block — those two MUST agree at upload time so the bundled release notes match the version users see. - Verify every user-visible change since the last release has a
<li>bullet in the block (see Release notes below). Add any that are missing. - Run
./gradlew verifyPlugin(the first run downloads the recommended IDEs): a finding fails the release in both channels. - Commit. Subject like
Release X.Y.Zis fine; the body should list what's shipping, mirroring the change-notes bullets. Don't squash this with unrelated work — the release commit should diff cleanly toplugin.xml(and onlyplugin.xmlunless the rollout literally needs another file). - Push to
master. Jenkins publishes to both channels as described above; Marketplace users get the version after JetBrains approves it.
The change-notes block is intentionally not reset by the release commit — it keeps showing what shipped in X.Y.Z until the next contributor with a user-visible change does the rollover (see below).
The CDATA block in META-INF/plugin.xml is the only source of release notes; both channels render it verbatim, and there is no parallel CHANGELOG.md. The block is HTML — basic tags only (<b>, <br>, <ul>, <li>, <code>).
What to write. One terse <li> bullet per user-visible improvement, in past-or-imperative tense, framed as what the developer-of-lsFusion sees in the IDE — not as what the plugin internally does. Mention concrete syntax (<code>NEWEXECUTOR ... CLIENT conn</code>), inspection names, settings paths. Skip plugin-internal mechanics (BNF rule names, PSI class names, mixin wiring) — those belong in the commit body, not in the marketplace listing.
When to write. Append a bullet to the change-notes block in the same commit that introduces the user-visible change.
If the <b>Version X.Y.Z</b> header in the block still matches the currently-published <version> (i.e. you are the first contributor with a user-visible change after a release), do the rollover in your commit:
- Bump the header to the next patch version:
<b>Version X.Y.Z+1</b>. - Empty the
<ul>list of the previous release's bullets. - Add your new
<li>as the first entry.
If the header is already ahead of <version> (a previous contributor has already rolled over), just append your <li> to the existing list.
This way release notes are maintained lazily — no dedicated cleanup commit, and at any point in time the block accurately reflects "what's queued for the next release."
Examples of changes that warrant an entry:
- New / changed syntax support in the grammar (parser, lexer, annotator).
- New inspection, intention, completion behavior, or gutter icon.
- A deprecation warning the developer will start seeing.
- Changes to the MCP / AI-assistant tool surface the plugin exposes.
- Behavior changes in Go To Definition, Find Usages, Refactor, or any IDE action.
Examples of changes that do not warrant an entry:
- Internal refactors with no observable effect.
- Build / Gradle / CI tweaks.
- Regenerated
gen/after a.bnf/.flexedit when the user-visible effect is already covered by another bullet for that edit. - Test infrastructure.
If a release accidentally landed with a bullet that turned out to be wrong, fix it in a follow-up <version> bump rather than amending the published one — both channels cache the published notes externally.
GitHub issues are usually not created for plugin changes — the plugin's release notes aren't generated from closes #N references, and most plugin work either tracks a platform-side issue or is pure IDE tooling. Reference the corresponding platform issue in the commit subject when applicable (e.g. (lsfusion/platform#NNNN)); that's enough for traceability.
If a plugin-only issue is genuinely warranted (e.g. a long-standing IDE-tooling bug that needs its own tracking), use the same template as the platform repo:
### Title
<short imperative title>
### Description
<observed IDE behavior, minimal .lsf reproducer, expected vs. actual outcome>
### Reason
<motivation: why this matters to plugin users>
Frame the Description in terms of what a developer sees in the IDE — wrong highlighting, missing completion, broken Go To Definition, false-positive inspection — and include the smallest .lsf snippet that triggers it. Class names, PSI types, and BNF rule names belong in the ### Fix section (for bug reports) or in the commit body, not in the user-facing Description.
- PSI mixins/implements for grammar-driven references go under
references/andreferences/impl/, notdeclarations/. NewformExtendXxx-style rules in the BNF wiremixintoreferences.impl.*ReferenceImplandimplementstoreferences.*Reference. - After adding a new BNF rule that needs validation or highlighting, wire it into
LSFReferenceAnnotator(visitor pattern). Most references plug in viavisitXxxUsage→checkReference(o). - Don't add error handling for impossible PSI states. Trust the generated grammar — for elements whose BNF rule guarantees presence, let
nullpropagate without defensive checks; for optional elements, check explicitly. - Minimize call-tree depth and inter-function coupling. A helper called from only one place, doing one obvious thing, is usually clearer inlined; delegation chains where each level just forwards arguments make code harder to follow than one self-contained function. Exception: genuinely primitive or broadly-reused utilities (PSI traversal helpers, well-known infrastructure) — coupling to those is cheap and welcome.
- Don't comment what the code already says. Add a comment only when why is non-obvious — an IDE platform quirk, a workaround, a subtle invariant.
- No emojis in source, commits, or comments unless the user explicitly asks.
- Prefer editing existing files to creating new ones.
Treat the following as requiring explicit user authorization each time, even if a similar action was authorized earlier in the session:
git pushand any remote-affecting operation; force-pushes are never automatic.- Destructive operations:
git reset --hard,git clean -fd, branch deletion, force pushes,rm -rfof working trees or generated directories outside the explicit build outputs (build/,gen/). - Anything that publishes outside the local machine: GitHub PR/issue creation, comments, releases, JetBrains Marketplace uploads (
./gradlew publishPlugin). - Bumping
META-INF/plugin.xml<version>— only when explicitly preparing a release (see Releases: the bump publishes to both channels).
Local, reversible work — editing files, running tests, regenerating gen/ via ./gradlew generate* — doesn't need per-action confirmation.
CLAUDE.md,GEMINI.mdat the repo root are thin wrappers that import this file (Claude Code and Gemini CLI don't auto-readAGENTS.mdout of the box)..github/copilot-instructions.md— if added, GitHub Copilot surfaces that don't readAGENTS.mdwill pick it up. Keep canonical guidance here.