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.
Files changed (65) hide show
  1. leyline/__init__.py +3 -0
  2. leyline/adapters/__init__.py +19 -0
  3. leyline/adapters/csharp.py +1026 -0
  4. leyline/adapters/generic.py +844 -0
  5. leyline/adapters/python.py +929 -0
  6. leyline/adapters/typescript.py +1512 -0
  7. leyline/affected.py +509 -0
  8. leyline/agent_skills.py +311 -0
  9. leyline/change.py +500 -0
  10. leyline/channels.py +1782 -0
  11. leyline/cli.py +1029 -0
  12. leyline/cluster.py +149 -0
  13. leyline/context.py +531 -0
  14. leyline/coupling.py +485 -0
  15. leyline/coverage.py +376 -0
  16. leyline/diagrams.py +680 -0
  17. leyline/diff.py +1078 -0
  18. leyline/drift.py +747 -0
  19. leyline/exact.py +410 -0
  20. leyline/explain.py +973 -0
  21. leyline/export.py +673 -0
  22. leyline/fncoupling.py +328 -0
  23. leyline/formats.py +923 -0
  24. leyline/grade.py +207 -0
  25. leyline/incremental.py +931 -0
  26. leyline/indexer.py +3733 -0
  27. leyline/learnings.py +743 -0
  28. leyline/loop.py +584 -0
  29. leyline/model.py +142 -0
  30. leyline/outline.py +1066 -0
  31. leyline/packing.py +86 -0
  32. leyline/patterns.py +478 -0
  33. leyline/pr.py +1249 -0
  34. leyline/props.py +255 -0
  35. leyline/query.py +683 -0
  36. leyline/quick.py +853 -0
  37. leyline/related.py +266 -0
  38. leyline/removal.py +231 -0
  39. leyline/renames.py +231 -0
  40. leyline/rereview.py +375 -0
  41. leyline/roslyn_refs/Program.cs +179 -0
  42. leyline/roslyn_refs/RoslynRefs.csproj +16 -0
  43. leyline/rules.py +171 -0
  44. leyline/schema.sql +178 -0
  45. leyline/scip_pb2.py +97 -0
  46. leyline/server.py +1230 -0
  47. leyline/skills/leyline-adversarial-review/SKILL.md +121 -0
  48. leyline/skills/leyline-ask/SKILL.md +57 -0
  49. leyline/skills/leyline-change-impact/SKILL.md +79 -0
  50. leyline/skills/leyline-explain-flow/SKILL.md +70 -0
  51. leyline/skills/leyline-explore-module/SKILL.md +55 -0
  52. leyline/skills/leyline-pr-review/SKILL.md +77 -0
  53. leyline/skills/leyline-quick-change/SKILL.md +63 -0
  54. leyline/skills/leyline-spec/SKILL.md +179 -0
  55. leyline/skills/leyline-tour/SKILL.md +46 -0
  56. leyline/spec.py +1819 -0
  57. leyline/store.py +560 -0
  58. leyline/tours.py +352 -0
  59. leyline/verdicts.py +390 -0
  60. leyline/viewer/viewer.html +1622 -0
  61. leyline_code-0.8.1.dist-info/METADATA +1325 -0
  62. leyline_code-0.8.1.dist-info/RECORD +65 -0
  63. leyline_code-0.8.1.dist-info/WHEEL +4 -0
  64. leyline_code-0.8.1.dist-info/entry_points.txt +2 -0
  65. leyline_code-0.8.1.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,179 @@
