leyline-code 0.8.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- leyline/__init__.py +3 -0
- leyline/adapters/__init__.py +19 -0
- leyline/adapters/csharp.py +1026 -0
- leyline/adapters/generic.py +844 -0
- leyline/adapters/python.py +929 -0
- leyline/adapters/typescript.py +1512 -0
- leyline/affected.py +509 -0
- leyline/agent_skills.py +311 -0
- leyline/change.py +500 -0
- leyline/channels.py +1782 -0
- leyline/cli.py +1029 -0
- leyline/cluster.py +149 -0
- leyline/context.py +531 -0
- leyline/coupling.py +485 -0
- leyline/coverage.py +376 -0
- leyline/diagrams.py +680 -0
- leyline/diff.py +1078 -0
- leyline/drift.py +747 -0
- leyline/exact.py +410 -0
- leyline/explain.py +973 -0
- leyline/export.py +673 -0
- leyline/fncoupling.py +328 -0
- leyline/formats.py +923 -0
- leyline/grade.py +207 -0
- leyline/incremental.py +931 -0
- leyline/indexer.py +3733 -0
- leyline/learnings.py +743 -0
- leyline/loop.py +584 -0
- leyline/model.py +142 -0
- leyline/outline.py +1066 -0
- leyline/packing.py +86 -0
- leyline/patterns.py +478 -0
- leyline/pr.py +1249 -0
- leyline/props.py +255 -0
- leyline/query.py +683 -0
- leyline/quick.py +853 -0
- leyline/related.py +266 -0
- leyline/removal.py +231 -0
- leyline/renames.py +231 -0
- leyline/rereview.py +375 -0
- leyline/roslyn_refs/Program.cs +179 -0
- leyline/roslyn_refs/RoslynRefs.csproj +16 -0
- leyline/rules.py +171 -0
- leyline/schema.sql +178 -0
- leyline/scip_pb2.py +97 -0
- leyline/server.py +1230 -0
- leyline/skills/leyline-adversarial-review/SKILL.md +121 -0
- leyline/skills/leyline-ask/SKILL.md +57 -0
- leyline/skills/leyline-change-impact/SKILL.md +79 -0
- leyline/skills/leyline-explain-flow/SKILL.md +70 -0
- leyline/skills/leyline-explore-module/SKILL.md +55 -0
- leyline/skills/leyline-pr-review/SKILL.md +77 -0
- leyline/skills/leyline-quick-change/SKILL.md +63 -0
- leyline/skills/leyline-spec/SKILL.md +179 -0
- leyline/skills/leyline-tour/SKILL.md +46 -0
- leyline/spec.py +1819 -0
- leyline/store.py +560 -0
- leyline/tours.py +352 -0
- leyline/verdicts.py +390 -0
- leyline/viewer/viewer.html +1622 -0
- leyline_code-0.8.1.dist-info/METADATA +1325 -0
- leyline_code-0.8.1.dist-info/RECORD +65 -0
- leyline_code-0.8.1.dist-info/WHEEL +4 -0
- leyline_code-0.8.1.dist-info/entry_points.txt +2 -0
- leyline_code-0.8.1.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,1325 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: leyline-code
|
|
3
|
+
Version: 0.8.1
|
|
4
|
+
Summary: Map a codebase so a person can plan a change, have an agent write the code, and check that it was done as agreed.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Orbitope/leyline
|
|
6
|
+
Project-URL: Issues, https://github.com/Orbitope/leyline/issues
|
|
7
|
+
License-Expression: GPL-3.0-only
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: mcp>=1.0
|
|
11
|
+
Requires-Dist: networkx>=3.0
|
|
12
|
+
Requires-Dist: tree-sitter-c-sharp>=0.23
|
|
13
|
+
Requires-Dist: tree-sitter-python>=0.23
|
|
14
|
+
Requires-Dist: tree-sitter-typescript>=0.23
|
|
15
|
+
Requires-Dist: tree-sitter>=0.23
|
|
16
|
+
Provides-Extra: languages
|
|
17
|
+
Requires-Dist: tree-sitter-bash; extra == 'languages'
|
|
18
|
+
Requires-Dist: tree-sitter-c; extra == 'languages'
|
|
19
|
+
Requires-Dist: tree-sitter-cpp; extra == 'languages'
|
|
20
|
+
Requires-Dist: tree-sitter-gdscript; extra == 'languages'
|
|
21
|
+
Requires-Dist: tree-sitter-go; extra == 'languages'
|
|
22
|
+
Requires-Dist: tree-sitter-java; extra == 'languages'
|
|
23
|
+
Requires-Dist: tree-sitter-kotlin; extra == 'languages'
|
|
24
|
+
Requires-Dist: tree-sitter-lua; extra == 'languages'
|
|
25
|
+
Requires-Dist: tree-sitter-php; extra == 'languages'
|
|
26
|
+
Requires-Dist: tree-sitter-ruby; extra == 'languages'
|
|
27
|
+
Requires-Dist: tree-sitter-rust; extra == 'languages'
|
|
28
|
+
Requires-Dist: tree-sitter-scala; extra == 'languages'
|
|
29
|
+
Requires-Dist: tree-sitter-swift; extra == 'languages'
|
|
30
|
+
Provides-Extra: scip
|
|
31
|
+
Requires-Dist: protobuf>=7.35; extra == 'scip'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# Leyline
|
|
35
|
+
|
|
36
|
+
## Working through an agent
|
|
37
|
+
|
|
38
|
+
Leyline is meant to be driven by a coding agent: you ask in plain words, the agent uses Leyline's map and follows
|
|
39
|
+
one of its skills, and you read the answer or the page it writes. Set it up once, in the repository you will work on:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install leyline-code # or, from a checkout: pip install -e .
|
|
43
|
+
claude mcp add leyline -- leyline serve # connect Claude Code to Leyline's MCP server
|
|
44
|
+
leyline skills install # copy the skills into .claude/skills (and .agents/skills if you have one)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Start a new agent session, then ask. Nothing needs mapping first: the agent maps the code when it needs to.
|
|
48
|
+
|
|
49
|
+
| Skill | What it does | Ask, for example |
|
|
50
|
+
| --- | --- | --- |
|
|
51
|
+
| `leyline-ask` | Answers a question about the code, citing it, and says how sure it is | "Who calls `saveOne`, and is that link certain?" |
|
|
52
|
+
| `leyline-explain-flow` | Explains how something runs, step by step, from the map | "What happens when a user saves a dialogue?" |
|
|
53
|
+
| `leyline-explore-module` | Says what one part of the code holds and where to start reading | "What is in `editor/host`, and where do I start?" |
|
|
54
|
+
| `leyline-change-impact` | Says what a described change would touch, break and need tested | "What would changing the validator's output format affect?" |
|
|
55
|
+
| `leyline-spec` | Plans a change as an OpenSpec folder, checked against the code, then checks it after | "Plan adding an export button, with tests." |
|
|
56
|
+
| `leyline-quick-change` | Makes a small fix with no spec and says whether it was done; says when it needs a spec | "Make the retry count 3." |
|
|
57
|
+
| `leyline-pr-review` | Reviews a pull request: what changed, what it breaks, findings, what to fix first | "Review pull request 123." |
|
|
58
|
+
| `leyline-adversarial-review` | Attacks a plan or a pull request as a logic or a performance reviewer | "Stress-test the plan for loud-engine." |
|
|
59
|
+
| `leyline-tour` | Writes a guided reading order through the code, saved on the map | "Write a tour of the payment code for a new hire." |
|
|
60
|
+
|
|
61
|
+
`leyline skills list` describes them, `leyline skills show <name>` prints one, and running `leyline skills install`
|
|
62
|
+
again updates them, leaving alone any you edited (`--force` replaces those). An agent connected to `leyline serve`
|
|
63
|
+
can also load each skill as an MCP prompt of the same name, or through the `skills` tool, without installing them.
|
|
64
|
+
The rest of this page is what the agent does underneath, and the commands for doing it yourself.
|
|
65
|
+
|
|
66
|
+
## Start here
|
|
67
|
+
|
|
68
|
+
Leyline lets a person design a change to a codebase while a coding agent writes it. Before any code is
|
|
69
|
+
written it shows, on one page, what code will be written, what it will affect and how you will know it
|
|
70
|
+
was done; afterwards it says on the same page whether the change was done as agreed. Everything on that
|
|
71
|
+
page comes from the code and the test results, not from the agent's account of its own work.
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pip install -e . # Python 3.10 or later; installs the `leyline` command
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Three commands, in this order. Each one ends with a `Next:` line saying what to do after it.
|
|
78
|
+
|
|
79
|
+
| Command | When | What you read |
|
|
80
|
+
| --- | --- | --- |
|
|
81
|
+
| `leyline map [repo ...]` | Once, before the first change | Ten lines on what was found, and a map page to browse |
|
|
82
|
+
| `leyline plan <change>` | After the change is written as a spec, and after every edit to it | The page, `leyline.md`, and what is still needed before implementation |
|
|
83
|
+
| `leyline check <change> --tests FILE` | After the code is written | The verdict, added to the same page |
|
|
84
|
+
|
|
85
|
+
A change is an [OpenSpec](https://openspec.dev) folder, `openspec/changes/<id>/`: a proposal, the
|
|
86
|
+
requirements with scenarios, and a task list. You describe the change in words and your agent writes the
|
|
87
|
+
folder; `skills/leyline-spec/SKILL.md` tells it how. `<change>` is the folder or just its id.
|
|
88
|
+
|
|
89
|
+
A change someone else already wrote (a branch, a pull request) has no spec. Check it out and run
|
|
90
|
+
`leyline pr [base]` instead: see [Reviewing a pull request](#reviewing-a-pull-request).
|
|
91
|
+
|
|
92
|
+
A one-line fix needs no spec either: `leyline quick "make the retry count 3" --about RETRIES` before, and
|
|
93
|
+
`leyline quick --done quick-make-the-retry-count-3 --tests -` after. See [A quick change](#a-quick-change).
|
|
94
|
+
|
|
95
|
+
### A worked example
|
|
96
|
+
|
|
97
|
+
The repository `tests/fixture2` has a small Python package with an `Engine` class and its tests.
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
cp -r tests/fixture2 /tmp/engine-repo && cd /tmp/engine-repo
|
|
101
|
+
leyline map .
|
|
102
|
+
```
|
|
103
|
+
```
|
|
104
|
+
Mapped engine-repo in 0.8 s: 14 files, 354 lines, 4 modules.
|
|
105
|
+
Found 34 types, 73 functions, 5 tests and 1 entry point (where a program starts).
|
|
106
|
+
Modules: cs/Mod (8 files), py/src/pkg (2 files), py/tests (2 files), py/web (2 files)
|
|
107
|
+
Design patterns found: builder, composite, decorator, factory 2, singleton, strategy, template method
|
|
108
|
+
Store: .leyline/leyline.db
|
|
109
|
+
Map page: .leyline/map.html (open it in a browser)
|
|
110
|
+
Next: write the change you want as an OpenSpec folder, openspec/changes/<id>/ (ask your agent; ...
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
You tell your agent: "engine names should come back in upper case, and add a `shout`". It writes
|
|
114
|
+
`openspec/changes/loud-engine/` with this `tasks.md`
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
- [ ] 1.1 Change `Engine.start` to return the name in upper case
|
|
118
|
+
- [ ] 1.2 Add `Engine.shout`, the name with an exclamation mark
|
|
119
|
+
- [ ] 1.3 Add the test "Shout"
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
and two scenarios, `Start` and `Shout`, in `specs/engine/spec.md`. Then you plan it, passing the tests'
|
|
123
|
+
output from before any code changes so that `check` can tell a test the change breaks from one that
|
|
124
|
+
already failed (TAP from vitest, node --test or tap, `pytest -rA`, `go test -v`, `jest --verbose`,
|
|
125
|
+
`dotnet test --logger "console;verbosity=detailed"`, or any runner
|
|
126
|
+
that prints one PASS or FAIL line per test):
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
(cd py && PYTHONPATH=src pytest -rA tests) | leyline plan loud-engine --tests -
|
|
130
|
+
```
|
|
131
|
+
```
|
|
132
|
+
# Loud engine
|
|
133
|
+
...
|
|
134
|
+
**State: ready to implement.** It has not been reviewed.
|
|
135
|
+
|
|
136
|
+
Names are hard to read in logs. The plan has 3 tasks: it changes Engine.start and adds Engine.shout.
|
|
137
|
+
Nothing else must be edited with it; 4 places in 1 module run into the changed code and may behave
|
|
138
|
+
differently; 3 existing tests already run through it. It is done when 2 scenarios pass: 1 already has a
|
|
139
|
+
test, 1 needs a test written.
|
|
140
|
+
...
|
|
141
|
+
**Shares a caller or a field with the change, and no task names it.** Each line is either right to
|
|
142
|
+
leave alone or a missing task:
|
|
143
|
+
- Engine.name (used by Engine.start) is also used by Engine.child
|
|
144
|
+
...
|
|
145
|
+
Next: have the plan reviewed (logic and performance) before code is written (ask your agent to run the
|
|
146
|
+
leyline-adversarial-review skill), then run `leyline plan loud-engine` again. Or, if you accept the plan
|
|
147
|
+
as it is, implement it.
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The agent implements the tasks. Then:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
(cd py && PYTHONPATH=src pytest -rA tests) | leyline check loud-engine --tests -
|
|
154
|
+
```
|
|
155
|
+
```
|
|
156
|
+
## 4. Was it done as agreed
|
|
157
|
+
**Yes.** Every task is done, every scenario is proven, and nothing outside the spec changed.
|
|
158
|
+
|
|
159
|
+
| Task | Result | Verdict | Missing |
|
|
160
|
+
| 1.1 Change `Engine.start` to return the name in upper case | done | proven | |
|
|
161
|
+
...
|
|
162
|
+
| Scenario | Result | Verdict | Evidence |
|
|
163
|
+
| Start | passes | proven | its test reaches the changed code on the map |
|
|
164
|
+
| Shout | passes | proven | its test reaches the changed code on the map |
|
|
165
|
+
|
|
166
|
+
Verdicts: 5 proven. Blocking: partial, contradicted, inconclusive. Not blocking: needs you. (The default.)
|
|
167
|
+
|
|
168
|
+
Tests: 5 of 5 passed before, 6 of 6 after.
|
|
169
|
+
Next: nothing left to check; the change was done as agreed. Review the diff and commit it.
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
`check` exits 0 only when the change was done as agreed. Had the agent also edited `Engine.child`, the
|
|
173
|
+
verdict would read "Not yet: 1 edit is outside the spec", and `Next:` would ask you to add a task for it or
|
|
174
|
+
undo it.
|
|
175
|
+
|
|
176
|
+
### What you read
|
|
177
|
+
|
|
178
|
+
`openspec/changes/<id>/leyline.md` is the one page for a change. It opens with its state (not ready,
|
|
179
|
+
ready, done as agreed or not) and a paragraph in plain words, then:
|
|
180
|
+
|
|
181
|
+
1. **What code will be written**: each task and the code it touches.
|
|
182
|
+
2. **What it will affect**: what must be edited with it, what runs into it, the risks, and code that
|
|
183
|
+
shares a caller or a field with the change that no task names.
|
|
184
|
+
3. **How you will know it was done**: each scenario and the test that proves it.
|
|
185
|
+
4. **Review findings** and **Before implementation**: what blocks implementation, and what is left to
|
|
186
|
+
decide. Only you resolve a finding.
|
|
187
|
+
5. **Was it done as agreed**, added by `check`.
|
|
188
|
+
|
|
189
|
+
### With an agent
|
|
190
|
+
|
|
191
|
+
The agent uses the same loop through Leyline's MCP server. To connect it to Claude Code, run this in
|
|
192
|
+
the root of the repository you will work on:
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
claude mcp add leyline -- leyline serve # this repository only
|
|
196
|
+
claude mcp add -s user leyline -- leyline serve # or: every project you open
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
and `leyline skills install` to give Claude Code the skills (see [Working through an agent](#working-through-an-agent)).
|
|
200
|
+
|
|
201
|
+
Claude Code starts the server in the directory it was opened in, and the server reads the store there,
|
|
202
|
+
`.leyline/leyline.db`, or, from a folder inside the repository, the nearest such store above it. Nothing needs to be mapped first: the agent's first call is `map`. If `leyline` is
|
|
203
|
+
installed in a virtual environment, give its full path (`-- /path/to/venv/bin/leyline serve`); for a
|
|
204
|
+
store elsewhere, such as a workspace mapped from another directory, add `-e LEYLINE_DB=/full/path/leyline.db`
|
|
205
|
+
before the `--`. `claude mcp list` should then show `leyline` as connected.
|
|
206
|
+
|
|
207
|
+
The agent's path is the same three tools, `map`, `plan` and `check`, each returning `next`, with the
|
|
208
|
+
review tools between plan and implementation; the server's instructions give the agent that order.
|
|
209
|
+
The skills in `skills/` say how to write the spec (`leyline-spec`), review it
|
|
210
|
+
(`leyline-adversarial-review`), assess any change (`leyline-change-impact`), explain the code
|
|
211
|
+
(`leyline-tour`) and answer how something works (`leyline-explain-flow`). Answers are kept short enough for an agent's context: long lists are cut, and the
|
|
212
|
+
answer says what was cut (`cut`) and how to see the rest (`more`).
|
|
213
|
+
|
|
214
|
+
Everything below is the detail: the other commands, how the map is built, and how far to trust it.
|
|
215
|
+
`leyline --help` lists the other commands under "advanced".
|
|
216
|
+
|
|
217
|
+
## What Leyline is
|
|
218
|
+
|
|
219
|
+
Leyline maps a codebase into one SQLite graph that people and coding agents can both query.
|
|
220
|
+
Every record belongs to one of three layers:
|
|
221
|
+
|
|
222
|
+
- **fact**: extracted deterministically from the source, rebuilt on every index run
|
|
223
|
+
- **inferred**: written by an LLM, with evidence and a confidence
|
|
224
|
+
- **intent**: written by you, such as boundaries and rules
|
|
225
|
+
|
|
226
|
+
This version builds the fact layer for C# and Python, proposes systems by clustering, labels design
|
|
227
|
+
patterns by their shape, generates an orientation tour, assesses a described change before it is made,
|
|
228
|
+
reviews it after, and checks architecture rules.
|
|
229
|
+
|
|
230
|
+
## Use
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
pip install -e .
|
|
234
|
+
leyline map path/to/repo # writes path/to/repo/.leyline/leyline.db and .leyline/map.html
|
|
235
|
+
leyline index path/to/repo # the same index, printing the full statistics instead
|
|
236
|
+
leyline --db ... coverage .coverage # import measured test coverage
|
|
237
|
+
leyline --db path/to/repo/.leyline/leyline.db overview
|
|
238
|
+
leyline --db ... search "Simulation Step"
|
|
239
|
+
leyline --db ... expand "<node id>"
|
|
240
|
+
leyline --db ... tour # a guided walk through the repository
|
|
241
|
+
leyline --db ... patterns # design patterns found by their shape
|
|
242
|
+
leyline --db ... state # fields assigned from outside their own type
|
|
243
|
+
leyline --db ... serve # MCP server over stdio
|
|
244
|
+
leyline --db ... view # the map, served on http://127.0.0.1:8765
|
|
245
|
+
leyline --db ... export -o map.html # the map as one self-contained page
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
To use it from an MCP client, register the command `leyline --db <path> serve`.
|
|
249
|
+
|
|
250
|
+
### Several repositories
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
leyline map flask/ werkzeug/ # one workspace; writes ./.leyline/leyline.db
|
|
254
|
+
leyline --db .leyline/leyline.db index werkzeug/ # re-indexes flask too
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Repositories named together are indexed in one run, so a name in one resolves to its declaration in
|
|
258
|
+
the other: `import werkzeug` in Flask reaches Werkzeug's source (a package under `src/` by its package
|
|
259
|
+
name), a Flask class extends Werkzeug's, and a Werkzeug method that calls `self.open()` dispatches
|
|
260
|
+
into Flask's override. Ids keep their own repo's prefix, so a store indexed one repository at a time
|
|
261
|
+
reads the same. The store remembers its members: indexing any one of them later indexes all of them
|
|
262
|
+
again, which keeps the links between them. A member whose directory has gone is left as stored, and `map` says so.
|
|
263
|
+
Name its new place and it is the same member, under its old id, not a second copy, when Leyline can tell it is the
|
|
264
|
+
same repository: both are git repositories with the same first commit, and the same `origin` where both have one.
|
|
265
|
+
Anything less sure (not under git, or a store mapped before Leyline recorded first commits) is mapped as a new
|
|
266
|
+
member; drop the old one with `leyline map --forget <id>`. That drops a member's facts, where it was and its place
|
|
267
|
+
in the workspace, then maps the others again in full so no link into it is left; notes and review decisions about
|
|
268
|
+
its code are kept, as when code is deleted.
|
|
269
|
+
|
|
270
|
+
`overview` lists the links between repositories; the `cross_repo` tool adds the functions most called
|
|
271
|
+
across and the flows that cross and come back. An edge between repositories carries `to_repo`;
|
|
272
|
+
`impact` and a change assessment count what they reach per repo and flag a change that reaches into
|
|
273
|
+
another one. Not yet: the C# compiler pass (SCIP indexes are matched to their repository by the
|
|
274
|
+
directory they were made in), and values handed across that the syntax pass cannot follow, such as
|
|
275
|
+
the WSGI app a test client calls.
|
|
276
|
+
|
|
277
|
+
### Someone else's repository
|
|
278
|
+
|
|
279
|
+
Leyline is made to be run on code you did not write: a pull request under review, a project you just
|
|
280
|
+
cloned. What such a repository holds is treated as its author's, not as Leyline's or yours:
|
|
281
|
+
|
|
282
|
+
- **`.leyline/` is Leyline's own.** Its files are local and rebuilt by `leyline map`, so a repository
|
|
283
|
+
that tracks files there (a pull request can commit a store with annotations, resolved findings and
|
|
284
|
+
rules of its author's making, a snapshot or a review page, and checking it out writes over your
|
|
285
|
+
ignored copy) is refused, with the files named. Stop tracking them (`git rm -r --cached .leyline`)
|
|
286
|
+
or keep the store elsewhere (`--db /elsewhere/leyline.db`, or `LEYLINE_DB`). A `.leyline` that is
|
|
287
|
+
a link to a folder outside the repository is refused too, so nothing is written there.
|
|
288
|
+
`LEYLINE_TRUST_STORE=1` uses the folder anyway, for a store you committed on purpose.
|
|
289
|
+
- **Nothing is written outside the repository through a link.** A file Leyline writes (the map page,
|
|
290
|
+
a change's `leyline.md`, the anchors and learnings files) replaces a link at its name rather than
|
|
291
|
+
writing through it; a change folder or `openspec/` that is a link to a folder outside the
|
|
292
|
+
repository is read but not written to, and the plan or check says so ("Not written: ...").
|
|
293
|
+
- **A pull request does not vouch for itself.** A learnings file records what reviewers decided
|
|
294
|
+
before, so one a pull request commits ("f may return anything, do not flag it") would steer its own
|
|
295
|
+
review. Learnings the pull request's commits add or change (compared with its base) are not applied
|
|
296
|
+
to it: not in `learnings_that_apply`, not matched against its findings. Its page lists them under
|
|
297
|
+
"Learnings added by this pull request, not applied", for the person to judge.
|
|
298
|
+
- **An agent's paths stay inside the map.** The MCP tools that take a path (`map`'s `paths`, the change
|
|
299
|
+
folder of `plan`, `check` and the `spec_*` tools, `affected_tests`, `drift`'s `path`, `review_pr`'s
|
|
300
|
+
`path`, a coverage file for `check`, `quick` or `coverage`) accept only paths inside the
|
|
301
|
+
repositories the store maps, or, before anything is mapped, the directory the server was started
|
|
302
|
+
in. An agent can be told which path to pass by text it read; the error says the person can run the
|
|
303
|
+
`leyline` command themselves, which takes any path.
|
|
304
|
+
|
|
305
|
+
## How the pieces fit
|
|
306
|
+
|
|
307
|
+
Everything lives in one file: `.leyline/leyline.db` inside the repository you indexed. Three things
|
|
308
|
+
read and write it, and none of them runs unless you start it. Beside it, `leyline.cache.db` holds what the
|
|
309
|
+
next index needs to do again only what an edit changed: each file's parse output under its content hash,
|
|
310
|
+
and fingerprints of the last run. Deleting it only makes the next index a full one, and so does damage to it: a
|
|
311
|
+
cache that is not a database is started again.
|
|
312
|
+
|
|
313
|
+
| Piece | Started by | Reads | Writes |
|
|
314
|
+
| --- | --- | --- | --- |
|
|
315
|
+
| `leyline map` or `index` | You, a git hook or CI; `plan` and `check` when the code changed | The working tree | Facts, flows and system proposals. Replaces the previous facts: after the first run only what changed is redone, and the store comes out as a full run would leave it (`--full` forces one). |
|
|
316
|
+
| `leyline serve` (MCP) | Your coding agent, when it starts | The store | Annotations, change proposals and saved views |
|
|
317
|
+
| `leyline view` or `export` | You | The store | Nothing |
|
|
318
|
+
|
|
319
|
+
One index runs at a time per store. A second one that starts while another is running (a `map` while a `plan`
|
|
320
|
+
re-maps) says so on stderr, naming the other process and how long it has been going, waits for it to finish,
|
|
321
|
+
and then maps what changed since. It waits up to an hour (`LEYLINE_WAIT`, in seconds), and past that stops with
|
|
322
|
+
the same message. A run that dies lets go of the store with its process.
|
|
323
|
+
|
|
324
|
+
Annotations, proposals and views are not facts, so re-indexing keeps them. An annotation is flagged
|
|
325
|
+
stale when the code behind its evidence changes.
|
|
326
|
+
|
|
327
|
+
### The spec loop
|
|
328
|
+
|
|
329
|
+
The intended way to change a mapped codebase: the person drives the design, an agent writes the
|
|
330
|
+
code, and one page says what will be written, what it affects and how you will know it was done.
|
|
331
|
+
|
|
332
|
+
A change is an [OpenSpec](https://openspec.dev) change folder. Leyline reads it and writes
|
|
333
|
+
`leyline.md` back into it. `leyline plan` and `leyline check` (see Start here) run the loop; these are
|
|
334
|
+
its steps one at a time, for when you want one alone:
|
|
335
|
+
|
|
336
|
+
```
|
|
337
|
+
leyline spec brief openspec/changes/<id> # before any code: the one-page brief, and the gaps in the spec
|
|
338
|
+
leyline spec facts openspec/changes/<id> --reviewer logic # what the graph says, as questions for reviewers
|
|
339
|
+
leyline spec verify openspec/changes/<id> --before before --after after # after: was it done as agreed
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
`plan` is `brief` after re-indexing changed code, and records the test output you pass it under
|
|
343
|
+
`before:spec-<id>`. `check` re-indexes, records its test output under `after:spec-<id>`, and runs `verify`
|
|
344
|
+
with both. Before re-indexing, both compare each source file's hash with the store, so an unchanged
|
|
345
|
+
repository is not indexed again.
|
|
346
|
+
|
|
347
|
+
node --test's TAP writes a tab in a test's name and a backslash followed by `t` the same way (`\\t`, and so
|
|
348
|
+
for `\n`, `\r`, `\b`, `\f`, `\v`); Leyline reads it as the tab, so a name with a literal `\t` in it may not
|
|
349
|
+
match its test.
|
|
350
|
+
|
|
351
|
+
The brief ties each task to code and each scenario to a test, by three conventions and no markup:
|
|
352
|
+
|
|
353
|
+
- Code named in backticks in `tasks.md` is looked up on the map (`` `Vehicle.Speed` ``). A name not on
|
|
354
|
+
the map is new code: `` `Owner.NewName` `` says where a member goes, and `` `module.new_func` ``,
|
|
355
|
+
`` `path/to/file.py: new_func` `` or `` `new_func` in `file.py` `` where a top-level function goes. A
|
|
356
|
+
path that is on the map counts with or without backticks. Other words in backticks (an issue code, a
|
|
357
|
+
value, a doc file) are noted, not checked. A task that names no code is left for the person to check;
|
|
358
|
+
a change whose tasks all name no code (docs only) is planned and checked all the same, and `check` says
|
|
359
|
+
whether any code changed with it.
|
|
360
|
+
- A task that starts with add, remove, rename or "change the signature" is read that way. Anything
|
|
361
|
+
else is a change in behavior.
|
|
362
|
+
- A scenario is proven by a test with the same name: one on the map, or one made at run time (a name built
|
|
363
|
+
from a template, a pytest parameter) whose result in the test run carries that name. A scenario that states an
|
|
364
|
+
invariant ("for any", "for every", "always", "never") is marked "invariant: a property test fits", with the
|
|
365
|
+
library for its language (Hypothesis, fast-check, FsCheck). When such a test fails, `check` reads the shrunk
|
|
366
|
+
counterexample from the runner's output and gives it as the reason: "contradicted: fails for amount=-1".
|
|
367
|
+
|
|
368
|
+
It then lists what the change reaches that no task covers, the patterns it sits in, the gaps that
|
|
369
|
+
block implementation, and the code that uses the same things and that no task names: other callers of
|
|
370
|
+
a changed function, other users of a field it uses, and users of an existing member that a new one is
|
|
371
|
+
named like (a new `EmergencyQueues` beside `EntryQueues`). Each of those lines is either right to
|
|
372
|
+
leave alone or a missing task. So is the last such list, which comes from git history rather than the map:
|
|
373
|
+
files that usually change with the files the tasks touch and that no task names (see
|
|
374
|
+
[Change coupling](#change-coupling)). Reviewers (the `leyline-adversarial-review` skill, one run
|
|
375
|
+
for logic and one for performance) file findings with node ids as evidence; only the person
|
|
376
|
+
resolves them (`leyline spec resolve <finding> accepted|rejected|deferred "why"`). A reviewer that passes
|
|
377
|
+
its kind (`--reviewer`, or `reviewer` on the `spec_review_facts` tool) is recorded as having run, so the page
|
|
378
|
+
can say a review found nothing rather than that none ran.
|
|
379
|
+
|
|
380
|
+
After implementation and a re-index, `verify` (inside `check`) marks each task from the graph diff and each scenario
|
|
381
|
+
from its test's recorded result, lists edits outside the spec, new links between modules and rules
|
|
382
|
+
newly broken, and appends the result to `leyline.md`. It exits 0 only when the change was done as
|
|
383
|
+
agreed. A new function that only code named in the spec calls is listed as a helper, not as an edit
|
|
384
|
+
outside the spec.
|
|
385
|
+
|
|
386
|
+
Each task and scenario gets one verdict, shown in the tables, counted under them ("7 proven, 1 needs
|
|
387
|
+
you, 1 inconclusive"), and given as `verdict` and `verdict_why` in the MCP `check` answer:
|
|
388
|
+
|
|
389
|
+
| Verdict | Task | Scenario |
|
|
390
|
+
| --- | --- | --- |
|
|
391
|
+
| proven | the code it names changed | its test passes, and failed or did not exist before, or passed both times while running the changed code |
|
|
392
|
+
| partial | some of the code it names changed, some did not | some results that carry its name pass, some fail |
|
|
393
|
+
| contradicted | the code it names did not change (or what it adds is missing) while other code did | its test fails after the change |
|
|
394
|
+
| inconclusive | the map cannot place the code it names, or nothing changed at all | no pass or fail was recorded for it |
|
|
395
|
+
| needs a person | it names no code (docs, say) | its test passes, but does not reach the changed code, or Leyline cannot tell whether it does |
|
|
396
|
+
|
|
397
|
+
A task that removes code is judged by what is left, since edited is not removed: "Remove `X`" is contradicted while
|
|
398
|
+
`X` still exists, partial when `X` is gone but code that called it still does ("`X` is gone but `caller` still
|
|
399
|
+
calls it"), and proven only when it is gone and uncalled. "Rename `X` to `Y`" also needs `Y` there.
|
|
400
|
+
|
|
401
|
+
Partial, contradicted and inconclusive hold up "done as agreed"; an item that needs a person is listed
|
|
402
|
+
and does not. A project can change that in `openspec/leyline.toml`, committed beside the changes:
|
|
403
|
+
|
|
404
|
+
```toml
|
|
405
|
+
[check]
|
|
406
|
+
blocking = ["contradicted", "inconclusive", "partial", "needs a person"] # every item proven by the code
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
A tick in `tasks.md` never clears an item: the agent ticks tasks as it goes.
|
|
410
|
+
|
|
411
|
+
A spec can change part-way. Once the code has moved on, `brief` keeps the picture of the code from
|
|
412
|
+
the first brief, so `verify` still compares with the code as it was; `--new-baseline` starts over. The code has moved
|
|
413
|
+
on when any source file on the map has changed: a constant at the top of a module counts, and so, to be safe, does a
|
|
414
|
+
comment.
|
|
415
|
+
That picture is `.leyline/snapshots/<change id>.db`: one per change, taken once, holding only what the
|
|
416
|
+
comparison reads (nodes and their hashes, links, flows, a hash per source line) and what a diagram of the code
|
|
417
|
+
as it was needs (the line of each call, the order of each flow, each channel link's channel). It stays after the
|
|
418
|
+
change is checked done as agreed, so `check` can run again after a later edit, and is deleted with
|
|
419
|
+
`leyline spec forget <id>` or once the change folder is archived or removed (a folder that a local branch not checked
|
|
420
|
+
out still holds is not removed: it is back when that branch is). A baseline deleted any other way (by hand, or with
|
|
421
|
+
the snapshots folder) is lost, not forgotten: `plan` will not quietly take the code as it is now as the start, which
|
|
422
|
+
after the change is implemented would leave `check` nothing to find, and asks for `--new-baseline` to take a new one.
|
|
423
|
+
`leyline map` writes `.leyline/.gitignore`, so the store stays out of git without touching your own `.gitignore`.
|
|
424
|
+
|
|
425
|
+
`skills/leyline-spec/SKILL.md` tells an agent how to write the folder and run the loop.
|
|
426
|
+
|
|
427
|
+
### Spec drift
|
|
428
|
+
|
|
429
|
+
Specs name code in backticks, and the code moves on without them: a function is renamed, a method gets
|
|
430
|
+
a parameter, a class moves to another file. `leyline drift` reads every backticked name in the living
|
|
431
|
+
specs (`openspec/specs/<capability>/spec.md`) and in finished changes (`openspec/changes/archive/<date>-<id>/`),
|
|
432
|
+
compares each with the map, and prints one page, spec by spec:
|
|
433
|
+
|
|
434
|
+
```
|
|
435
|
+
## openspec/specs/engine/spec.md
|
|
436
|
+
|
|
437
|
+
- `Engine.start` has changed signature since the spec was written: was `def start(self)`, now `def start(self, loud=True)`.
|
|
438
|
+
- `core.make_engine` is gone: py/src/pkg/core.py has no `make_engine` now.
|
|
439
|
+
- `Journal.note` has moved: it is now in py/src/pkg/journal.py (was in py/src/pkg/core.py).
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
A name is *gone*, *renamed*, *moved* (another file or owner), *signature changed*, *ambiguous* (it now names
|
|
443
|
+
several things) or *changed inside* (same signature; worth a read, not drift by itself). It exits 1 when something
|
|
444
|
+
is gone, renamed or changed signature. *Renamed* is a gone name with one new node beside it (same owner, same file
|
|
445
|
+
or module) that has its body without the name, or its declaration without the name when git or the change's
|
|
446
|
+
baseline shows the node is new; or a file git records as renamed: "`Engine.shout` has been renamed to
|
|
447
|
+
`Engine.yell`". Names that are not code (`true`, `GET`) are counted and left alone, and so is
|
|
448
|
+
code a task removes or renames.
|
|
449
|
+
|
|
450
|
+
To see a changed signature, Leyline must know what a name meant when it was right. When `check` finds a change
|
|
451
|
+
done as agreed, it records an *anchor* for each code name in the change's tasks and spec deltas: the node,
|
|
452
|
+
its kind and file, and fingerprints of its declaration (a function's signature line, a type's declaration and
|
|
453
|
+
member names, a field's type) and of its body. Anchors are kept in the store (table `spec_anchors`) and in
|
|
454
|
+
`openspec/leyline-anchors.json`: canonical JSON, sorted keys, keyed by change id (or `specs/<capability>`),
|
|
455
|
+
with node ids written without the repository's id so another clone reads them. Commit it with the specs; it
|
|
456
|
+
survives a fresh map, and wins over the store. A living spec's name is read against its own anchors, or those
|
|
457
|
+
of the change that wrote it. A name with no anchor is checked by name only. When the specs and the code agree,
|
|
458
|
+
`leyline drift --accept` records the code as it is now; code that is gone stays reported until the spec stops
|
|
459
|
+
naming it.
|
|
460
|
+
|
|
461
|
+
`leyline plan` reads the same anchors: a new change whose tasks touch drifted code lists, under "Specs that no
|
|
462
|
+
longer match code this change touches", lines such as "The living spec engine/spec.md names `Engine.start`,
|
|
463
|
+
which has changed signature since it was written."
|
|
464
|
+
|
|
465
|
+
### Assessing a change
|
|
466
|
+
|
|
467
|
+
1. You describe a change to an agent that has the Leyline MCP server connected.
|
|
468
|
+
2. The agent finds the nodes the description refers to and calls `propose_change`.
|
|
469
|
+
3. Leyline walks callers, interface links, channels and flows from those nodes and stores the result
|
|
470
|
+
as a draft proposal with a saved view.
|
|
471
|
+
4. You refresh the map and open the Views tab: the change, what must be edited with it, what it
|
|
472
|
+
reaches, the tests to run and the risks.
|
|
473
|
+
|
|
474
|
+
`skills/leyline-change-impact/SKILL.md` tells an agent how to do steps 2 and 3 well. The same
|
|
475
|
+
`save_view` tool lets an agent save any other slice of the code as a view.
|
|
476
|
+
|
|
477
|
+
### Reviewing a pull request
|
|
478
|
+
|
|
479
|
+
A spec says what a change will do before it is written. A pull request comes the other way round: the code
|
|
480
|
+
exists, and what it was meant to do is a title and a paragraph. `leyline pr` reads the change from the code:
|
|
481
|
+
|
|
482
|
+
```
|
|
483
|
+
gh pr checkout 123
|
|
484
|
+
leyline pr main --about "what the pull request says it does" # or: leyline pr --github 123
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
It maps the commit the branch left `main` at (from `git archive` into a temporary folder, so the repository
|
|
488
|
+
and its worktrees are not touched; files that did not change keep their parse output, so this costs a few
|
|
489
|
+
seconds), compares it with the checkout, uncommitted edits included, and writes
|
|
490
|
+
`.leyline/reviews/pr-<id>.md`:
|
|
491
|
+
|
|
492
|
+
- **What changed**: functions edited, added and removed, a changed parameter list as it was and as it is,
|
|
493
|
+
and the files the map does not read (docs, styles, data), so you know where its view stops.
|
|
494
|
+
- **What it reaches and did not change**: callers of a changed signature that were not edited, removed code
|
|
495
|
+
that is still called, and each channel the edit touches (an edited line within a few lines of the route,
|
|
496
|
+
table, event or program it names) with every other end that must agree. A channel the changed function
|
|
497
|
+
sits on but the edit does not touch is left out.
|
|
498
|
+
- **Shares a caller, a field or data with the change**: weaker leads, for a reviewer to read.
|
|
499
|
+
- **Usually changes with what it changed, and it did not change**: from the history before the branch (see
|
|
500
|
+
[Change coupling](#change-coupling)).
|
|
501
|
+
- **Tests**: whether it edits any, which changed code no test reaches, and which tests run it.
|
|
502
|
+
- New dependencies between modules, rules that now fail, and the repository's own rules for reviewers
|
|
503
|
+
(`AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md` ...) to review against.
|
|
504
|
+
|
|
505
|
+
With no `--about`, the commits' messages stand for the description. The review is stored as `pr-<id>` (the
|
|
506
|
+
pull request's number, else the branch name), and everything that takes a change id takes it: `leyline spec
|
|
507
|
+
facts pr-123 --reviewer logic` gives the adversarial reviewers their facts, `leyline spec finding pr-123 ...`
|
|
508
|
+
files one, and the page lists them. A finding whose evidence is nowhere near the change (not on its blast
|
|
509
|
+
radius, nor one call from it) is kept but marked, so the person questions it first. `leyline spec forget
|
|
510
|
+
pr-123` deletes the base's map.
|
|
511
|
+
|
|
512
|
+
`leyline pr` exits 0 whatever it finds. With `--gate` it exits 1 while something that blocks is left, so it
|
|
513
|
+
can hold up a merge in CI. The page opens with a **Gate** section: whether it passes, what blocks under which
|
|
514
|
+
config, one line for each thing that blocks, and a `Next:` line saying what to do about the first. The MCP
|
|
515
|
+
`review_pr` answer carries the same lines as `blocking`, and `gate_passed`. Each run judges the gate again from
|
|
516
|
+
the code at the checkout and the findings as they stand, so a commit that fixes a caller, or a finding the person
|
|
517
|
+
resolves, clears it on the next run.
|
|
518
|
+
|
|
519
|
+
By default four things block, the ones the map shows are broken: a caller of a changed signature that was not
|
|
520
|
+
edited, removed code that is still called, a confirmed error-level rule (see [Rules](#rules)) that now fails and
|
|
521
|
+
did not at the base, and an open high finding. A line that rests only on a link the map guessed by name never
|
|
522
|
+
blocks; the Gate section counts those so you can read them. A project picks its own set in
|
|
523
|
+
`openspec/leyline.toml`, as it does for `check`:
|
|
524
|
+
|
|
525
|
+
```toml
|
|
526
|
+
[pr]
|
|
527
|
+
blocking = ["unedited-callers", "still-called", "failing-rules", "open-high-findings"] # the default
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
The kinds are `unedited-callers`, `still-called`, `failing-rules`, `open-high-findings`, `open-medium-findings`
|
|
531
|
+
(medium or high), `open-findings` (any severity), `other-ends` (the other end of a channel the edit changed, not
|
|
532
|
+
edited) and `untested` (changed code no test on the map reaches). `blocking = []` lets everything through.
|
|
533
|
+
|
|
534
|
+
In CI, check the pull request's head out with enough history to find where it left the base:
|
|
535
|
+
|
|
536
|
+
```yaml
|
|
537
|
+
- uses: actions/checkout@v4
|
|
538
|
+
with:
|
|
539
|
+
fetch-depth: 0
|
|
540
|
+
- run: pip install leyline-code
|
|
541
|
+
- run: leyline pr origin/${{ github.base_ref }} --gate
|
|
542
|
+
```
|
|
543
|
+
|
|
544
|
+
### A quick change
|
|
545
|
+
|
|
546
|
+
A spec folder is too much for "make the retry count 3". `leyline quick` gives the same three answers with no folder:
|
|
547
|
+
|
|
548
|
+
```
|
|
549
|
+
pytest -rA | leyline quick "make the retry count 3" --about RETRIES fetch --tests -
|
|
550
|
+
... edit ...
|
|
551
|
+
pytest -rA | leyline quick --done quick-make-the-retry-count-3 --tests -
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
Before the edit it looks up the named code as a task's names are looked up (`--about`, or names in backticks in the
|
|
555
|
+
sentence; a constant the map has no node for is found where it is set, with the functions that read it), assesses what
|
|
556
|
+
it reaches, records the test run, keeps a baseline, and prints a short page: what it will touch, what must be edited
|
|
557
|
+
with it, the channels it touches or is reached across, the tests that run it, and the command to run when done.
|
|
558
|
+
|
|
559
|
+
After the edit, `--done` maps the code again and compares it with the baseline as `leyline pr` compares a branch with its
|
|
560
|
+
base. One line gives the verdict, then four items, each proven, partial, contradicted, inconclusive or needs a person:
|
|
561
|
+
the edits stayed in the named code (a new helper only it calls, and a caller updated to match a changed signature,
|
|
562
|
+
count as part of it), no caller was left broken, no test broke against the run from before, and a test that passed ran
|
|
563
|
+
the changed code. It exits 0 when nothing that blocks is left (by default partial, contradicted and inconclusive; the
|
|
564
|
+
project's `openspec/leyline.toml` applies). An edit that belongs but was not named is named afterwards, keeping the
|
|
565
|
+
baseline: `leyline quick --done <id> --about <name>`.
|
|
566
|
+
|
|
567
|
+
When the change has grown past quick (more than three functions edited, a channel crossed, or a caller that must
|
|
568
|
+
change left as it was), the page says so and gives the way into a spec: write `openspec/changes/<id>/`, run
|
|
569
|
+
`leyline quick --to-spec <id> quick-<slug>` to hand it this baseline and the test run from before, then `leyline plan
|
|
570
|
+
<id>` and `leyline check <id>`.
|
|
571
|
+
|
|
572
|
+
The change is stored as `quick-<slug>`, and the review steps take it as they take `pr-<id>`: `leyline spec facts
|
|
573
|
+
quick-<slug> --reviewer logic`, `leyline spec finding`, `leyline spec findings`, `leyline affected-tests` and
|
|
574
|
+
`leyline spec forget quick-<slug>` (which deletes its baseline). The MCP tool is `quick`.
|
|
575
|
+
|
|
576
|
+
Run it again after new commits and the page opens with **Since the last review (`<sha>`, `<when>`)**: the functions
|
|
577
|
+
edited, added and removed between the two heads (not since the base), the facts that are new (a caller newly broken)
|
|
578
|
+
and those that are gone, and each open finding marked "may be fixed" (the code its evidence names changed since it
|
|
579
|
+
was filed: re-check it) or "still applies" (do not file it again). Each run is kept in the store with its head, its
|
|
580
|
+
time and its facts, and a slim map of the code at that head (`.leyline/snapshots/pr-<id>.head-<n>.db`, the last
|
|
581
|
+
three and any an open finding was filed against); running it again on the same code changes nothing. The reviewers'
|
|
582
|
+
facts carry the same as `since_last_review`.
|
|
583
|
+
|
|
584
|
+
Both `leyline pr` and `leyline plan` list **Earlier changes to this code**: up to five finished OpenSpec changes
|
|
585
|
+
(archived, or checked) and earlier pull request reviews that touched the same functions or types, newest first, with
|
|
586
|
+
the names they share; the facts carry them as `related_changes`. A repository with no such history gets the commits
|
|
587
|
+
before the change that changed the same files instead (of the last 500 that touched them), most overlap first.
|
|
588
|
+
"Checked" means its latest check found it done as agreed: a check that fails after one that passed makes it unfinished
|
|
589
|
+
again, and so does planning it again (it is then reopened; the store keeps each check's time and verdict).
|
|
590
|
+
|
|
591
|
+
### Learning from rejected findings
|
|
592
|
+
|
|
593
|
+
Many review findings that people reject are correct about the code but miss a choice made on purpose. So
|
|
594
|
+
when you reject a finding and say why (`leyline spec resolve <finding> rejected "why"`), Leyline keeps a
|
|
595
|
+
learning: your reason in your words, the finding's claim, its kind of review, and the code it is about (the
|
|
596
|
+
evidence nodes, and the type, file and module each is in). A rejection with no reason keeps nothing.
|
|
597
|
+
|
|
598
|
+
Learnings go in a file in the repository, so you commit them, the team shares them, and a fresh map keeps
|
|
599
|
+
them: `openspec/leyline-learnings.json` when the repository has an `openspec` folder, else
|
|
600
|
+
`.leyline-learnings.json` at its root. A file stays where it was first made. It is JSON with sorted keys
|
|
601
|
+
and a two-space indent, one entry per learning: `id`, `status` (active or retired), `reviewer`, `claim`,
|
|
602
|
+
`reason`, `scope`, `fingerprint` (the code it is about, see below), `source` (the change and finding it
|
|
603
|
+
came from), `created`, `confirmed` (when a person last said it holds for the code as it is), `hits`,
|
|
604
|
+
`dismissals`, `accepted`, and `findings` (the later findings it matched, with what people decided). Node
|
|
605
|
+
ids in it leave out the repository's id, so a clone in a folder of another name reads them. In a workspace of
|
|
606
|
+
several repositories each keeps its own file, and a learning applies only to code in its own repository.
|
|
607
|
+
|
|
608
|
+
- **Reviewers read them first.** The facts for a spec or a pull request (`leyline spec facts`, or
|
|
609
|
+
`spec_review_facts`) start with `learnings_that_apply`: active learnings about code the change touches or
|
|
610
|
+
reaches, closest first.
|
|
611
|
+
- **A repeat is marked, not dropped.** A new finding of the same kind of review, on the same node, type or
|
|
612
|
+
file, whose claim shares at least 45% of its words with a learning's claim (60% when only the module is
|
|
613
|
+
shared), comes back with `learned`, naming the learning and its reason. The page shows it as "Matches a
|
|
614
|
+
past decision: ..." and the person still decides it. Words are compared loosely: lower case, code names
|
|
615
|
+
split (`parseArgs` and `parse_args` are one name, `app.use.count` is `count`), the names of the code both
|
|
616
|
+
claims are about left out (they say where, not what), common words dropped, endings cut, words a review
|
|
617
|
+
uses for the same thing made one (about forty groups, such as argument and parameter, remove and delete,
|
|
618
|
+
null, None and undefined, caller and call site; `SYNONYMS` in `learnings.py`), and words found in nearly
|
|
619
|
+
every claim (file, read, value) counted half.
|
|
620
|
+
- **A wrong learning retires itself.** Each later decision on a finding it matched is counted. Once people
|
|
621
|
+
have accepted at least two of them, and more than they rejected, the learning is retired and the file
|
|
622
|
+
says why. It is also retired if the finding it came from is later marked anything but rejected.
|
|
623
|
+
- **A learning about code that has changed says so.** A learning keeps a fingerprint: for each evidence
|
|
624
|
+
node, the hash the map keeps of that node's own lines (trimmed, so moving or re-indenting it does not
|
|
625
|
+
count; the file's hash for a node with none). Each time a learning is used, in `learnings_that_apply`,
|
|
626
|
+
in `learned` on a new finding, on the page and in `leyline learnings`, it is compared with the map. If a
|
|
627
|
+
node was edited or is gone, the learning is `stale` and names them in `edited` and `gone`. It still
|
|
628
|
+
applies and still marks findings, and is not retired. The page says "Matches a past decision, but the
|
|
629
|
+
code it was about has changed since: ..." and asks whether it still holds. That is the person's call:
|
|
630
|
+
`leyline learnings confirm <id>` takes the fingerprint again for the code as it is now, and
|
|
631
|
+
`leyline learnings retire <id>` ends it. A learning kept before Leyline recorded fingerprints has none.
|
|
632
|
+
Whether its code changed is unknown, not stale, and it says so; confirming it gives it one.
|
|
633
|
+
|
|
634
|
+
`leyline learnings` lists them, and exits 1, naming the file, when a learnings file cannot be read;
|
|
635
|
+
`leyline learnings retire <id> "why"` retires one by hand; `leyline learnings confirm <id>` says one still holds for
|
|
636
|
+
the code as it is now.
|
|
637
|
+
|
|
638
|
+
### How it runs: sequence diagrams
|
|
639
|
+
|
|
640
|
+
`leyline.md` (under "What code will be written", and again after `check`) and the pull request page carry a
|
|
641
|
+
Mermaid sequence diagram of the changed code, which GitHub draws from the ```` ```mermaid ```` block: how execution
|
|
642
|
+
reaches it from the nearest entry point (along the stored flow, in source order), and what it calls. Participants
|
|
643
|
+
are types, or the file for a function at the top of a file. Every arrow is an edge on the map: a call, a call through
|
|
644
|
+
an interface into an implementation, or a channel link, drawn with an open arrowhead (`-)`) and named (`http GET
|
|
645
|
+
/api/x`, `writes table orders, which load() reads later`); a link the map guessed by name is dotted. The changed
|
|
646
|
+
code is shaded. A diagram keeps to about 8 participants and 25 arrows, and says how many calls it left out.
|
|
647
|
+
|
|
648
|
+
After a change, the check page and the pull request page show two diagrams, **Before** and **After**, then list what
|
|
649
|
+
changed in how it runs: calls and channel links into or out of the changed code, added and removed since the
|
|
650
|
+
baseline. The Before diagram is drawn from the baseline alone, never from the map as it is now, and is shown only
|
|
651
|
+
when every one of its arrows is a link the baseline holds (`diagrams.unbacked(baseline, d)`; given the map, it
|
|
652
|
+
checks a diagram of the code as it is). A baseline taken by an older Leyline kept which pairs were linked but not in
|
|
653
|
+
what order: it still compares, and the code as it was is listed, not drawn. Keeping the order made a baseline of
|
|
654
|
+
Parlance about 22% larger (8.2 MB to 10.0 MB).
|
|
655
|
+
|
|
656
|
+
### Reviewing a change after it is made
|
|
657
|
+
|
|
658
|
+
The first `propose_change` of a change keeps a baseline in `.leyline/snapshots/<change id>.db` (what the
|
|
659
|
+
comparison reads, not a copy of the store). Once the change is implemented:
|
|
660
|
+
|
|
661
|
+
```
|
|
662
|
+
<run the tests> > before.txt # before the change, on the old code
|
|
663
|
+
leyline record-tests before before.txt
|
|
664
|
+
... implement ...
|
|
665
|
+
<run the tests> > after.txt
|
|
666
|
+
leyline index .
|
|
667
|
+
leyline record-tests after after.txt
|
|
668
|
+
leyline review <change id> --before before --after after
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
`review` compares the snapshot with the graph as it is now and reports:
|
|
672
|
+
|
|
673
|
+
- which predicted edits were made, which edits were not in the proposal, and which predicted edits
|
|
674
|
+
did not happen. A method whose parameters changed gets a new id; it is paired with the old one
|
|
675
|
+
by owner and name, so it counts as one edited method.
|
|
676
|
+
- links between modules that are new or gone, and the flows whose path changed
|
|
677
|
+
- rules that fail now and held before
|
|
678
|
+
- tests that fail now and passed before
|
|
679
|
+
|
|
680
|
+
It saves the result as a view, which the map shows under Views as "Review: ...".
|
|
681
|
+
`record-tests` reads one `PASS name` or `FAIL name: message` line per test; an agent can pass
|
|
682
|
+
results in any other format through the `record_test_run` tool.
|
|
683
|
+
|
|
684
|
+
### Rules
|
|
685
|
+
|
|
686
|
+
A rule is a constraint on the graph, checked with no inference: `forbid` (nothing in A may use
|
|
687
|
+
anything in B), `no_cycle` (between modules or systems), `must_be_tested` (every function in A is on
|
|
688
|
+
some test's path). Selectors are `module:Name`, `system:Name`, `external:Name`, `path:prefix`,
|
|
689
|
+
`id:prefix` and `*`. A rule an agent adds is stored as suggested until you confirm it with
|
|
690
|
+
`leyline rules --confirm ID`. `leyline rules` checks all of them and exits 1 when a confirmed
|
|
691
|
+
error-level rule fails, so it can run in CI. A `forbid` rule on an external package sees imports
|
|
692
|
+
(`using`, `import`), not fully qualified names used inline.
|
|
693
|
+
|
|
694
|
+
### Exact references
|
|
695
|
+
|
|
696
|
+
The syntax resolvers below work without a compiler and are sometimes wrong. Where a compiler's view
|
|
697
|
+
is available, `leyline index` lets it overrule them (`--exact auto`, the default; `--exact off` to
|
|
698
|
+
skip):
|
|
699
|
+
|
|
700
|
+
- **C#**: if `dotnet` is on the PATH, a small program built against the Roslyn that ships inside
|
|
701
|
+
the .NET SDK binds every module and reports each call and field access it resolved. It needs no
|
|
702
|
+
package restore. A module whose packages are missing still binds whatever refers to source in the
|
|
703
|
+
repository; the rest stays as the syntax pass left it. The first run builds the program into
|
|
704
|
+
`~/.cache/leyline` (about ten seconds).
|
|
705
|
+
- **Other languages**: a SCIP index, from `--scip FILE` or `index.scip` in the repository root.
|
|
706
|
+
Tested with scip-python. Run the indexer in an environment where the package resolves to the
|
|
707
|
+
source tree (an editable install), or references through the installed copy are lost.
|
|
708
|
+
|
|
709
|
+
A link the compiler confirms is stored as `exact`. A link it contradicts is removed, but only where
|
|
710
|
+
it bound every call of that name in the function (C#) or bound that name to something else on the
|
|
711
|
+
same line (SCIP, which does not say what it failed to bind). Links it found and the syntax pass
|
|
712
|
+
missed are added. `overview` reports the counts under `exact:roslyn` and `exact:scip`.
|
|
713
|
+
|
|
714
|
+
### Measured coverage
|
|
715
|
+
|
|
716
|
+
Flows are static: what a test can reach. `leyline coverage FILE` imports what did run:
|
|
717
|
+
|
|
718
|
+
- coverage.py's data file. With `pytest --cov=<package> --cov-context=test`, each function is tied
|
|
719
|
+
to the tests that ran it.
|
|
720
|
+
- Cobertura XML (coverlet, `coverage xml`). A function ran or did not; no per-test detail.
|
|
721
|
+
- Istanbul's `coverage-final.json` (vitest `--coverage.reporter=json`, jest `--coverageReporters=json`). It covers a
|
|
722
|
+
whole run, so run one test file at a time and import each with `--test <that file>` (`test` on the `coverage`
|
|
723
|
+
tool): each function is then tied to the test file that ran it, not to the one test inside it.
|
|
724
|
+
- lcov (`lcov.info`, from c8, vitest or jest with the `lcov` reporter, and most other tools). Lines (`DA:`) and
|
|
725
|
+
functions (`FN:`, `FNDA:`) that ran; records under a `TN:` test name are tied to that test, and a file with no test
|
|
726
|
+
names is a whole run, imported with `--test` as Istanbul's is. Paths may be absolute, relative or Windows-style.
|
|
727
|
+
|
|
728
|
+
#### Scenarios proven by what ran
|
|
729
|
+
|
|
730
|
+
A scenario's test passing says the behavior holds; it does not say the test ran the code the change edited. Pass the
|
|
731
|
+
coverage of the same run to `check`, and each scenario also says whether its test ran the changed code:
|
|
732
|
+
|
|
733
|
+
```
|
|
734
|
+
pytest -rA --cov=src --cov-context=test > after.txt
|
|
735
|
+
leyline check <change> --tests after.txt --coverage .coverage
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
Each scenario result gets `ran_changed_code` (true, false, or null when it cannot be told) and a one-line
|
|
739
|
+
`ran_changed_code_note`. A test that passed without running the changed code is listed under **Weaker proof**: it
|
|
740
|
+
would pass whatever the change did. With no per-test coverage, `check` reads as it always has.
|
|
741
|
+
|
|
742
|
+
#### The tests a change needs
|
|
743
|
+
|
|
744
|
+
`leyline affected-tests <change>` (the `affected_tests` tool) lists the tests to run for a planned change or a
|
|
745
|
+
`pr-<id>` review, each with why, and prints a command that runs them (`pytest path::test ...`, `npx vitest run
|
|
746
|
+
<files>`, `npx jest --runTestsByPath <files>`, `go test -run`). With per-test coverage it takes the tests measured running the changed
|
|
747
|
+
or must-edit code, the change's own new tests, and, from the map, tests that reach changed code no measured test ran
|
|
748
|
+
or that the measured run left out; without it, the tests whose path on the map passes through the change. Feed that
|
|
749
|
+
smaller run to `check`. `leyline pr` lists the tests measured running the changed code the same way.
|
|
750
|
+
|
|
751
|
+
With coverage imported, a test's flow dims the steps that did not run and lists what ran without
|
|
752
|
+
being on its path (reached through a link the map does not have, such as a framework calling back
|
|
753
|
+
into the code). A change assessment adds the tests that were measured running the changed code, and
|
|
754
|
+
`must_be_tested` counts measured functions as tested. Only a function's body counts: the `def` line
|
|
755
|
+
runs when the file loads. An import outlives a re-index; `leyline coverage` flags one taken at
|
|
756
|
+
another commit.
|
|
757
|
+
|
|
758
|
+
### Field reads and writes
|
|
759
|
+
|
|
760
|
+
Each function is linked to the fields it reads and the fields it assigns (`reads` and `writes` edges,
|
|
761
|
+
with a count and the first line). `expand` on a field lists its readers and writers; on a function,
|
|
762
|
+
the fields it touches; on a type, each field with how many functions set and read it and which
|
|
763
|
+
other types set it. `leyline state` (the `shared_state` tool) ranks the fields assigned from outside
|
|
764
|
+
the type that declares them: the mutable state with no single owner.
|
|
765
|
+
|
|
766
|
+
- An assignment is `x.f = v`, `x.f += v`, `x.f++`, `out`/`ref x.f` and `x.f[i] = v`. A value set
|
|
767
|
+
while creating an object (`new Foo { f = 1 }`) is recorded as construction and left out of the
|
|
768
|
+
shared-state ranking, as are constructors, subclasses and test code.
|
|
769
|
+
- A call on the field to a method that changes a collection in place (`list.Add(x)`, `items.append(x)`,
|
|
770
|
+
`queue.push(x)`) is a read and a write. The methods are named in a short list per language (`MUTATORS` in
|
|
771
|
+
each adapter); any other method call on a field is a read, since the map cannot tell what it does.
|
|
772
|
+
- Properties count as fields. Enum members and events do not. In Python, reading an attribute that a
|
|
773
|
+
getter computes (`@property`, or a decorator that is a descriptor class, such as Werkzeug's
|
|
774
|
+
`cached_property`) is also a call to the getter; on `self`, so is a subclass's getter of that name.
|
|
775
|
+
- In C#, a field on a receiver of unknown type is linked by name only when one field has that name,
|
|
776
|
+
and marked as a guess. In Python there is no such guess: attribute names repeat too often.
|
|
777
|
+
- A change assessment whose target is a field marks every reader and writer, and `review` reports
|
|
778
|
+
field access that is new.
|
|
779
|
+
|
|
780
|
+
### Design patterns
|
|
781
|
+
|
|
782
|
+
Every index run looks for nine shapes in the graph and labels what it finds, with the nodes that
|
|
783
|
+
play each role, a sentence saying why, and a confidence:
|
|
784
|
+
|
|
785
|
+
| Pattern | The shape that is matched |
|
|
786
|
+
|---|---|
|
|
787
|
+
| strategy | an abstraction with two or more implementations, held in a field by another type that calls it |
|
|
788
|
+
| decorator | a type that implements an abstraction and holds one more of it |
|
|
789
|
+
| composite | a type that implements an abstraction and holds a collection of it |
|
|
790
|
+
| template method | a concrete method on a base type that calls methods its subclasses override |
|
|
791
|
+
| observer | an event with a raiser and subscribers in other code |
|
|
792
|
+
| factory | a function that creates two or more types sharing a supertype (higher confidence when it returns that supertype) |
|
|
793
|
+
| builder | a type with chaining methods that return itself and a `Build` or `Create` that returns something else |
|
|
794
|
+
| singleton | a static field of the type's own type and no public constructor |
|
|
795
|
+
| process boundary | one program launching another and talking over its pipes |
|
|
796
|
+
|
|
797
|
+
A label says the code has the shape. It does not say the author meant the pattern, and a pattern
|
|
798
|
+
built another way is not found. Labels in tests, samples and benchmarks are kept apart. An agent that
|
|
799
|
+
has read the code can add a label the matchers missed with `label_pattern`; it goes stale when the
|
|
800
|
+
code behind it changes.
|
|
801
|
+
|
|
802
|
+
### Tours
|
|
803
|
+
|
|
804
|
+
A tour is an ordered list of stops, each pointing at something on the map (a module, a function, a
|
|
805
|
+
flow, a pattern, a saved view) with a few sentences on what to notice. `leyline index` writes an
|
|
806
|
+
orientation tour from the graph alone: what the repository is, where it starts, the modules from the
|
|
807
|
+
most depended-on outward, the main abstractions and boundaries, one test path worth tracing, how it
|
|
808
|
+
is tested, and what the map cannot see. Every sentence in it is a count or a name the store can
|
|
809
|
+
back. An agent can write further tours with `save_tour`; `skills/leyline-tour/SKILL.md` says how.
|
|
810
|
+
|
|
811
|
+
### Change coupling
|
|
812
|
+
|
|
813
|
+
Some files have to change together with nothing in the code to say so: a doc and the rule it describes, a JSON
|
|
814
|
+
schema and its reader, a test fixture, the other side of a protocol. Git history shows them. `leyline coupling
|
|
815
|
+
<file>` lists what usually changed in the same commits as a file, and `leyline coupling` alone the most coupled
|
|
816
|
+
pairs:
|
|
817
|
+
|
|
818
|
+
```
|
|
819
|
+
$ leyline coupling tooling/validate.py
|
|
820
|
+
`tooling/validate.py` changed in 12 commits (from the last 206 commits, leaving out 17 that changed more than 50
|
|
821
|
+
files). What usually changed with it:
|
|
822
|
+
|
|
823
|
+
- files in `editor/core/src/validation/`: 7 of 12 (58%)
|
|
824
|
+
- `tooling/conformance/validator/build_cases.py`: 7 of 12 (58%)
|
|
825
|
+
- files in `tooling/conformance/validator/cases/`: 7 of 12 (58%)
|
|
826
|
+
- `tooling/scripts/mutation_probe.py`: 6 of 12 (50%)
|
|
827
|
+
```
|
|
828
|
+
|
|
829
|
+
That is the rule a contributor's guide states in words (a validator rule changes both validators, adds a
|
|
830
|
+
conformance case and a mutant), found from history alone.
|
|
831
|
+
|
|
832
|
+
A file is listed when it changed together with the first in at least 3 commits and in at least half of the first
|
|
833
|
+
file's commits (`--min-together`, `--min-confidence`). A folder is listed when its files did, though no one file
|
|
834
|
+
did (a rule that comes with a new test case each time), unless it is at the top of the repository. Read: the last
|
|
835
|
+
1,000 commits within two years, merges and commits that changed more than 50 files left out, renames followed.
|
|
836
|
+
It is worked out once per commit and kept in the store. `plan` lists the files that usually change with the files
|
|
837
|
+
the tasks touch and that no task names, and `pr` those that usually changed with what the branch changed and that
|
|
838
|
+
it did not; `leyline spec facts` has the whole list.
|
|
839
|
+
|
|
840
|
+
The same is worked out by function, for the functions the tasks name (in `plan`) or the branch edited (in `pr`), up
|
|
841
|
+
to 6 of them: "`spec.py.verify` changed in 8 of the 9 commits that changed `spec.py.brief`". A past commit's line
|
|
842
|
+
numbers say which function they were in only against the file as it was then, so this reads, for each function, the
|
|
843
|
+
last 20 commits that changed its lines (`git log -L`), parses its file as it was at each with the same parser the map
|
|
844
|
+
uses, and puts each changed line in the innermost function around it; then reads the same way the 4 other files that
|
|
845
|
+
changed in most of those commits. The same thresholds apply (3 commits, half of them). Which functions a commit
|
|
846
|
+
changed in a file is kept in the store, so only the first plan pays for it (about a second on this repository; a
|
|
847
|
+
function renamed or moved counts only from then).
|
|
848
|
+
|
|
849
|
+
### An outline for an agent
|
|
850
|
+
|
|
851
|
+
`leyline context <focus...> [--tokens N]` (the `context` tool) gives an agent about to edit some code a short outline of
|
|
852
|
+
the code around it, cut to a token budget (2,000 by default, 200 to 8,000; a token is counted as four characters):
|
|
853
|
+
|
|
854
|
+
```
|
|
855
|
+
$ leyline context registerLoreRoutes --tokens 1000
|
|
856
|
+
Code around registerLoreRoutes: declarations, the most related files first; > marks the focus.
|
|
857
|
+
|
|
858
|
+
editor/host/src/lore-routes.ts
|
|
859
|
+
const relOf = (params: unknown): string
|
|
860
|
+
-- called by registerLoreRoutes
|
|
861
|
+
> function registerLoreRoutes(server: FastifyInstance, opts: { index: () => ReferenceIndex }): void
|
|
862
|
+
-- answers GET /api/lore-files, GET /api/usages/:type/:id; called from 1 place, calls 12
|
|
863
|
+
|
|
864
|
+
editor/host/src/server.ts
|
|
865
|
+
async function buildServer()
|
|
866
|
+
-- answers GET /api/types, ... and 29 more; calls registerLoreRoutes
|
|
867
|
+
...
|
|
868
|
+
editor/client/src/lib/loreApi.ts
|
|
869
|
+
const loreApi = { ... }
|
|
870
|
+
listFiles: ()
|
|
871
|
+
-- requests GET /api/lore-files
|
|
872
|
+
Shown: 30 symbols in 17 files.
|
|
873
|
+
Left out: 1 more symbol linked directly to the focus and 422 two links away, in 120 files; the nearest: ...
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
The focus is node ids, names (`Owner.method`), file paths, a change (`spec-<id>`, `pr-<id>`: the code its plan or review
|
|
877
|
+
marked) or words to search for. Every function, type, field and test is ranked by personalized PageRank from the focus
|
|
878
|
+
over calls, channel links, type use and containment, each way, with a small boost for code a test reaches and code an
|
|
879
|
+
entry point runs first. The outline shows declaration lines, not bodies, file by file, most related file first: the focus
|
|
880
|
+
marked `>`, each channel end in words ("answers GET /api/x", "writes table t"), and what calls the focus or what it calls
|
|
881
|
+
said so. The last lines say how much was left out and name the nearest of it. The ranking visits only code near the focus,
|
|
882
|
+
and the graph is read once per map run: on Parlance (160,000 lines) the first call takes 0.3 s and later ones 0.05 s.
|
|
883
|
+
|
|
884
|
+
### Asking how something works
|
|
885
|
+
|
|
886
|
+
Leyline holds no language model; an agent asks it in words and reasons over what comes back. Two commands (and the
|
|
887
|
+
tools of the same names) make "how does a vehicle get spawned" or "what happens when a writer saves a dialogue" a
|
|
888
|
+
question with an answer on the map, and the `leyline-explain-flow` skill drives them:
|
|
889
|
+
|
|
890
|
+
```
|
|
891
|
+
$ leyline find-flows "what happens when a writer saves a dialogue in the editor"
|
|
892
|
+
1. DialogueScriptEditor.save [UI event handler] editor/client/src/surfaces/DialogueEditor/DialogueScriptEditor.tsx:338
|
|
893
|
+
matched: saves in its name; dialogue in the name of what it is in; editor in the name of what it is in
|
|
894
|
+
2. DialogueCanvas.onInspectorSave [UI event handler] editor/client/src/surfaces/DialogueEditor/DialogueCanvas.tsx:764
|
|
895
|
+
...
|
|
896
|
+
$ leyline explain-path DialogueCanvas.save
|
|
897
|
+
Walk from DialogueCanvas.save: 40 of 154 steps shown.
|
|
898
|
+
1. DialogueCanvas.save (editor/client/src/surfaces/DialogueEditor/DialogueCanvas.tsx:230)
|
|
899
|
+
2. call -> useAppStore.saveEntity (editor/client/src/store.ts:754)
|
|
900
|
+
at editor/client/src/surfaces/DialogueEditor/DialogueCanvas.tsx:233: return saveEntity(updated, label, ...
|
|
901
|
+
...
|
|
902
|
+
17. http PUT /api/entities/:type/:id -> the PUT /api/entities/:type/:id handler (editor/host/src/server.ts:361)
|
|
903
|
+
crosses to another process or service
|
|
904
|
+
...
|
|
905
|
+
31. call -> scheduleValidation (editor/host/src/validation.ts:161)
|
|
906
|
+
```
|
|
907
|
+
|
|
908
|
+
`find-flows` ranks every function and test by the words it shares with the description: in its name, its route, the
|
|
909
|
+
test's name, the name of the type or component it sits in, its file's name and folders, its language, and the comment
|
|
910
|
+
just above it (or a Python docstring). Code names are split (`saveDialogue` is save and dialogue), endings are cut
|
|
911
|
+
(saves, saved, saving), a short table makes words that mean the same in code one (save, write, persist, store, put;
|
|
912
|
+
create, add, spawn; run, execute, play, start; about twenty groups, `SYNONYMS` in `explain.py`), a word rare on the
|
|
913
|
+
map counts for more, and a word for who does it (a user, a writer) counts little. Route handlers, UI event handlers,
|
|
914
|
+
program entries, message handlers and commands are ranked up, then tests. Each candidate says why it matched and which
|
|
915
|
+
flows start there; one where none starts names the entry points' flows that reach it (`reached_from`) and the tests'
|
|
916
|
+
(`tests_reaching`). `ambiguous` is set when the first ones score close in different files, so the agent
|
|
917
|
+
asks the person which they mean. The words index is built once per map run (about a second on Parlance) and kept: a
|
|
918
|
+
later question takes a few milliseconds.
|
|
919
|
+
|
|
920
|
+
`explain-path <start>` walks from a function, a route (`"PUT /api/x"`), a test or a flow id. Alone, it follows the
|
|
921
|
+
stored flow that starts there, or walks the same way now (depth first, in the order the code makes its calls, 8 calls
|
|
922
|
+
deep at most), and shows the shallow steps first and every hop across a channel, up to `--steps` (40); leaves called
|
|
923
|
+
from 8 or more places are counted as helpers, not shown. `--to` gives the shortest path (without file, table and key
|
|
924
|
+
hops when there is one), `--through` the path through a node and on from it. Each step says how it was reached (a
|
|
925
|
+
call, `http PUT /x`, `starts <program>`, a message, an interface's implementation), the calling line and the callee's
|
|
926
|
+
declaration line, and `later, elsewhere` for a table, file or key it writes that other code reads later. The walk ends
|
|
927
|
+
with what the map could not see: guessed links, interfaces, the depth limit, and a program a type in the walk started
|
|
928
|
+
and talks to over its pipes (the messages are not calls the map can follow, so it names the program to walk next).
|
|
929
|
+
Its diagram has one arrow per step, each a link on the map. `leyline diagram <ids>` draws the usual sequence diagram
|
|
930
|
+
for any functions or types.
|
|
931
|
+
|
|
932
|
+
### Finding your way around a large module
|
|
933
|
+
|
|
934
|
+
On a large repository a module is still huge: Parlance's `editor/client` holds 249 files and the map draws it as one
|
|
935
|
+
box. `leyline outline <module>` (the `module_outline` tool) splits it into at most twelve parts, largest first, and
|
|
936
|
+
says for each what it holds and how it connects. With no module it lists the repository's modules. Here is
|
|
937
|
+
`editor/core/src` after an agent named its parts:
|
|
938
|
+
|
|
939
|
+
```
|
|
940
|
+
$ leyline outline parlance:dir:editor/core/src
|
|
941
|
+
Core library (folder, editor/core/src/): 67 files, 669 functions, 20,380 lines.
|
|
942
|
+
The project model and every engine on it: storage, validation, explore, play, prose, review, export.
|
|
943
|
+
Split by folders and groups of files. 11 of 12 parts have a name.
|
|
944
|
+
|
|
945
|
+
1. Storage and rename (projectStorage.ts group) [system]: 7 files, 104 functions, 3,098 lines
|
|
946
|
+
id: parlance:dir:editor/core/src#system:projectStorage.ts
|
|
947
|
+
Canonical JSON in and out, the one atomic write path, loading a project, and entity-id renames.
|
|
948
|
+
entry points: none; 103 of 104 functions on a test's path
|
|
949
|
+
uses: editor/runtime (25), Validation rules (8), Validator (8), Review threads and localization (6), ...
|
|
950
|
+
used by: Core tests (496), editor/host (175), editor/mcp (64), editor (16), editor/client (10), projectDiff.ts (6)
|
|
951
|
+
channel: file from editor/host, 5 links (e.g. threads/*.json)
|
|
952
|
+
busiest: stringify (887 flows), parse (753 flows), ownValue (738 flows)
|
|
953
|
+
key types: StorageContext, RenamePlan, EntityType, RenameInput
|
|
954
|
+
risky: busy: its functions are on the most flows
|
|
955
|
+
2. Validation rules (validation/): 9 files, 78 functions, 2,850 lines
|
|
956
|
+
...
|
|
957
|
+
12. 12 more parts [more]: 18 files, 85 functions, 2,565 lines
|
|
958
|
+
```
|
|
959
|
+
|
|
960
|
+
A part is a folder when the folder tree means something; a folder that holds nearly all of a module is shown instead
|
|
961
|
+
of it (`editor/client` is shown from `editor/client/src`), with the few files beside it as parts of their own. Where a
|
|
962
|
+
folder is flat, its files are grouped by Louvain community detection over calls, type use and inheritance between
|
|
963
|
+
them, each file going to the group that holds most of its code; when few of them link to each other (a folder of
|
|
964
|
+
tests), they are grouped by the code they use instead. A module that is one flat folder reuses the systems the map
|
|
965
|
+
proposed. A level of more than twelve parts shows the eleven largest and a `@more` part holding the rest. A file or a
|
|
966
|
+
group of one file is a leaf: its key functions, most flows through them first.
|
|
967
|
+
|
|
968
|
+
For each part: its size; its entry points (routes, UI handlers and components, programs, commands, message handlers)
|
|
969
|
+
and tests, and how many of its functions are on a test's path; what it uses and what uses it (its siblings, the
|
|
970
|
+
folder next to it, other modules), as counted calls and type links; the channels that cross its edge; its busiest
|
|
971
|
+
functions (most flows through them) and the types other code leans on most; and `risky` beside its siblings: the
|
|
972
|
+
busiest, the most depended on, and those few tests reach. `--depth 2` (the default) also lists the parts inside each
|
|
973
|
+
part; drill in with a part's id. Ids hold across maps: `<repo>:dir:<path>` for a folder, `<path>#files` for the loose
|
|
974
|
+
files beside its folders, `<path>#system:<anchor>` for a group (named after its most linked type or file), a file's or
|
|
975
|
+
a module's node id for those.
|
|
976
|
+
|
|
977
|
+
An agent names a part after reading its code: `name_part(part_id, name, summary, evidence)`, or `leyline name-part
|
|
978
|
+
<part id> "Validation rules" --summary "..." --evidence <node ids>` (`--intent` when the person said it). Names are
|
|
979
|
+
kept in the store (table `part_names`) with the part's files at the time, so they survive a re-map: a group whose
|
|
980
|
+
anchor changed takes the name of the old group of its folder it shares most files with, and a name whose part kept
|
|
981
|
+
less than half its files, or most of whose evidence is gone, is shown as "may be stale". Outlines, `overview` (module
|
|
982
|
+
titles and `named_parts`), `explain_path` (each step says the named part it enters) and the map page show the names.
|
|
983
|
+
Naming a system the map proposed also sets its `name` and `responsibility`.
|
|
984
|
+
|
|
985
|
+
The `leyline-explore-module` skill walks an agent through it: outline the module, read each part's key code, name
|
|
986
|
+
it, and give the person a one-screen guide (the parts, how they connect, where to start reading for common tasks,
|
|
987
|
+
which parts are risky), offered as a tour.
|
|
988
|
+
|
|
989
|
+
## The map
|
|
990
|
+
|
|
991
|
+
`view` and `export` open the same page. It has up to four zoom levels:
|
|
992
|
+
|
|
993
|
+
1. **Modules.** One box per module, with an arrow for each dependency and a count of the links behind it.
|
|
994
|
+
2. **Inside a module.** A module of 30 files or more is drawn as its parts, as `leyline outline` splits it (see
|
|
995
|
+
[Finding your way around a large module](#finding-your-way-around-a-large-module)), with their names and sizes;
|
|
996
|
+
open a part for the parts inside it, then its types and files. A smaller module shows one box per system where
|
|
997
|
+
it was split, otherwise one per type or file.
|
|
998
|
+
3. **Inside a system.** One box per type, or per file where functions sit outside any type.
|
|
999
|
+
4. **Inside a type or file.** One box per function, with the outside callers and callees around it.
|
|
1000
|
+
|
|
1001
|
+
Select a box or an arrow to see detail in the side panel: members, callers, callees, the links that
|
|
1002
|
+
make up an arrow, and source text. Double-click a box to open it. Solid arrows are exact, dashed
|
|
1003
|
+
arrows were worked out from syntax, dotted arrows are guesses, and blue dash-dot arrows are events and
|
|
1004
|
+
process launches.
|
|
1005
|
+
|
|
1006
|
+
The Matrix tab shows the same level as a dependency matrix, ordered so that cycles appear above the
|
|
1007
|
+
diagonal. The Flows tab steps through one flow at a time, with the source of the selected step beside it.
|
|
1008
|
+
The Views tab shows saved views: for a proposed change, the marked code colored by role beside the
|
|
1009
|
+
impact report.
|
|
1010
|
+
|
|
1011
|
+
The exported page embeds the graph and the source text of every indexed file, so share it with the
|
|
1012
|
+
same care as the repository. Pass `--no-sources` to leave source text out.
|
|
1013
|
+
|
|
1014
|
+
## MCP tools
|
|
1015
|
+
|
|
1016
|
+
| Tool | Returns |
|
|
1017
|
+
| --- | --- |
|
|
1018
|
+
| `map(paths?)` | Step 1: index the code (or map again what the store holds); counts, the map page, `next` |
|
|
1019
|
+
| `plan(change, test_output?, test_results?, new_baseline?)` | Step 2: the one page for a change, `status.blocking`, `next`; records the tests from before; `new_baseline` compares from the code as it is now |
|
|
1020
|
+
| `check(change, test_output?, test_results?, coverage_path?)` | Step 3: re-index, record the tests from after, the verdict and `next`; with the run's coverage, whether each scenario's test ran the changed code |
|
|
1021
|
+
| `affected_tests(change)` | The tests to run for a change or a `pr-<id>` review, and the command that runs them |
|
|
1022
|
+
| `overview(scope?, limit?)` | Repos, modules with sizes, module-to-module dependencies by edge kind, systems, external packages, extractors that ran |
|
|
1023
|
+
| `cross_repo(limit?)` | In a workspace of several repositories: links between them, functions most called across, flows that cross and come back |
|
|
1024
|
+
| `search(text, kind?, limit?)` | Node ids matching a name, qualified name or path |
|
|
1025
|
+
| `expand(node_id, limit?)` | One node in detail: contents, dependencies, dependents, callers and callees |
|
|
1026
|
+
| `neighbors(node_id, direction?, kinds?, limit?)` | Raw edges around a node, by kind |
|
|
1027
|
+
| `source(node_id, max_lines?)` | The node's source text |
|
|
1028
|
+
| `context(focus, budget_tokens?)` | A short outline of the code around a focus (ids, names, paths, a change, or words), most related first, cut to a token budget |
|
|
1029
|
+
| `module_outline(module?, depth?)` | A large module split into at most 12 parts (folders, or groups of files where a folder is flat): each part's size, entry points, links in and out, channels, busiest functions, risks and name; drill in with a part's id |
|
|
1030
|
+
| `name_part(part_id, name, summary?, evidence?, layer?)` | Name a part from the outline with a one-line summary; kept across maps, shown as "may be stale" when the part changes a lot |
|
|
1031
|
+
| `flows(kind?, through?, limit?, offset?)` | Flows walked from each entry point and test; `through` keeps flows that pass a node |
|
|
1032
|
+
| `flow(flow_id, max_steps?, offset?)` | One flow step by step, in source order, with call depth |
|
|
1033
|
+
| `trace(from_id, to_id)` | The shortest chain of calls and channels between two functions |
|
|
1034
|
+
| `find_flows(description, limit?)` | Where a behavior described in words could start: entry points, route, UI and message handlers, tests named for it, functions; each with why it matched, its kind, file:line and the flows that start there, or else the entry points' flows (`reached_from`) and the tests' (`tests_reaching`) that reach it |
|
|
1035
|
+
| `explain_path(start, to?, through?, max_steps?)` | An ordered walk across calls and channels from `start` (its main flow, the shortest path `to` a node, or the path `through` one), each step with how it was reached, the calling line and the declaration; data written for later marked; a Mermaid diagram of the walk |
|
|
1036
|
+
| `diagram(ids)` | A Mermaid sequence diagram of how execution reaches some functions or types and what they call |
|
|
1037
|
+
| `impact(node_id, max_depth?, limit?)` | What can reach a node: callers by module and the flows through it |
|
|
1038
|
+
| `annotate(node_id, key, value, evidence?, confidence?, layer?)` | Write an inferred or intent statement about a node |
|
|
1039
|
+
| `propose_change(intent, targets, title?, depth?)` | Assess a change without an OpenSpec folder and save its blast-radius view |
|
|
1040
|
+
| `save_view(title, narrative, marks, legend?)` | Save any set of marked nodes as a view |
|
|
1041
|
+
| `review_change(change_id, before_run?, after_run?)` | Compare a change assessed with `propose_change` with what was done, and save a review view |
|
|
1042
|
+
| `record_test_run(run, results)` | Store one test run under a label, for `review_change` |
|
|
1043
|
+
| `add_rule(kind, selector_from, selector_to?, edge_kinds?, severity?, reason?, confirmed?)` | Add an architecture rule, suggested unless the user stated it |
|
|
1044
|
+
| `check_rules()` | Evaluate every rule against the graph |
|
|
1045
|
+
| `review_pr(base?, about?, github?, review_id?, path?)` | Review a checked-out branch or pull request with no spec: what changed, what it reaches and did not change, tests; returns `change_id` (`pr-<id>`), `blocking` (one line for each thing that holds up the merge) and `gate_passed` |
|
|
1046
|
+
| `quick(what?, names?, done?, test_output?, test_results?, coverage_path?, change_id?)` | A small change with no spec folder: before (`what`, `names`), what it touches and the tests that run it; after (`done`), one verdict, and `grown` when it needs a spec |
|
|
1047
|
+
| `spec_review_facts(change, reviewer?)`, `spec_finding(change, reviewer, severity, claim, evidence, proposal?)`, `spec_findings(change)`, `spec_resolve(finding_id, status, resolution?)` | Adversarial review of a planned change, or of a pull request by its `pr-<id>` |
|
|
1048
|
+
| `learnings(retire?, why?, confirm?)` | Past decisions on review findings, kept from rejections with a reason, each marked `stale` when its code changed since; `retire` one the person says no longer holds, `confirm` one they say still holds for the code as it is now |
|
|
1049
|
+
| `spec_brief(change, new_baseline?)`, `spec_verify(change, before_run?, after_run?)` | The steps inside `plan` and `check`, one at a time; rarely needed |
|
|
1050
|
+
| `drift(path?, accept?)` | Code the living specs and finished changes name that is gone, moved, changed signature or ambiguous; `fails`, the page, `next` |
|
|
1051
|
+
| `shared_state(scope?, limit?)` | Fields assigned from outside the type that declares them |
|
|
1052
|
+
| `coupling(path?, min_together?, min_confidence?, limit?)` | Files (and folders) that usually change in the same commits as a file, from git history; with no path, the most coupled pairs |
|
|
1053
|
+
| `coverage(node_id?, flow_id?, import_path?, test?)` | Measured coverage: what ran, set against the static paths; `import_path` reads a report in first, and `test` names the one test file that ran for a report with no per-test detail |
|
|
1054
|
+
| `patterns(pattern?, node_id?, include_tests?, limit?)` | Design patterns found by shape, with roles, rationale and confidence; `include_tests` adds those inside test code |
|
|
1055
|
+
| `label_pattern(pattern, roles, rationale, confidence?)` | Record a pattern the matchers missed |
|
|
1056
|
+
| `tours()`, `tour(tour_id)` | List tours, or read one stop by stop |
|
|
1057
|
+
| `save_tour(title, stops, audience?)` | Save a tour written for the user |
|
|
1058
|
+
| `views()`, `view(view_id, limit?)` | List saved views, or read one |
|
|
1059
|
+
| `skills(skill?)` | The skills that ship with Leyline, each with when to use it, or one skill's text. Each is also an MCP prompt of the same name |
|
|
1060
|
+
|
|
1061
|
+
`change` is a change folder or its id. A tool that cannot answer returns an error saying what to do
|
|
1062
|
+
instead (no store yet: call `map`; an unknown id: find it with `search`). Each answer is at most about
|
|
1063
|
+
24,000 characters (30,000 for `module_outline` and `explain_path`, 36,000 for `context`): lists show their
|
|
1064
|
+
first items and their total, `cut` names any list that was shortened, and `more` says how to see the rest
|
|
1065
|
+
(`limit`, `offset`, `scope`, or a narrower id).
|
|
1066
|
+
|
|
1067
|
+
## What is indexed
|
|
1068
|
+
|
|
1069
|
+
### Which files
|
|
1070
|
+
|
|
1071
|
+
The files git lists: tracked ones, and untracked ones `.gitignore` does not exclude. A directory that is
|
|
1072
|
+
not a git repository (or one git refuses to read, such as a checkout owned by another user) is walked
|
|
1073
|
+
instead, leaving out what git would: the patterns of your global excludes file (`core.excludesFile`, else
|
|
1074
|
+
`~/.config/git/ignore`), of `.git/info/exclude` and of each `.gitignore`, escapes and trailing spaces read as
|
|
1075
|
+
git reads them, so a file kept out of git such as `.env` stays off the map and its page. Some files are left out, and `map` names them with
|
|
1076
|
+
the reason: other people's code (`node_modules`, a Go or Composer `vendor`), submodules and nested
|
|
1077
|
+
repositories, symlinks to directories or out of the repository, files git lists that are gone, and source
|
|
1078
|
+
files that are binary, minified, unreadable or larger than 5 MB (`LEYLINE_MAX_FILE_MB` changes that). A
|
|
1079
|
+
file that does not parse is kept on the map as a file, without its contents, and named too.
|
|
1080
|
+
|
|
1081
|
+
Large repositories are parsed in worker processes (`LEYLINE_JOBS` sets how many). A worker that crashes
|
|
1082
|
+
or is still on one batch of files after ten minutes (`LEYLINE_PARSE_TIMEOUT`, in seconds) costs only
|
|
1083
|
+
the file it was on. Workers start by spawn on macOS and Windows and by fork on Linux;
|
|
1084
|
+
`LEYLINE_START_METHOD` picks one. The compiler step (`--exact`) gives up after half an hour
|
|
1085
|
+
(`LEYLINE_EXACT_TIMEOUT`) and keeps the syntax-based links.
|
|
1086
|
+
|
|
1087
|
+
### Languages
|
|
1088
|
+
|
|
1089
|
+
Any language with a tree-sitter grammar is indexed by one **generic adapter** that knows no
|
|
1090
|
+
language. It reads what grammars have in common: node names (`function_declaration`,
|
|
1091
|
+
`class_definition`, `call_expression`), the grammar's own tags query where it ships one, and how
|
|
1092
|
+
typed languages write a variable's type (`Foo x`, `x: Foo`, `x = new Foo(`). From that it gets
|
|
1093
|
+
declarations, nesting, calls with what the text shows of the receiver, imports matched to files by
|
|
1094
|
+
path, tests by naming convention, and `main`. Adding a language is one line in
|
|
1095
|
+
`adapters/generic.py` and a `pip install tree-sitter-<language>`.
|
|
1096
|
+
|
|
1097
|
+
Installed by default: C#, Python, TypeScript and JavaScript. `pip install 'leyline-code[languages]'`
|
|
1098
|
+
adds Go, Rust, Java, Kotlin, Swift, C, C++, Ruby, PHP, Scala, Lua, Bash and GDScript.
|
|
1099
|
+
|
|
1100
|
+
C#, Python and TypeScript also have **hand-written adapters** that see more: receiver types,
|
|
1101
|
+
overloads, field reads and writes, events, routes. They are used for those languages unless
|
|
1102
|
+
`LEYLINE_GENERIC=1`. For other languages, a **SCIP index** (from the language's own indexer,
|
|
1103
|
+
`--scip FILE`) replaces the generic adapter's guesses with the compiler's links.
|
|
1104
|
+
|
|
1105
|
+
`leyline grade <repo> <index.scip | roslyn>` measures either adapter against a compiler. Precision
|
|
1106
|
+
counts only call sites the compiler resolved; recall counts the compiler's links between functions
|
|
1107
|
+
on the map.
|
|
1108
|
+
|
|
1109
|
+
| Language (repository) | Compiler | Hand-written: right / found | Generic: right / found |
|
|
1110
|
+
| --- | --- | --- | --- |
|
|
1111
|
+
| TypeScript (Parlance, 160k lines) | scip-typescript | 100% / 98% | 99% / 97% |
|
|
1112
|
+
| Python (Flask) | scip-python | 99% / 68% | 96% / 87% |
|
|
1113
|
+
| C# (a private project) | Roslyn | 99.7% / 97% | 98% / 83% |
|
|
1114
|
+
|
|
1115
|
+
Where the generic adapter loses, it is on calls made on a variable whose type it cannot read from
|
|
1116
|
+
the text. The hand-written Python adapter misses calls inside decorators (`@app.route(...)`).
|
|
1117
|
+
|
|
1118
|
+
| Record | Precision | Notes |
|
|
1119
|
+
| --- | --- | --- |
|
|
1120
|
+
| Modules, files, types, callables, fields, entry points | exact | A module is a directory with a project file, or a source file's own directory |
|
|
1121
|
+
| `contains`, `has_field`, `exposes` | exact | |
|
|
1122
|
+
| `imports`, `depends_on` | exact | From `using` and `import` statements and from `.csproj` references |
|
|
1123
|
+
| `extends`, `implements`, `uses_type`, `instantiates` | heuristic | Type names resolved by name, limited to what the project can reference |
|
|
1124
|
+
| `calls` | heuristic or guess | See below |
|
|
1125
|
+
| `overrides` | heuristic | A method to the interface or base method it implements, by name and arity |
|
|
1126
|
+
| `communicates` | heuristic or guess | Events and process launches; see Channels |
|
|
1127
|
+
| Tests | exact | xUnit, NUnit and MSTest attributes, pytest names, and inline runners of the form `Run("name", () => { ... })` |
|
|
1128
|
+
|
|
1129
|
+
### How calls are resolved
|
|
1130
|
+
|
|
1131
|
+
Without a compiler, a call is linked only when its receiver's type can be worked out from what is
|
|
1132
|
+
written: fields, parameters, typed locals, `new` expressions, collection element types, static type
|
|
1133
|
+
names, and the declared return type of the call a value came from (`a.Make().Run()`, `var x =
|
|
1134
|
+
a.Make(); x.Run()`). In C#, extension methods are matched on the type of their `this` parameter,
|
|
1135
|
+
and overloads of equal length are narrowed by the arguments: a lambda's parameter count, the type
|
|
1136
|
+
of a literal, a `new` expression or a typed local, and explicit type arguments. In Python, packages
|
|
1137
|
+
under a source root are imported by their own name, with or without an `__init__.py` (a namespace
|
|
1138
|
+
package), names re-exported through `__init__.py` are followed, and a test parameter filled by a pytest
|
|
1139
|
+
fixture takes the fixture's return type. In TypeScript and JavaScript, an import through the `paths` of
|
|
1140
|
+
the nearest `tsconfig.json` or `jsconfig.json` (`@ui/*`) is the local file, not a package; `extends` is
|
|
1141
|
+
not followed. Three outcomes are counted per run:
|
|
1142
|
+
|
|
1143
|
+
- **resolved**: linked to a callable in the workspace
|
|
1144
|
+
- **external**: the receiver's type is outside the workspace, or nothing in the workspace has that name
|
|
1145
|
+
- **unresolved**: the name exists in the workspace but the receiver's type is unknown
|
|
1146
|
+
|
|
1147
|
+
A call on a receiver of unknown type is linked by name only when exactly one declaration of that
|
|
1148
|
+
name is visible and the name was never seen on an outside type. In Python it is linked only to a method, and
|
|
1149
|
+
never by a name the built-in types have (`add`, `get`, `close`, `split`). Those links are stored with
|
|
1150
|
+
precision `guess`, not `heuristic`, and the map draws them differently.
|
|
1151
|
+
|
|
1152
|
+
### Channels
|
|
1153
|
+
|
|
1154
|
+
A `communicates` edge records code talking to code without a direct call. It points the way data
|
|
1155
|
+
moves, and carries a `channel` and an `address`.
|
|
1156
|
+
|
|
1157
|
+
- **event** (C#): from the function that raises an event to the function that handles it. The
|
|
1158
|
+
address is the event. Subscriptions to events declared outside the workspace are counted, not linked.
|
|
1159
|
+
- **process** (Python): from a `subprocess` call to the entry point of the program it launches, when
|
|
1160
|
+
the command names a project, a build output or a script in the workspace. If only the surrounding
|
|
1161
|
+
file names it, the edge is a `guess`.
|
|
1162
|
+
|
|
1163
|
+
- **http**: from a request to the route that serves it. Routes are read from decorators
|
|
1164
|
+
(`@app.route("/x")`, `@app.get`), ASP.NET attributes (`[HttpGet("x")]`) and `MapGet`-style calls;
|
|
1165
|
+
requests from `.get("/x")`-style calls and `HttpClient` methods with a literal path. A request is
|
|
1166
|
+
linked to a route declared inside the same test first, then to the only route in the repository
|
|
1167
|
+
that matches. When several match, in every language, the route that names more of the path outright
|
|
1168
|
+
wins (`/items/special` over `/items/{id}`, `/api/review/health` over `/api/review/:id`); if that leaves
|
|
1169
|
+
more than one, it is counted as ambiguous and left unlinked.
|
|
1170
|
+
In TypeScript (Fastify, Express, Hono, Koa routers), a route's inline handler
|
|
1171
|
+
(`server.get("/api/x", async (req) => ...)`, `server.route({ method, url, handler })`, or
|
|
1172
|
+
`server.get(path, { schema }, handler)`) is a function of its own, named for its route (`GET /api/x`)
|
|
1173
|
+
and nested in the function that registers it: the request lands on it, what it calls is its own, and
|
|
1174
|
+
the registrar registers it (kept as a call, so startup still reaches it). A handler given by name
|
|
1175
|
+
(`server.get("/x", listThings)`) is that function. When several handlers' routes still fit a request
|
|
1176
|
+
after that, a request with no method written is taken as a GET. In a pull request, an edit inside a handler touches
|
|
1177
|
+
its route and no other. An ASP.NET
|
|
1178
|
+
controller's `[Route("api/[controller]")]`, and a minimal API's `MapGroup("api/x")`, start the
|
|
1179
|
+
routes declared under them. A path handed to
|
|
1180
|
+
something the name does not give away is linked only when exactly one route serves it: a wrapper
|
|
1181
|
+
(`apiFetch("/api/x")`, `api_get("/api/x/1")`, `Get<T>("/api/x")`), `server.inject({ url })`, a
|
|
1182
|
+
session's `request("GET", "/x")`, a test client's `open("/x", method=...)`, a base address in a
|
|
1183
|
+
variable (`f"{BASE}/api/x"`, `BASE + "/api/x"`), `HttpClient` with a relative path
|
|
1184
|
+
(`GetAsync("api/x")`), RestSharp's `new RestRequest("api/x")` and `new HttpRequestMessage(...)`.
|
|
1185
|
+
- **file**: from a function that writes a file to one that reads it, when the path fragments written
|
|
1186
|
+
in the two agree: the same file name, or the same directory and the same extension
|
|
1187
|
+
(`policies/*.bin`). These are always a `guess`, since paths are usually built at run time. Flows
|
|
1188
|
+
do not follow them: writing a file does not run its reader.
|
|
1189
|
+
|
|
1190
|
+
- **di**: from a method of an interface to the same method of the implementation a container
|
|
1191
|
+
registers for it (`services.AddScoped<IFoo, Foo>()` and `typeof` forms, Autofac's
|
|
1192
|
+
`RegisterType<Foo>().As<IFoo>()`, Python `container.register(IFoo, Foo)` and `bind(IFoo).to(Foo)`,
|
|
1193
|
+
Angular and NestJS `{ provide: X, useClass: Y }`, Inversify `bind<X>(…).to(Y)`), with
|
|
1194
|
+
`registered_in` and `registered_at` naming the registration. An implementation read off a factory
|
|
1195
|
+
(`sp => new Foo()`, `sp.GetRequiredService<Foo>()`) is a `guess`. Also from the registration to a
|
|
1196
|
+
hosted service's `ExecuteAsync`, and from a FastAPI endpoint to the function its `Depends()` names.
|
|
1197
|
+
Flows try the registered implementation before the other implementations.
|
|
1198
|
+
- **queue**: from code that publishes to the handlers of the same topic, queue or message type:
|
|
1199
|
+
Kafka, RabbitMQ, Redis, NATS-style `publish`/`subscribe` with a literal or constant name, Node
|
|
1200
|
+
`emit`/`on`, NestJS `@EventPattern`/`@OnEvent`, graphql-subscriptions, Celery and RQ tasks
|
|
1201
|
+
(`send_receipt.delay()` reaches `send_receipt`), Django signals, and C# messages by type (MediatR
|
|
1202
|
+
`Send(new X())` and `AddDomainEvent` to `IRequestHandler<X>`/`INotificationHandler<X>`, and the
|
|
1203
|
+
like). A message type that is created with a handler in sight but not visibly sent (handed to an
|
|
1204
|
+
outbox or a wrapper) is linked as a `guess` with `created_only`. Event names that streams and
|
|
1205
|
+
sockets raise themselves (`error`, `data`, `exit`) are not linked.
|
|
1206
|
+
- **db**: from a function that writes a table to one that reads it: SQL in string literals and
|
|
1207
|
+
constants, SQLAlchemy and SQLModel, Django managers, EF `DbSet`s and generic repositories
|
|
1208
|
+
(`IRepository<Order>`), Prisma, Knex, TypeORM, Mongoose, Sequelize and document stores
|
|
1209
|
+
(`db.users.find`). Table names come from `__tablename__`, `[Table]`, `ToTable`, `@Entity` and the
|
|
1210
|
+
like, or the class name. A match only after folding singular and plural (`user ~ users`) is a
|
|
1211
|
+
`guess`. Sites in tests and migrations are counted, not linked. Flows do not follow these.
|
|
1212
|
+
- **rpc**: from a call on a gRPC or Thrift stub (`GreeterStub`, `Greeter.GreeterClient`,
|
|
1213
|
+
`getService('X')`) to the method of the same name on a class that implements the service
|
|
1214
|
+
(`GreeterServicer`, `Greeter.GreeterBase`, `@GrpcMethod`). When several servers implement it, the
|
|
1215
|
+
ones nearest the caller by directory are linked.
|
|
1216
|
+
- **format** (TypeScript, Python, C#): from a function that builds keys of one shape to a function
|
|
1217
|
+
that takes them apart, where no call joins the two. The address is the shape
|
|
1218
|
+
(`dialogue/*/nodes/*/text`). A builder is a template with holes and two or more fixed parts
|
|
1219
|
+
between `/` or `:` separators, starting with a fixed part: a template literal, an f-string, an
|
|
1220
|
+
interpolated string, `"%s/x/%s" %`, `String.Format` or a chain of `+`. A hole that calls another
|
|
1221
|
+
builder (`${nodeKey(d, n)}/text`) takes that builder's shape. A reader is a regular expression over
|
|
1222
|
+
the shape (where a constant holding it is used), a `startsWith` test of a literal with two fixed
|
|
1223
|
+
parts, or code that compares a split key's pieces by position (`parts[2] === "nodes"`, through
|
|
1224
|
+
`slice`, aliases and destructuring). They are linked when two or more of the reader's fixed parts
|
|
1225
|
+
sit at the same places in the builder's shape and none disagrees; when the pieces come from a
|
|
1226
|
+
parameter and two shapes share a tail, the reader is taken to read the shape its own file builds,
|
|
1227
|
+
else the one most files build. Routes, file paths, git's `refs/...` and keys built in tests are
|
|
1228
|
+
left out. A `guess` unless three fixed parts agree. Like files and tables, flows do not follow
|
|
1229
|
+
these, and a pull request that edits the builder's template lists the readers as "must agree".
|
|
1230
|
+
|
|
1231
|
+
All five meet by a name or a shape written in code, so each link is `heuristic` at best. Each change brief
|
|
1232
|
+
lists the channels a change crosses, with their address.
|
|
1233
|
+
|
|
1234
|
+
### Systems
|
|
1235
|
+
|
|
1236
|
+
A module is split into systems by Louvain community detection over calls, type use and inheritance
|
|
1237
|
+
between its units: each top-level type, and each file for the functions that sit outside any type. A
|
|
1238
|
+
module with fewer than 12 units holding code, or one that does not split cleanly (modularity under
|
|
1239
|
+
0.3), is left whole. Each proposed system is named after its most connected unit (a type, or a file)
|
|
1240
|
+
until something better is written with `annotate`, using the keys `name` and `responsibility`.
|
|
1241
|
+
|
|
1242
|
+
The grouping is deterministic. The names are not: they belong to the inferred layer.
|
|
1243
|
+
|
|
1244
|
+
### Annotations
|
|
1245
|
+
|
|
1246
|
+
`annotate` is the only write. An inferred annotation must list the node ids it is based on; the
|
|
1247
|
+
store hashes the files behind them and marks the annotation stale when any of those files changes.
|
|
1248
|
+
An intent annotation is the user's own statement and needs no evidence. Facts cannot be written.
|
|
1249
|
+
|
|
1250
|
+
### Flows
|
|
1251
|
+
|
|
1252
|
+
A flow is a walk of the call graph from one entry point or one test, depth-first, in source order,
|
|
1253
|
+
listing each function once. Flows follow calls, calls through an interface into its implementations
|
|
1254
|
+
(the registered one first), process launches, HTTP requests, messages to their handlers, remote calls,
|
|
1255
|
+
and events whose handler was subscribed earlier in the same flow. They do not follow files or tables:
|
|
1256
|
+
writing one does not run its reader. They stop at
|
|
1257
|
+
8 calls deep or 300 steps.
|
|
1258
|
+
|
|
1259
|
+
A flow's id is `flow:<start>`. A start that is two kinds of start at once (an entry point that is also a test)
|
|
1260
|
+
has a flow for each: the entry point's keeps that id, and the other's is `flow:<start>#test`.
|
|
1261
|
+
|
|
1262
|
+
Flows are static: they show what can run, not what did run. Per-test coverage will replace them with
|
|
1263
|
+
observed paths where it is available.
|
|
1264
|
+
|
|
1265
|
+
SCIP indexers will replace these heuristics with exact references. That extractor and test coverage
|
|
1266
|
+
are recorded as `not_analyzed` in `extractor_coverage`, so missing analysis is never mistaken for an
|
|
1267
|
+
empty result.
|
|
1268
|
+
|
|
1269
|
+
### How well this holds up
|
|
1270
|
+
|
|
1271
|
+
Measured on three repositories, counting call sites whose target is inside the repository:
|
|
1272
|
+
|
|
1273
|
+
| Repository | Files | Index time | Call sites linked | Of those, by guess | Left open |
|
|
1274
|
+
|---|---|---|---|---|---|
|
|
1275
|
+
| A private project (C# and Python) | 59 | 1 s | 99% | 4% | 18 |
|
|
1276
|
+
| Flask (Python) | 83 | 1 s | 91% | 9% | 101 |
|
|
1277
|
+
| Polly (C#) | 801 | 7 s | 94% | 2% | 915 |
|
|
1278
|
+
|
|
1279
|
+
With the compiler pass on, the syntax links can be scored. On the private project the compiler confirmed 2,081
|
|
1280
|
+
of them, removed 16 and added 38 it had missed. On Polly, where packages could not be restored and
|
|
1281
|
+
only part of the code binds, it confirmed 4,844 and removed 1,286, nearly all extra overloads of
|
|
1282
|
+
the right method.
|
|
1283
|
+
|
|
1284
|
+
Without that pass, "linked" is not "correct". In a random sample of 42 links across the
|
|
1285
|
+
three, read against the source by hand, all 42 pointed at the right method; 4 of the 22 from Polly
|
|
1286
|
+
pointed at the wrong overload of it. Polly's fluent API has up to twenty overloads per name, which
|
|
1287
|
+
is the hard case for this approach. A call the indexer classes
|
|
1288
|
+
as leaving the repository is not in these counts, and some of those are misses: a call on a value
|
|
1289
|
+
whose type comes from outside (Flask's test client) cannot be followed back in.
|
|
1290
|
+
|
|
1291
|
+
## Identity
|
|
1292
|
+
|
|
1293
|
+
Node ids are stable across file moves within a project:
|
|
1294
|
+
|
|
1295
|
+
- C#: `repo:csharp:Project::Namespace.Type.Member(ParamTypes)`. The project is part of the id
|
|
1296
|
+
because two projects may declare the same type name.
|
|
1297
|
+
- Python: `repo:python:package.module.Class.method`
|
|
1298
|
+
- TypeScript, JavaScript and the generic languages: `repo:language:dir.file.Name`, the file named without its
|
|
1299
|
+
extension. Two files of one language in one directory that differ only in their extension (`a.js` beside
|
|
1300
|
+
`a.ts`, `m.c` beside `m.h`) would name their contents alike: the first by path keeps that id, and the others
|
|
1301
|
+
keep their extension in it (`repo:typescript:dir.a.ts.Name`). Adding or removing such a neighbour can change
|
|
1302
|
+
the ids of the files after it.
|
|
1303
|
+
- Files and modules: `repo:file:path` and `repo:module:path`
|
|
1304
|
+
|
|
1305
|
+
A name defined twice in one file (`def load()` in both branches of an `if` or a `try`, a property's getter and
|
|
1306
|
+
setter) is one node: which definition runs is not known from the text. Its span runs from the first definition
|
|
1307
|
+
to the last, its `definitions` attribute lists each one's lines, its text hash reads every body, and its calls
|
|
1308
|
+
come from all of them.
|
|
1309
|
+
|
|
1310
|
+
## Tests
|
|
1311
|
+
|
|
1312
|
+
```bash
|
|
1313
|
+
pytest
|
|
1314
|
+
```
|
|
1315
|
+
|
|
1316
|
+
A new skill is a folder under `skills/` with a `SKILL.md`. That folder is the only copy: the wheel carries it as
|
|
1317
|
+
`leyline/skills/`, and `leyline skills`, the MCP prompts and the `skills` tool pick it up with no other edit.
|
|
1318
|
+
`tests/test_skills.py` checks each skill's frontmatter and that the wheel's copy matches.
|
|
1319
|
+
|
|
1320
|
+
## License
|
|
1321
|
+
|
|
1322
|
+
Copyright (C) 2026 Matthew Burke.
|
|
1323
|
+
|
|
1324
|
+
Leyline is free software, licensed under the GNU General Public License, version 3.
|
|
1325
|
+
See [LICENSE](LICENSE) for the full text. It comes with no warranty.
|