Guides
Extending Lore
Most contributions are small. This guide covers the common ones: teaching discovery a convention, adding a language or framework, widening the search vocabulary, adding a risk pattern, and adding a command or a tool.
core imports nothing of Lore's own; see architecture), and a test fails if you break that. Every behaviour change needs a test in tests/, and a bug fix needs a test that fails without the fix. Run the suite with python3 -m unittest discover -s tests. To try changes without touching your real rules, point Lore at a scratch folder: export LORE_HOME=$(mktemp -d).Teach discovery a convention
A convention is one small object, a marker, added to a language or framework module. Pick the kind that fits:
| Kind | Fires when | Required fields | Example |
|---|---|---|---|
FileMarker | nearly every applicable file contains a pattern | has; optional when makes a file applicable | every PHP file declares strict_types |
OccurrenceMarker | nearly every occurrence of X is also Y | total, has | every function has a return type |
AbsentMarker | nearly no non-test file contains a pattern | bad | no console.log |
ChoiceMarker | one of several styles clearly dominates | options: a list of Option(label, pattern, rule) | function against class components |
Every marker also takes id (unique), glob (which files it looks at, such as **/*.py), rule (the proposed text, one imperative sentence), and optionally exclude_tests, min_ratio and min_applicable to override the defaults for that marker. The thresholds are explained under discover.py.
AbsentMarker(id="py-assert", glob="**/*.py", bad=r"^\s*assert\b",
rule="Raise exceptions instead of using assert in application code."),
ChoiceMarker(id="django-urls", glob="**/urls.py", options=(
Option("path()", r"\bpath\(", "Declare URLs with path()."),
Option("re_path()", r"\bre_path\(", "Declare URLs with re_path().")))),
Write the pattern so it matches the thing precisely: it is a regular expression applied to whole files with the multiline flag. A marker can mis-fire on comments and strings, so test against a small fixture.
Add a language
A new language is one file in discovery/languages/ that exports LANGUAGE. Nothing needs registering: discovery/registry.py imports every module in the folder.
from ..markers import AbsentMarker
from .base import Language
LANGUAGE = Language(
name="zig", # must equal the language name in core/mapper.py LANGS for these extensions
extensions=("zig",), # without dots
markers=(
AbsentMarker(id="zig-unreachable", glob="**/*.zig", bad=r"\bunreachable\b",
rule="Do not use unreachable in application code."),
),
)
| Field | Meaning |
|---|---|
name | Must match what core/mapper.py maps the extension to (a test checks that they agree). Add the extension to LANGS there if it is new. |
extensions | File extensions without dots. |
markers | Framework-independent conventions. |
formatter_enforced | True when a formatter decides layout (gofmt, rustfmt): indentation and line-length statistics are skipped. |
check_quotes, check_semicolons | Whether quote-style and semicolon statistics make sense for the language. |
Add a framework
A framework is a Framework(...) in a module of discovery/frameworks/ that exports a FRAMEWORKS list. Its markers are evaluated only when the framework is detected.
FLASK = Framework(
name="Flask", languages=("python",),
deps=("flask",), # dependency names (lower case) from any manifest
markers=(
AbsentMarker(id="flask-global-state", glob="**/*.py", bad=r"^\s*global\s+\w+",
rule="Do not keep request state in module globals."),
),
)
FRAMEWORKS = [DJANGO, FLASK]
Detection uses deps (a dependency in a manifest) and/or signatures, regular expressions searched in the source of the listed languages for frameworks that have no manifest entry; signature_files (default 3) is how many files must match. A framework with no marker and no detector fails a test.
Quick one-offs without touching the code
Pass a JSON list to discover.py --markers extra.json; each object has kind set to file, occ, absent or choice, plus the marker's fields. See your own markers.
Test a marker
Add a case to tests/test_discovery.py: write a few files into a temporary project that should trigger the marker and a few that should not, and check the proposed rules. The existing tests in TestFrameworkMarkers and TestLanguageMarkers are the pattern to copy. Two tests protect you: every marker id must be unique, and every regular expression must compile.
Search vocabulary
Rule selection (how rules are chosen) uses plain word lists in core/search.py:
_TOPICS- Areas of work. A word in a topic also matches every other word of that topic, so a task about a "column" finds a rule about "migrations". To add a word, put it in the right topic's string; to add an area, add a key. Keep topics small and distinct: a word in several topics widens matches.
_ALIASES- Short forms that mean something (
py→ python,db→ database). _STOP- Filler words that never carry meaning.
_SUFFIXES- The endings the rough stemmer removes.
Add a test to tests/test_agent_tools.py (TestSearch) showing a task that now finds a rule it missed.
Risk patterns
core/risk.py holds the patterns that keep a proposed rule from being activated automatically. Each entry in _CHECKS is a short reason and a compiled regular expression. Add one when you see a class of attack text that slips through, and add tests to tests/test_safety.py for two things: that the attack is flagged, and that ordinary conventions ("Never commit secrets", "Do not ignore failing tests") are not. Prefer precise patterns; a false alarm costs one extra confirmation, but a pattern that fires on common rule wording trains people to click through.
Add a command
- Put the logic in a module in
cli/with amain(argv=None)decorated with@entry(it turnsCliErrorintoerror: …and exit code 2). - Add a root script that only wraps it, like the existing ones (16 lines or fewer):
#!/usr/bin/env python3 """Entry point; the implementation (and --help text) lives in cli/mytool.py.""" import sys from cli.mytool import main if __name__ == "__main__": sys.exit(main()) - Add the script's name to
ROOT_SCRIPTSintests/test_architecture.py; that test checks that the set of root scripts is exactly the expected one and that each is a thin wrapper. - Build on
core. If the web UI should offer it, add a handler inweb/api.pythat builds the script's argument list.
Add an MCP tool
- Write a handler in
mcpserver/tools.py: it takes the arguments dictionary and returns text. Decide the project with_targetor_project_for; a tool that writes rules must useby_name=Falseso it cannot be aimed at another project by name. - Add a
Tool(name, description, input_schema, handler)toTOOLS. The description is what the agent reads: keep it true. Includepathsin the schema. - If the tool needs session state (what was already sent, the activation count), add its name to the
ownmapping increate_server()and accept asessionargument. - Test it through the server (
create_server().handle(...)) intests/test_agent_tools.py, and update the tool list intests/test_mcp.py. - Document it in MCP tools.
Before you open a pull request
- Fork the repository, create a branch in your fork, and open the pull request from there.
- Keep it to one change, run the whole suite, and update the docs if behaviour changed.
- The full contributor guide is
CONTRIBUTING.md.