1
+ // Reads a manifest of C# modules, binds each with the compiler, and prints one JSON line per
2
+ // call or field access that the compiler resolved to a declaration in the same workspace.
3
+ //
4
+ // dotnet run -- manifest.json > refs.jsonl
5
+ //
6
+ // manifest: {"root": "...", "modules": [{"name": "...", "files": ["rel/path.cs"], "refs": ["other module"]}]}
7
+ using System.Text;
8
+ using System.Text.Json;
9
+ using Microsoft.CodeAnalysis;
10
+ using Microsoft.CodeAnalysis.CSharp;
11
+ using Microsoft.CodeAnalysis.CSharp.Syntax;
12
+
13
+ var manifest = JsonDocument.Parse(File.ReadAllText(args[0])).RootElement;
14
+ var root = manifest.GetProperty("root").GetString();
15
+ var modules = manifest.GetProperty("modules").EnumerateArray().ToList();
16
+
17
+ // Framework reference assemblies: the newest pack under the running SDK's root.
18
+ var dotnetRoot = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(typeof(object).Assembly.Location), "..", "..", ".."));
19
+ var packs = Path.Combine(dotnetRoot, "packs", "Microsoft.NETCore.App.Ref");
20
+ var refDir = Directory.Exists(packs)
21
+ ? Directory.GetDirectories(packs).OrderBy(d => d).Select(d => Directory.GetDirectories(Path.Combine(d, "ref")).FirstOrDefault()).LastOrDefault(d => d != null)
22
+ : Path.GetDirectoryName(typeof(object).Assembly.Location);
23
+ var framework = Directory.GetFiles(refDir, "*.dll").Select(f => (MetadataReference)MetadataReference.CreateFromFile(f)).ToList();
24
+
25
+ var built = new Dictionary<string, CSharpCompilation>();
26
+ var stdout = new StreamWriter(Console.OpenStandardOutput(), new UTF8Encoding(false)) { AutoFlush = false };
27
+ var pending = modules.ToList();
28
+ // The compiler parses and binds recursively; deeply nested code (a long else-if chain, a generated expression)
29
+ // overflows the main thread's stack, which ends the process. A worker thread gets a 1 GB stack instead.
30
+ var work = new Thread(() => {
31
+ while (pending.Count > 0)
32
+ {
33
+ // A module is ready when everything it references is built; a cycle is broken by taking the first.
34
+ var next = pending.FirstOrDefault(m => m.GetProperty("refs").EnumerateArray().All(r => built.ContainsKey(r.GetString()) || !modules.Any(x => x.GetProperty("name").GetString() == r.GetString())));
35
+ if (next.ValueKind == JsonValueKind.Undefined) next = pending[0];
36
+ pending.Remove(next);
37
+ var name = next.GetProperty("name").GetString();
38
+ var trees = new List<SyntaxTree>();
39
+ var options = new CSharpParseOptions(LanguageVersion.Preview);
40
+ foreach (var f in next.GetProperty("files").EnumerateArray())
41
+ {
42
+ var rel = f.GetString();
43
+ var path = Path.Combine(root, rel);
44
+ if (File.Exists(path)) trees.Add(CSharpSyntaxTree.ParseText(File.ReadAllText(path), options, rel));
45
+ }
46
+ // Projects that use implicit usings compile against these; adding them never hides a real error we care about.
47
+ trees.Add(CSharpSyntaxTree.ParseText("global using System; global using System.Collections.Generic; global using System.IO; global using System.Linq; global using System.Threading; global using System.Threading.Tasks;", options, "<implicit>"));
48
+ var refs = new List<MetadataReference>(framework);
49
+ foreach (var r in next.GetProperty("refs").EnumerateArray())
50
+ if (built.TryGetValue(r.GetString(), out var dep)) refs.Add(dep.ToMetadataReference());
51
+ var compilation = CSharpCompilation.Create(name, trees, refs,
52
+ new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true, nullableContextOptions: NullableContextOptions.Enable));
53
+ built[name] = compilation;
54
+
55
+ int ok = 0, candidate = 0, none = 0, outside = 0;
56
+ foreach (var tree in trees)
57
+ {
58
+ if (tree.FilePath == "<implicit>") continue;
59
+ var model = compilation.GetSemanticModel(tree);
60
+ foreach (var node in tree.GetRoot().DescendantNodes())
61
+ {
62
+ switch (node)
63
+ {
64
+ case InvocationExpressionSyntax inv:
65
+ {
66
+ var nameNode = inv.Expression switch
67
+ {
68
+ MemberAccessExpressionSyntax m => (SyntaxNode)m.Name,
69
+ MemberBindingExpressionSyntax b => b.Name,
70
+ _ => inv.Expression,
71
+ };
72
+ var written = nameNode is SimpleNameSyntax s ? s.Identifier.ValueText : null;
73
+ if (written == null || written == "nameof") break;
74
+ EmitCall(model.GetSymbolInfo(inv), written, nameNode.GetLocation(), ref ok, ref candidate, ref none, ref outside);
75
+ break;
76
+ }
77
+ case BaseObjectCreationExpressionSyntax made:
78
+ EmitCall(model.GetSymbolInfo(made), ".ctor", made.GetLocation(), ref ok, ref candidate, ref none, ref outside);
79
+ break;
80
+ case ConstructorInitializerSyntax init:
81
+ EmitCall(model.GetSymbolInfo(init), ".ctor", init.GetLocation(), ref ok, ref candidate, ref none, ref outside);
82
+ break;
83
+ case IdentifierNameSyntax id:
84
+ {
85
+ if (id.Parent is InvocationExpressionSyntax call && call.Expression == id) break;
86
+ if (id.Parent is MemberAccessExpressionSyntax ma && ma.Name == id && ma.Parent is InvocationExpressionSyntax) break;
87
+ var symbol = model.GetSymbolInfo(id).Symbol;
88
+ if (symbol is not (IFieldSymbol or IPropertySymbol)) break;
89
+ if (symbol is IFieldSymbol { ContainingType.TypeKind: TypeKind.Enum }) break;
90
+ var decl = Declared(symbol);
91
+ if (decl == null) break;
92
+ Emit(Access(id), id.GetLocation(), symbol.Name, decl.Value.file, decl.Value.line, "ok");
93
+ break;
94
+ }
95
+ }
96
+ }
97
+ }
98
+ // Per file: how many binding errors, so a reader knows whether "nothing found" means "nothing there".
99
+ foreach (var tree in trees)
100
+ {
101
+ if (tree.FilePath == "<implicit>") continue;
102
+ var errors = compilation.GetSemanticModel(tree).GetDiagnostics().Count(d => d.Severity == DiagnosticSeverity.Error);
103
+ stdout.WriteLine("{\"k\":\"file\",\"f\":" + JsonSerializer.Serialize(tree.FilePath) + ",\"e\":" + errors + "}");
104
+ }
105
+ Console.Error.WriteLine($"{name}: {trees.Count - 1} files, {ok} calls bound, {candidate} by candidate, {none} unbound, {outside} to code outside");
106
+ }
107
+ }, 1 << 30);
108
+ work.Start();
109
+ work.Join();
110
+ stdout.Flush();
111
+
112
+ void EmitCall(SymbolInfo info, string written, Location at, ref int ok, ref int candidate, ref int none, ref int outside)
113
+ {
114
+ var symbol = info.Symbol as IMethodSymbol;
115
+ var state = "ok";
116
+ if (symbol == null && info.CandidateSymbols.Length == 1) { symbol = info.CandidateSymbols[0] as IMethodSymbol; state = "candidate"; }
117
+ if (symbol == null)
118
+ {
119
+ // Several candidates, or none: the compiler could not settle it (often a missing package). Say so.
120
+ none++;
121
+ Emit("call", at, written, null, 0, info.CandidateSymbols.Length > 1 ? "ambiguous" : "none");
122
+ return;
123
+ }
124
+ if (symbol.MethodKind == MethodKind.LocalFunction || symbol.MethodKind == MethodKind.Ordinary || symbol.MethodKind == MethodKind.Constructor
125
+ || symbol.MethodKind == MethodKind.ReducedExtension || symbol.MethodKind == MethodKind.UserDefinedOperator || symbol.MethodKind == MethodKind.DelegateInvoke)
126
+ {
127
+ var target = (symbol.ReducedFrom ?? symbol).OriginalDefinition;
128
+ if (target.PartialImplementationPart != null) target = target.PartialImplementationPart;
129
+ var decl = Declared(target);
130
+ if (decl == null) { outside++; Emit("call", at, written, null, 0, "outside"); return; }
131
+ if (state == "ok") ok++; else candidate++;
132
+ Emit("call", at, written, decl.Value.file, decl.Value.line, state, target.MethodKind == MethodKind.Constructor ? ".ctor" : target.Name);
133
+ }
134
+ }
135
+
136
+ (string file, int line)? Declared(ISymbol symbol)
137
+ {
138
+ foreach (var r in symbol.OriginalDefinition.DeclaringSyntaxReferences)
139
+ {
140
+ if (r.SyntaxTree.FilePath == "<implicit>") continue;
141
+ var syntax = r.GetSyntax();
142
+ // A field is declared inside a declaration that may hold several: point at the whole statement.
143
+ if (syntax is VariableDeclaratorSyntax && syntax.Parent?.Parent is BaseFieldDeclarationSyntax whole) syntax = whole;
144
+ return (r.SyntaxTree.FilePath, syntax.GetLocation().GetLineSpan().StartLinePosition.Line + 1);
145
+ }
146
+ return null;
147
+ }
148
+
149
+ string Access(IdentifierNameSyntax id)
150
+ {
151
+ SyntaxNode cur = id;
152
+ if (cur.Parent is MemberAccessExpressionSyntax m && m.Name == id) cur = m;
153
+ while (cur.Parent is ParenthesizedExpressionSyntax p) cur = p;
154
+ var up = cur.Parent;
155
+ if (up is AssignmentExpressionSyntax a && a.Left == cur)
156
+ {
157
+ if (a.Parent is InitializerExpressionSyntax) return "init";
158
+ return a.IsKind(SyntaxKind.SimpleAssignmentExpression) ? "write" : "readwrite";
159
+ }
160
+ if (up is PostfixUnaryExpressionSyntax || up is PrefixUnaryExpressionSyntax pre && (pre.IsKind(SyntaxKind.PreIncrementExpression) || pre.IsKind(SyntaxKind.PreDecrementExpression)))
161
+ return up is PostfixUnaryExpressionSyntax post && post.IsKind(SyntaxKind.SuppressNullableWarningExpression) ? "read" : "readwrite";
162
+ if (up is ArgumentSyntax arg && !arg.RefKindKeyword.IsKind(SyntaxKind.None))
163
+ return arg.RefKindKeyword.IsKind(SyntaxKind.OutKeyword) ? "write" : arg.RefKindKeyword.IsKind(SyntaxKind.RefKeyword) ? "readwrite" : "read";
164
+ if (up is ElementAccessExpressionSyntax e && e.Expression == cur && e.Parent is AssignmentExpressionSyntax ea && ea.Left == e) return "readwrite";
165
+ return "read";
166
+ }
167
+
168
+ void Emit(string kind, Location at, string written, string targetFile, int targetLine, string state, string targetName = null)
169
+ {
170
+ var pos = at.GetLineSpan();
171
+ var sb = new StringBuilder(160);
172
+ sb.Append("{\"k\":\"").Append(kind).Append("\",\"f\":").Append(JsonSerializer.Serialize(pos.Path))
173
+ .Append(",\"l\":").Append(pos.StartLinePosition.Line + 1).Append(",\"n\":").Append(JsonSerializer.Serialize(written))
174
+ .Append(",\"s\":\"").Append(state).Append('"');
175
+ if (targetFile != null) sb.Append(",\"tf\":").Append(JsonSerializer.Serialize(targetFile)).Append(",\"tl\":").Append(targetLine)
176
+ .Append(",\"tn\":").Append(JsonSerializer.Serialize(targetName ?? written));
177
+ sb.Append('}');
178
+ stdout.WriteLine(sb.ToString());
179
+ }
@@ -0,0 +1,16 @@
1
+ <Project Sdk="Microsoft.NET.Sdk">
2
+ <!-- Built against the compiler that ships inside the .NET SDK, so it needs no package restore. -->
3
+ <PropertyGroup>
4
+ <OutputType>Exe</OutputType>
5
+ <TargetFramework>net8.0</TargetFramework>
6
+ <Nullable>disable</Nullable>
7
+ <ImplicitUsings>enable</ImplicitUsings>
8
+ <RollForward>Major</RollForward>
9
+ <SatelliteResourceLanguages>en</SatelliteResourceLanguages>
10
+ <NoWarn>$(NoWarn);CS8632;MSB3277</NoWarn>
11
+ </PropertyGroup>
12
+ <ItemGroup>
13
+ <Reference Include="Microsoft.CodeAnalysis"><HintPath>$(MSBuildToolsPath)/Roslyn/bincore/Microsoft.CodeAnalysis.dll</HintPath></Reference>
14
+ <Reference Include="Microsoft.CodeAnalysis.CSharp"><HintPath>$(MSBuildToolsPath)/Roslyn/bincore/Microsoft.CodeAnalysis.CSharp.dll</HintPath></Reference>
15
+ </ItemGroup>
16
+ </Project>
leyline/rules.py ADDED
@@ -0,0 +1,171 @@
1
+ """Architecture rules: constraints over the graph that are checked without any inference.
2
+
3
+ A rule is the user's intent. An agent may suggest one, but a suggested rule stays marked as
4
+ suggested until the user confirms it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import datetime
10
+ import json
11
+ from collections import defaultdict
12
+ from typing import Optional
13
+
14
+ from . import store
15
+
16
+ KINDS = ("forbid", "no_cycle", "must_be_tested")
17
+ EDGE_KINDS = ("calls", "imports", "uses_type", "instantiates", "extends", "implements", "depends_on", "communicates",
18
+ "reads", "writes")
19
+ # What a forbid rule looks at unless told otherwise. A channel is left out: the sender of an event
20
+ # does not depend on whoever listens to it.
21
+ DEPENDENCY_KINDS = ("calls", "imports", "uses_type", "instantiates", "extends", "implements", "depends_on", "reads", "writes")
22
+
23
+
24
+ def _select(con, selector: str) -> set[str]:
25
+ """Node ids a selector covers. Forms: module:Name, system:Name, external:Name, path:prefix, id:prefix, *"""
26
+ selector = selector.strip()
27
+ if selector == "*":
28
+ return {r[0] for r in con.execute("SELECT id FROM nodes WHERE layer = 'fact'")}
29
+ kind, _, value = selector.partition(":")
30
+ if kind == "module":
31
+ row = con.execute("SELECT id FROM nodes WHERE kind = 'module' AND (name = ? OR path = ?)", (value, value)).fetchone()
32
+ if not row:
33
+ return set()
34
+ return {row[0]} | {r[0] for r in con.execute("SELECT node_id FROM ancestry WHERE module_id = ?", (row[0],))}
35
+ if kind == "system":
36
+ row = con.execute(
37
+ "SELECT n.id FROM nodes n LEFT JOIN annotations a ON a.node_id = n.id AND a.key = 'name'"
38
+ " WHERE n.kind = 'system' AND (a.value = ? OR n.name = ?)", (value, value)).fetchone()
39
+ if not row:
40
+ return set()
41
+ out = set()
42
+ for u in con.execute("SELECT dst_id FROM edges WHERE kind = 'groups' AND src_id = ?", (row[0],)):
43
+ out |= {r[0] for r in con.execute(
44
+ "WITH RECURSIVE d(id) AS (SELECT ? UNION SELECT n.id FROM nodes n JOIN d ON n.parent_id = d.id)"
45
+ " SELECT id FROM d", (u[0],))}
46
+ return out
47
+ # A name or prefix as written: LIKE would read `_` and `%` in it as patterns, and ignore case.
48
+ if kind == "external":
49
+ return {r[0] for r in con.execute("SELECT id FROM nodes WHERE kind = 'external' AND (name = ? OR substr(name, 1, ?) = ?)",
50
+ (value, len(value) + 1, value + "."))}
51
+ if kind == "path":
52
+ under = value.rstrip("/") + "/"
53
+ return {r[0] for r in con.execute("SELECT id FROM nodes WHERE path = ? OR substr(path, 1, ?) = ?",
54
+ (value, len(under), under))}
55
+ if kind == "id":
56
+ return {r[0] for r in con.execute("SELECT id FROM nodes WHERE substr(id, 1, ?) = ?", (len(value), value))}
57
+ return set()
58
+
59
+
60
+ def add_rule(con, kind: str, selector_from: str, selector_to: str = "", edge_kinds: Optional[list[str]] = None,
61
+ severity: str = "error", reason: str = "", status: str = "suggested", source: str = "mcp") -> dict:
62
+ if kind not in KINDS:
63
+ return {"error": f"kind must be one of {KINDS}"}
64
+ if status not in ("suggested", "confirmed"):
65
+ return {"error": "status must be suggested or confirmed"}
66
+ if kind == "forbid" and not selector_to:
67
+ return {"error": "a forbid rule needs selector_to"}
68
+ if kind != "no_cycle" and not _select(con, selector_from):
69
+ return {"error": f"selector {selector_from!r} matches nothing"}
70
+ if kind == "forbid" and not _select(con, selector_to):
71
+ return {"error": f"selector {selector_to!r} matches nothing"}
72
+ with con:
73
+ cur = con.execute(
74
+ "INSERT INTO rules (kind, selector_from, selector_to, edge_kinds, severity, reason, status, source, created)"
75
+ " VALUES (?,?,?,?,?,?,?,?,?)",
76
+ (kind, selector_from, selector_to, json.dumps(edge_kinds or []), severity, reason, status, source,
77
+ datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")))
78
+ return {"id": cur.lastrowid, "kind": kind, "status": status}
79
+
80
+
81
+ def confirm_rule(con, rule_id: int) -> dict:
82
+ with con:
83
+ n = con.execute("UPDATE rules SET status = 'confirmed' WHERE id = ?", (rule_id,)).rowcount
84
+ return {"id": rule_id, "status": "confirmed"} if n else {"error": f"no rule {rule_id}"}
85
+
86
+
87
+ def _links(con, kinds: list[str], sure_only: bool = False):
88
+ """The links a rule reads. `sure_only` leaves out links the map guessed by name (a call is a guess only when
89
+ every call site behind it is one)."""
90
+ kinds = kinds or list(DEPENDENCY_KINDS)
91
+ if "calls" in kinds:
92
+ q = ("SELECT src_id, dst_id FROM calls GROUP BY src_id, dst_id HAVING MIN(precision = 'guess') = 0"
93
+ " ORDER BY dst_id, src_id" if sure_only else "SELECT DISTINCT src_id, dst_id FROM calls ORDER BY dst_id, src_id")
94
+ for r in con.execute(q):
95
+ yield r[0], r[1], "calls"
96
+ rest = [k for k in kinds if k != "calls"]
97
+ if rest:
98
+ sure = " AND (precision IS NULL OR precision != 'guess')" if sure_only else ""
99
+ for r in con.execute(f"SELECT src_id, dst_id, kind FROM edges WHERE kind IN ({','.join('?' * len(rest))}){sure}", rest):
100
+ yield r[0], r[1], r[2]
101
+
102
+
103
+ def check(con, rules_from=None, sure_only: bool = False) -> dict:
104
+ """Evaluate every rule against the graph in `con`. `rules_from` reads the rules from another store.
105
+ `sure_only` reads no link the map guessed by name, so a rule that fails only through guesses passes."""
106
+ names = {r["id"]: r["name"] for r in con.execute("SELECT id, name FROM nodes")}
107
+ out = []
108
+ for rule in (rules_from or con).execute("SELECT * FROM rules ORDER BY id").fetchall():
109
+ kinds = json.loads(rule["edge_kinds"] or "[]")
110
+ violations = []
111
+ if rule["kind"] == "forbid":
112
+ a, b = _select(con, rule["selector_from"]), _select(con, rule["selector_to"])
113
+ for s, d, k in _links(con, kinds, sure_only):
114
+ if s in a and d in b:
115
+ violations.append({"from": s, "to": d, "kind": k})
116
+ elif rule["kind"] == "no_cycle":
117
+ # Scope: "modules", or "systems" for systems and ungrouped types across the repo.
118
+ scope = (rule["selector_from"] or "modules").strip()
119
+ group = {r["node_id"]: r["module_id"] for r in con.execute("SELECT node_id, module_id FROM ancestry")}
120
+ if scope.startswith("system"):
121
+ unit_sys = {r["dst_id"]: r["src_id"] for r in con.execute("SELECT src_id, dst_id FROM edges WHERE kind = 'groups'")}
122
+ parent = {r["id"]: r["parent_id"] for r in con.execute("SELECT id, parent_id FROM nodes")}
123
+ def owner(i):
124
+ cur = i
125
+ while cur:
126
+ if cur in unit_sys:
127
+ return unit_sys[cur]
128
+ cur = parent.get(cur)
129
+ return None
130
+ group = {i: owner(i) for i in parent}
131
+ graph = defaultdict(set)
132
+ for s, d, k in _links(con, kinds or ["calls", "uses_type", "instantiates", "extends", "implements", "imports"],
133
+ sure_only):
134
+ gs, gd = group.get(s), group.get(d)
135
+ if gs and gd and gs != gd:
136
+ graph[gs].add(gd)
137
+ seen, done = set(), set()
138
+
139
+ def visit(v, path):
140
+ seen.add(v)
141
+ for w in sorted(graph[v]):
142
+ if w in path:
143
+ cyc = path[path.index(w):] + [w]
144
+ key = tuple(sorted(cyc[:-1]))
145
+ if key not in done:
146
+ done.add(key)
147
+ violations.append({"cycle": [names.get(c, c) for c in cyc], "ids": cyc})
148
+ elif w not in seen:
149
+ visit(w, path + [w])
150
+ for v in sorted(graph):
151
+ if v not in seen:
152
+ visit(v, [v])
153
+ elif rule["kind"] == "must_be_tested":
154
+ scope = _select(con, rule["selector_from"])
155
+ tested = {r[0] for r in con.execute(store.TESTED)}
156
+ try: # functions measured running under a test count as tested
157
+ tested |= {r[0] for r in con.execute("SELECT DISTINCT node_id FROM covered")}
158
+ except Exception:
159
+ pass
160
+ for r in con.execute("SELECT id FROM nodes WHERE kind = 'callable'"):
161
+ if r[0] in scope and r[0] not in tested:
162
+ violations.append({"untested": r[0]})
163
+ for v in violations:
164
+ for key in ("from", "to", "untested"):
165
+ if key in v:
166
+ v[key + "_name"] = names.get(v[key], v[key])
167
+ out.append({"id": rule["id"], "kind": rule["kind"], "from": rule["selector_from"], "to": rule["selector_to"],
168
+ "edge_kinds": kinds, "severity": rule["severity"], "reason": rule["reason"],
169
+ "status": rule["status"] or "confirmed", "passes": not violations,
170
+ "violations": len(violations), "examples": violations[:25]})
171
+ return {"total": len(out), "failing": sum(1 for r in out if not r["passes"]), "rules": out}
leyline/schema.sql ADDED
@@ -0,0 +1,178 @@
1
+ -- Leyline store, schema v0. One SQLite file per workspace.
2
+ -- Layers: fact (deterministic), inferred (LLM), intent (human).
3
+
4
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
5
+
6
+ CREATE TABLE IF NOT EXISTS nodes (
7
+ id TEXT PRIMARY KEY,
8
+ kind TEXT NOT NULL,
9
+ name TEXT NOT NULL,
10
+ parent_id TEXT REFERENCES nodes(id),
11
+ repo_id TEXT,
12
+ language TEXT,
13
+ path TEXT,
14
+ span_start INTEGER,
15
+ span_end INTEGER,
16
+ content_hash TEXT,
17
+ layer TEXT NOT NULL DEFAULT 'fact',
18
+ source TEXT NOT NULL,
19
+ commit_sha TEXT,
20
+ attrs TEXT
21
+ );
22
+ CREATE INDEX IF NOT EXISTS nodes_parent ON nodes(parent_id);
23
+ CREATE INDEX IF NOT EXISTS nodes_kind ON nodes(kind);
24
+ CREATE INDEX IF NOT EXISTS nodes_path ON nodes(path);
25
+
26
+ -- Every id once, numbered, for the large tables below that would otherwise repeat long ids in every row
27
+ -- and in each of their indexes.
28
+ CREATE TABLE IF NOT EXISTS keys (k INTEGER PRIMARY KEY, id TEXT NOT NULL UNIQUE);
29
+
30
+ -- Edges, with both ends as keys. Readers and writers use the `edges` view, which store.connect creates and
31
+ -- which has the columns of the original table (id, kind, src_id, dst_id, precision, layer, source,
32
+ -- commit_sha, attrs); inserting into or deleting from the view works.
33
+ CREATE TABLE IF NOT EXISTS links (
34
+ id INTEGER PRIMARY KEY,
35
+ kind TEXT NOT NULL,
36
+ src INTEGER NOT NULL,
37
+ dst INTEGER NOT NULL,
38
+ precision TEXT NOT NULL,
39
+ layer TEXT NOT NULL DEFAULT 'fact',
40
+ source TEXT NOT NULL,
41
+ commit_sha TEXT,
42
+ attrs TEXT
43
+ );
44
+ CREATE INDEX IF NOT EXISTS links_out ON links(src, kind);
45
+ CREATE INDEX IF NOT EXISTS links_in ON links(dst, kind);
46
+
47
+ -- Call edges are the largest kind, so they get a narrow table of their own, with both ends as keys. Readers
48
+ -- use the `calls` view, which store.connect creates and which has the columns of the original table
49
+ -- (src_id, dst_id, dispatch, precision, site_start, site_end, hit_count, commit_sha).
50
+ CREATE TABLE IF NOT EXISTS call_sites (
51
+ src INTEGER NOT NULL,
52
+ dst INTEGER NOT NULL,
53
+ dispatch TEXT NOT NULL,
54
+ precision TEXT NOT NULL,
55
+ site_start INTEGER,
56
+ site_end INTEGER,
57
+ hit_count INTEGER DEFAULT 0,
58
+ commit_sha TEXT
59
+ );
60
+ CREATE INDEX IF NOT EXISTS call_sites_out ON call_sites(src, dst);
61
+ CREATE INDEX IF NOT EXISTS call_sites_in ON call_sites(dst, src);
62
+
63
+ CREATE TABLE IF NOT EXISTS annotations (
64
+ id INTEGER PRIMARY KEY,
65
+ node_id TEXT NOT NULL REFERENCES nodes(id),
66
+ key TEXT NOT NULL,
67
+ value TEXT NOT NULL,
68
+ layer TEXT NOT NULL,
69
+ source TEXT NOT NULL,
70
+ confidence REAL,
71
+ evidence TEXT,
72
+ evidence_hash TEXT,
73
+ stale INTEGER DEFAULT 0
74
+ );
75
+ CREATE INDEX IF NOT EXISTS annotations_node ON annotations(node_id, key);
76
+
77
+ CREATE TABLE IF NOT EXISTS flows (
78
+ id TEXT PRIMARY KEY, name TEXT, origin TEXT, entry_id TEXT, weight REAL,
79
+ group_id TEXT, layer TEXT, source TEXT, attrs TEXT
80
+ );
81
+ -- Flow steps are the largest table by far (millions of rows on a big repository), so they are stored as
82
+ -- integers: every id once in `keys`, every step as a row of numbers. Readers use the `flow_steps` view,
83
+ -- which store.connect creates and which has the columns of the original table.
84
+ CREATE INDEX IF NOT EXISTS flows_entry ON flows(entry_id);
85
+ CREATE TABLE IF NOT EXISTS steps (
86
+ flow INTEGER, seq INTEGER, depth INTEGER, callable INTEGER,
87
+ via INTEGER, -- index into store.VIA
88
+ site_line INTEGER, -- line of the call that led here
89
+ parent_seq INTEGER, -- the step this one was reached from
90
+ PRIMARY KEY (flow, seq)
91
+ ) WITHOUT ROWID;
92
+ CREATE INDEX IF NOT EXISTS steps_callable ON steps(callable, flow);
93
+ CREATE TABLE IF NOT EXISTS pattern_instances (
94
+ id TEXT PRIMARY KEY, pattern TEXT, matcher TEXT, rationale TEXT,
95
+ confidence REAL, evidence_hash TEXT, stale INTEGER DEFAULT 0
96
+ );
97
+ CREATE TABLE IF NOT EXISTS pattern_roles (instance_id TEXT, role TEXT, node_id TEXT);
98
+ CREATE TABLE IF NOT EXISTS tours (id TEXT PRIMARY KEY, title TEXT, audience TEXT, layer TEXT, source TEXT);
99
+ CREATE TABLE IF NOT EXISTS tour_stops (tour_id TEXT, seq INTEGER, ref_kind TEXT, ref_id TEXT, narrative TEXT);
100
+ CREATE TABLE IF NOT EXISTS boundaries (id INTEGER PRIMARY KEY, system_id TEXT, selector TEXT);
101
+ CREATE TABLE IF NOT EXISTS rules (
102
+ id INTEGER PRIMARY KEY, kind TEXT, selector_from TEXT, selector_to TEXT,
103
+ edge_kinds TEXT, severity TEXT, reason TEXT
104
+ );
105
+ CREATE TABLE IF NOT EXISTS change_proposals (
106
+ id TEXT PRIMARY KEY, intent TEXT, status TEXT, base_commit TEXT, head_commit TEXT, attrs TEXT
107
+ );
108
+
109
+ -- What each extractor did per repo, so "not analyzed" is distinguishable from "none found".
110
+ CREATE TABLE IF NOT EXISTS extractor_coverage (
111
+ repo_id TEXT, extractor TEXT, version TEXT, status TEXT, commit_sha TEXT, stats TEXT,
112
+ PRIMARY KEY (repo_id, extractor)
113
+ );
114
+
115
+ -- Roll-up cache: the file and module each node sits in.
116
+ CREATE TABLE IF NOT EXISTS ancestry (
117
+ node_id TEXT PRIMARY KEY, file_id TEXT, module_id TEXT
118
+ );
119
+ CREATE INDEX IF NOT EXISTS ancestry_module ON ancestry(module_id);
120
+ CREATE INDEX IF NOT EXISTS ancestry_file ON ancestry(file_id);
121
+
122
+ CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(node_id UNINDEXED, name, qualified, path, kind UNINDEXED);
123
+
124
+ -- A saved view: a named set of marked nodes with a narrative. Change proposals create one;
125
+ -- an agent can also author its own.
126
+ CREATE TABLE IF NOT EXISTS views (
127
+ id TEXT PRIMARY KEY, title TEXT NOT NULL, kind TEXT NOT NULL, layer TEXT NOT NULL,
128
+ source TEXT NOT NULL, created TEXT, change_id TEXT, spec TEXT NOT NULL
129
+ );
130
+
131
+ -- Outcomes of test runs, labelled so two runs can be compared.
132
+ CREATE TABLE IF NOT EXISTS test_results (
133
+ run TEXT NOT NULL, name TEXT NOT NULL, test_id TEXT, status TEXT NOT NULL, message TEXT, recorded TEXT,
134
+ PRIMARY KEY (run, name)
135
+ );
136
+
137
+ -- Measured coverage: which functions actually ran, per test where the tool recorded it.
138
+ CREATE TABLE IF NOT EXISTS coverage_runs (
139
+ run TEXT PRIMARY KEY, format TEXT, created TEXT, commit_sha TEXT, stats TEXT
140
+ );
141
+ CREATE TABLE IF NOT EXISTS covered (
142
+ run TEXT, test TEXT, test_id TEXT, node_id TEXT, lines INTEGER
143
+ );
144
+ CREATE INDEX IF NOT EXISTS covered_node ON covered (node_id);
145
+ CREATE INDEX IF NOT EXISTS covered_test ON covered (test_id);
146
+
147
+ -- A change stated as a spec: its tasks and scenarios tied to the map, and what reviewers found.
148
+ CREATE TABLE IF NOT EXISTS spec_items (
149
+ change_id TEXT, kind TEXT, key TEXT, text TEXT, action TEXT, nodes TEXT, attrs TEXT,
150
+ PRIMARY KEY (change_id, kind, key)
151
+ );
152
+ CREATE TABLE IF NOT EXISTS findings (
153
+ id TEXT PRIMARY KEY, change_id TEXT, reviewer TEXT, severity TEXT, claim TEXT, evidence TEXT,
154
+ proposal TEXT, status TEXT, resolution TEXT, created TEXT
155
+ );
156
+
157
+ -- Spec drift (leyline.drift): what each code name a spec or a finished change names meant when it was last known
158
+ -- to be right. change_id is the change's id, or specs/<capability> for a living spec. The same anchors are kept in
159
+ -- openspec/leyline-anchors.json, which wins, so they outlive a fresh map. place is that openspec/ folder.
160
+ CREATE TABLE IF NOT EXISTS spec_anchors (
161
+ place TEXT NOT NULL, change_id TEXT NOT NULL, written TEXT NOT NULL, node_id TEXT NOT NULL, kind TEXT, path TEXT,
162
+ decl TEXT, decl_hash TEXT, body_hash TEXT, attrs TEXT, recorded TEXT,
163
+ PRIMARY KEY (place, change_id, written, node_id)
164
+ );
165
+
166
+ -- Change coupling read from git history (leyline.coupling), kept per repository under the commit it was read from:
167
+ -- how many commits changed each file, and each pair of files (and file and folder) changed together in two or more.
168
+ CREATE TABLE IF NOT EXISTS coupling_runs (
169
+ repo_id TEXT, sha TEXT, commits INTEGER, bulk INTEGER, seconds REAL, created REAL,
170
+ PRIMARY KEY (repo_id, sha)
171
+ );
172
+ CREATE TABLE IF NOT EXISTS coupling_files (repo_id TEXT, sha TEXT, path TEXT, changes INTEGER, PRIMARY KEY (repo_id, sha, path));
173
+ CREATE TABLE IF NOT EXISTS coupling_pairs (repo_id TEXT, sha TEXT, a TEXT, b TEXT, together INTEGER);
174
+ CREATE INDEX IF NOT EXISTS coupling_pairs_a ON coupling_pairs (repo_id, sha, a);
175
+ CREATE INDEX IF NOT EXISTS coupling_pairs_b ON coupling_pairs (repo_id, sha, b);
176
+ -- A file and a folder outside it (dir ends in /): commits that changed the file and any file in the folder.
177
+ CREATE TABLE IF NOT EXISTS coupling_dirs (repo_id TEXT, sha TEXT, path TEXT, dir TEXT, together INTEGER);
178
+ CREATE INDEX IF NOT EXISTS coupling_dirs_path ON coupling_dirs (repo_id, sha, path);