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
leyline/formats.py ADDED
@@ -0,0 +1,923 @@
1
+ """Code joined by a string's shape, not by a call: one function builds a key such as `dialogue/<d>/nodes/<n>/text`,
2
+ and another takes keys of that shape apart. Nothing calls across, so a change to the shape on one side breaks
3
+ the other side silently.
4
+
5
+ `extract` finds both sides in a parsed file and records them as `format` endpoints:
6
+
7
+ - a writer is a template with holes and at least two fixed parts between separators (`/` or `:`): a template
8
+ literal, an f-string, an interpolated string, `"%s/nodes/%s" % ...`, `"{}/nodes/{}".format(...)`,
9
+ `String.Format("{0}/nodes/{1}", ...)` or a chain of `+`. A hole that is a call to another key builder
10
+ (`${dialogueNodeKey(d, n)}/text`) is filled in with that builder's shape when the two are linked.
11
+ - a reader is a regular expression over such a shape (`/^dialogue\\/([^/]+)\\/nodes\\//`), a startsWith, endsWith
12
+ or includes test of a literal with two or more fixed parts, or a function that compares the pieces of a split
13
+ key by position (`parts[2] === "nodes"`, `rest[0] !== "text"`).
14
+
15
+ `resolve` links a writer to a reader when two or more fixed parts of the reader sit at the same places in the
16
+ writer's shape and no fixed part disagrees. Routes (`/api/...`) and file paths (a part with a dot, a leading `/`)
17
+ are left to the http and file channels. The link is data-like: the writer's end is the hub, and a reader must
18
+ read what the writer now writes."""
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from collections import Counter, defaultdict
24
+ from typing import Optional
25
+
26
+ from .model import Edge
27
+
28
+ WORD = re.compile(r"[A-Za-z_][A-Za-z0-9_\-]*\Z")
29
+ DIGITS = re.compile(r"\d+\Z")
30
+ SEPS = ("/", ":")
31
+ HOLE = "*"
32
+ # First parts that make a shape a URL or a path, which other channels own.
33
+ NOT_KEYS = frozenset("api http https www v1 v2 v3 static assets public src dist build node_modules tmp var usr etc home".split()
34
+ # git's own names (refs/heads/x): both ends talk to git, not to each other
35
+ + ["refs"])
36
+ PREFIX_TESTS = {"startsWith": "start", "startswith": "start", "StartsWith": "start", "endsWith": "end", "endswith": "end",
37
+ "EndsWith": "end", "includes": "any", "Contains": "any", "indexOf": "any", "IndexOf": "any"}
38
+ REGEX_FNS = {"compile", "match", "fullmatch", "search", "findall", "finditer", "sub", "subn", "split"}
39
+ CS_REGEX_FNS = {"Match", "IsMatch", "Matches", "Replace", "Split"}
40
+ TOO_MANY_WRITERS = 12 # one shape built in this many places is a convention, not one format's two ends
41
+ TOO_MANY_READERS = 25
42
+
43
+
44
+ def _t(node) -> str:
45
+ return node.text.decode("utf8", "replace") if node is not None else ""
46
+
47
+
48
+ # -- shapes ------------------------------------------------------------------------------------------------
49
+
50
+ def shape_of(pieces: list) -> Optional[tuple[str, list[str]]]:
51
+ """(separator, parts) for a template given as pieces: ("c", text) for fixed text, ("h", None) for a hole,
52
+ ("call", name) for a hole that is a call, ("v", value) for a hole filled by a known constant. A part is fixed
53
+ text, `*` for a hole, or `@name` for a call to fill in later. None when the template is not a key."""
54
+ text = "".join(p[1] if p[0] in ("c", "v") else "\x00" for p in pieces)
55
+ if not text or len(text) > 200 or "://" in text or text.startswith(("/", "http", "\\")) or "\n" in text:
56
+ return None
57
+ lead_call = bool(pieces) and pieces[0][0] == "call"
58
+ sep = next((s for s in SEPS if text.count(s) >= 2 or (lead_call and text.count(s) >= 1)), None)
59
+ if sep is None:
60
+ return None
61
+ segs: list[list] = [[]]
62
+ for kind, val in pieces:
63
+ if kind in ("c", "v"):
64
+ bits = val.split(sep)
65
+ for i, b in enumerate(bits):
66
+ if i:
67
+ segs.append([])
68
+ if b:
69
+ segs[-1].append(("c", b))
70
+ else:
71
+ segs[-1].append((kind, val))
72
+ if segs and not segs[-1]:
73
+ segs.pop() # a trailing separator
74
+ out = []
75
+ for seg in segs:
76
+ if not seg:
77
+ return None # a leading or doubled separator: a path
78
+ if all(k == "c" for k, _ in seg):
79
+ word = "".join(v for _, v in seg)
80
+ if not (WORD.match(word) or DIGITS.match(word)):
81
+ return None # spaces, dots, quotes: prose, a file name, markup
82
+ out.append(word)
83
+ elif len(seg) == 1 and seg[0][0] == "call":
84
+ out.append("@" + seg[0][1])
85
+ else:
86
+ fixed = "".join(v for k, v in seg if k == "c")
87
+ if fixed and not re.fullmatch(r"[A-Za-z0-9_\-]+", fixed):
88
+ return None
89
+ out.append(HOLE)
90
+ return sep, out
91
+
92
+
93
+ def is_key(parts: list[str]) -> bool:
94
+ """A finished writer shape: fixed first part, two or more fixed words, at least one hole."""
95
+ words = [p for p in parts if WORD.match(p)]
96
+ return (len(parts) >= 3 and bool(WORD.match(parts[0])) and parts[0].lower() not in NOT_KEYS
97
+ and len(words) >= 2 and HOLE in parts and not any(p.startswith("@") for p in parts))
98
+
99
+
100
+ def address(sep: str, parts: list[str]) -> str:
101
+ return sep.join(parts)
102
+
103
+
104
+ def regex_shape(pattern: str) -> Optional[tuple[str, list[str], bool]]:
105
+ """(separator, parts, anchored) for a regular expression over a key: ^dialogue\\/([^/]+)\\/nodes -> dialogue/*/nodes.
106
+ Only a plain sequence of words and groups is read; alternation at the top, or a part with a dot, is not."""
107
+ if len(pattern) > 300:
108
+ return None
109
+ anchored = pattern.startswith("^")
110
+ body = pattern[1:] if anchored else pattern
111
+ body = re.sub(r"\$$|\\z$|\\Z$", "", body)
112
+ sep = "/" if body.count("/") >= 2 else ":" if body.count(":") >= 2 else None
113
+ if sep is None:
114
+ return None
115
+ segs, cur, depth, cls, i = [], "", 0, False, 0
116
+ while i < len(body):
117
+ ch = body[i]
118
+ if ch == "\\" and i + 1 < len(body):
119
+ nxt = body[i + 1]
120
+ if nxt == sep and depth == 0 and not cls:
121
+ segs.append(cur)
122
+ cur, i = "", i + 2
123
+ continue
124
+ cur += ch + nxt
125
+ i += 2
126
+ continue
127
+ if cls:
128
+ cls = ch != "]"
129
+ elif ch == "[":
130
+ cls = True
131
+ elif ch == "(":
132
+ depth += 1
133
+ elif ch == ")":
134
+ depth -= 1
135
+ elif ch == "|" and depth == 0:
136
+ return None
137
+ elif ch == sep and depth == 0:
138
+ segs.append(cur)
139
+ cur, i = "", i + 1
140
+ continue
141
+ cur += ch
142
+ i += 1
143
+ segs.append(cur)
144
+ if segs and segs[-1] == "":
145
+ segs.pop()
146
+ if not segs or segs[0] == "":
147
+ return None
148
+ out = []
149
+ for s in segs:
150
+ plain = s.replace("\\-", "-").replace("\\_", "_")
151
+ if WORD.match(plain):
152
+ out.append(plain)
153
+ elif "\\." in s or re.search(r"(?<!\\)\.\w", s) and not re.search(r"[(\[]", s):
154
+ return None # a file name
155
+ elif re.search(r"[(\[.\\+*?{]", s):
156
+ out.append(HOLE)
157
+ else:
158
+ return None
159
+ words = [p for p in out if WORD.match(p)]
160
+ if len(words) < 2 or HOLE not in out:
161
+ return None
162
+ return sep, out, anchored
163
+
164
+
165
+ def literal_shape(text: str) -> Optional[tuple[str, list[str]]]:
166
+ """(separator, fixed parts) of a literal a key is tested against: "dialogue/" -> no (one part); "nodes/x/" -> two."""
167
+ sep = next((s for s in SEPS if s in text), None)
168
+ if sep is None or len(text) > 120 or any(ch.isspace() for ch in text):
169
+ return None
170
+ parts = [p for p in text.split(sep)]
171
+ inner = [p for p in parts if p]
172
+ if len(inner) < 2 or not all(WORD.match(p) for p in inner) or text.startswith("/") and inner[0].lower() in NOT_KEYS:
173
+ return None
174
+ if parts[0] == "" and text.startswith("/") and len(parts) > 1 and parts[1].lower() in NOT_KEYS:
175
+ return None
176
+ return sep, inner
177
+
178
+
179
+ # -- extraction --------------------------------------------------------------------------------------------
180
+
181
+ class _Base:
182
+ """Walks one file and records format ends. Subclasses say how each language writes the shapes."""
183
+
184
+ def __init__(self, tree, where, out, consts: dict):
185
+ self.tree, self.where, self.out = tree, where, out
186
+ self.consts = {k: v[0] for k, v in consts.items() if len(v) == 1 and v[0] and WORD.match(v[0])}
187
+ self.index: dict[tuple, dict] = defaultdict(lambda: defaultdict(set)) # (fn, array) -> position -> words
188
+ self.index_line: dict[tuple, int] = {}
189
+ self.alias: dict[tuple, tuple] = {} # (fn, name) -> (array, position)
190
+ self.origin: dict[tuple, Optional[int]] = {} # (fn, array) -> offset into a split key, None when unknown
191
+ self.splits: dict[str, set] = defaultdict(set) # fn -> separators it splits keys on
192
+ self.done: set = set()
193
+ self.held: dict[str, tuple] = {} # a module constant or field holding a pattern -> the pattern's shape
194
+
195
+ def fn(self, node) -> Optional[str]:
196
+ return self.where.fn(node.start_point[0] + 1)
197
+
198
+ def writer(self, node, pieces) -> None:
199
+ if (node.start_byte, node.end_byte) in self.done:
200
+ return
201
+ self.done.add((node.start_byte, node.end_byte))
202
+ got = shape_of(pieces)
203
+ src = self.fn(node)
204
+ if got is None or src is None:
205
+ return
206
+ sep, parts = got
207
+ parts = parts[:1] + [HOLE if p.startswith("@") else p for p in parts[1:]] # only a leading builder is filled in
208
+ if not (parts[0].startswith("@") and len(parts) >= 2) and not is_key(parts):
209
+ return
210
+ self.out.add("format", "write", src, address(sep, parts), node.start_point[0] + 1, "template")
211
+
212
+ def reader(self, node, sep, parts, kind, anchored=False) -> None:
213
+ src = self.fn(node)
214
+ if src is None:
215
+ return
216
+ n = self.where.nodes.get(src)
217
+ if n is not None and (n.kind == "type" or n.name in ("<module>", "<top-level>")):
218
+ # A pattern kept in a module constant or a field is read where the code uses it.
219
+ holder, cur = None, node
220
+ for _ in range(4):
221
+ cur = cur.parent
222
+ if cur is None:
223
+ break
224
+ if cur.type in ("assignment", "variable_declarator"):
225
+ name = cur.child_by_field_name("left") or cur.child_by_field_name("name") \
226
+ or next((c for c in cur.named_children if c.type == "identifier"), None)
227
+ holder = _t(name).rsplit(".", 1)[-1] if name is not None else None
228
+ break
229
+ if holder and WORD.match(holder):
230
+ self.held[holder] = (sep, parts, kind, anchored, src, node.start_point[0] + 1)
231
+ return
232
+ self.out.add("format", "read", src, address(sep, parts), node.start_point[0] + 1, kind,
233
+ ["anchored"] if anchored else [])
234
+
235
+ def uses_of_held(self) -> None:
236
+ if not self.held:
237
+ return
238
+ used = set()
239
+ stack = [self.tree.root_node]
240
+ while stack:
241
+ n = stack.pop()
242
+ if n.type in ("identifier", "property_identifier") and _t(n) in self.held:
243
+ src = self.fn(n)
244
+ node = self.where.nodes.get(src) if src else None
245
+ if node is not None and node.kind in ("callable", "test") and node.name not in ("<module>", "<top-level>"):
246
+ sep, parts, kind, anchored, _s, _l = self.held[_t(n)]
247
+ used.add(_t(n))
248
+ self.out.add("format", "read", src, address(sep, parts), n.start_point[0] + 1, kind,
249
+ ["anchored"] if anchored else [])
250
+ stack.extend(n.children)
251
+ for name, (sep, parts, kind, anchored, src, line) in self.held.items():
252
+ if name not in used: # used nowhere in this file: the constant itself is the reader
253
+ self.out.add("format", "read", src, address(sep, parts), line, kind, ["anchored"] if anchored else [])
254
+
255
+ def compare(self, node, sub_array: str, pos: int, words: list[str]) -> None:
256
+ src = self.fn(node)
257
+ if src is None:
258
+ return
259
+ key = (src, sub_array)
260
+ if key in self.alias:
261
+ sub_array, base = self.alias[key]
262
+ pos += base
263
+ key = (src, sub_array)
264
+ for w in words:
265
+ if WORD.match(w):
266
+ self.index[key][pos].add(w)
267
+ self.index_line.setdefault(key, node.start_point[0] + 1)
268
+
269
+ def finish(self) -> None:
270
+ self.uses_of_held()
271
+ for (src, arr), at in self.index.items():
272
+ if len(at) < 2:
273
+ continue
274
+ off = self.origin.get((src, arr), "?")
275
+ seps = self.splits.get(src) or set(SEPS)
276
+ lits = [f"{p}={'|'.join(sorted(ws))}" for p, ws in sorted(at.items())]
277
+ if isinstance(off, int):
278
+ lits.append(f"offset={off}")
279
+ for sep in sorted(seps):
280
+ shown = " ".join(f"[{p}]={'|'.join(sorted(ws))}" for p, ws in sorted(at.items()))
281
+ self.out.add("format", "read", src, f"{arr}{sep} {shown}", self.index_line[(src, arr)], "index", lits)
282
+
283
+
284
+ class _TypeScript(_Base):
285
+ def pieces(self, node) -> Optional[list]:
286
+ t = node.type
287
+ if t == "template_string":
288
+ out = []
289
+ for c in node.children:
290
+ if c.type in ("string_fragment", "escape_sequence"):
291
+ out.append(("c", _t(c)))
292
+ elif c.type == "template_substitution":
293
+ out.append(self.hole(c.named_children[0] if c.named_children else None))
294
+ return out
295
+ if t == "string":
296
+ return [("c", "".join(_t(c) for c in node.children if c.type in ("string_fragment", "escape_sequence")))]
297
+ if t == "parenthesized_expression" and node.named_children:
298
+ return self.pieces(node.named_children[0])
299
+ if t == "binary_expression" and _t(node.child_by_field_name("operator")) == "+":
300
+ a, b = self.pieces(node.child_by_field_name("left")), self.pieces(node.child_by_field_name("right"))
301
+ return (a or [("h", None)]) + (b or [("h", None)]) if (a or b) else None
302
+ return None
303
+
304
+ def hole(self, e):
305
+ if e is None:
306
+ return ("h", None)
307
+ if e.type == "identifier" and _t(e) in self.consts:
308
+ return ("v", self.consts[_t(e)])
309
+ if e.type == "call_expression":
310
+ f = e.child_by_field_name("function")
311
+ if f is not None and f.type == "identifier":
312
+ return ("call", _t(f))
313
+ return ("h", None)
314
+
315
+ def run(self) -> None:
316
+ stack = [self.tree.root_node]
317
+ while stack:
318
+ n = stack.pop()
319
+ t = n.type
320
+ if t == "template_string" and any(c.type == "template_substitution" for c in n.children):
321
+ self.writer(n, self.pieces(n) or [])
322
+ elif t == "binary_expression":
323
+ op = _t(n.child_by_field_name("operator"))
324
+ if op == "+" and not (n.parent is not None and n.parent.type == "binary_expression"
325
+ and _t(n.parent.child_by_field_name("operator")) == "+"):
326
+ p = self.pieces(n)
327
+ if p and any(k == "c" for k, _ in p) and any(k != "c" for k, _ in p):
328
+ self.writer(n, p)
329
+ elif op in ("===", "!==", "==", "!="):
330
+ self._compare(n, n.child_by_field_name("left"), n.child_by_field_name("right"))
331
+ self._compare(n, n.child_by_field_name("right"), n.child_by_field_name("left"))
332
+ elif t == "regex":
333
+ got = regex_shape(_t(n.child_by_field_name("pattern")))
334
+ if got:
335
+ self.reader(n, got[0], got[1], "regex", got[2])
336
+ elif t == "call_expression":
337
+ self._call(n)
338
+ elif t == "variable_declarator":
339
+ self._declare(n)
340
+ elif t == "switch_statement":
341
+ self._switch(n)
342
+ stack.extend(reversed(n.children))
343
+ self.finish()
344
+
345
+ def _sub(self, node):
346
+ """(array, position) for a[2]."""
347
+ while node is not None and node.type in ("parenthesized_expression", "non_null_expression"):
348
+ node = node.named_children[0] if node.named_children else None
349
+ if node is not None and node.type == "subscript_expression":
350
+ obj, idx = node.child_by_field_name("object"), node.child_by_field_name("index")
351
+ if obj is not None and obj.type == "identifier" and idx is not None and idx.type == "number" and _t(idx).isdigit():
352
+ return _t(obj), int(_t(idx))
353
+ if node is not None and node.type == "identifier":
354
+ key = (self.fn(node), _t(node))
355
+ if key in self.alias:
356
+ return self.alias[key][0], self.alias[key][1]
357
+ return None
358
+
359
+ def _compare(self, n, side, other) -> None:
360
+ at = self._sub(side)
361
+ if at is None or other is None or other.type != "string":
362
+ return
363
+ self.compare(n, at[0], at[1], [(self.pieces(other) or [("c", "")])[0][1]])
364
+
365
+ def _switch(self, n) -> None:
366
+ at = self._sub(n.child_by_field_name("value"))
367
+ body = n.child_by_field_name("body")
368
+ if at is None or body is None:
369
+ return
370
+ words = [_t(v)[1:-1] for c in body.named_children if c.type == "switch_case"
371
+ for v in [c.child_by_field_name("value")] if v is not None and v.type == "string"]
372
+ self.compare(n, at[0], at[1], words)
373
+
374
+ def _declare(self, n) -> None:
375
+ name, value = n.child_by_field_name("name"), n.child_by_field_name("value")
376
+ if name is None or value is None:
377
+ return
378
+ src = self.fn(n)
379
+ while value.type in ("non_null_expression", "parenthesized_expression", "as_expression") and value.named_children:
380
+ value = value.named_children[0]
381
+ if name.type == "identifier":
382
+ at = self._sub(value)
383
+ if at is not None:
384
+ self.alias[(src, _t(name))] = at
385
+ return
386
+ if value.type == "call_expression":
387
+ f = value.child_by_field_name("function")
388
+ args = value.child_by_field_name("arguments")
389
+ if f is not None and f.type == "member_expression":
390
+ prop = _t(f.child_by_field_name("property"))
391
+ obj = f.child_by_field_name("object")
392
+ if prop == "split":
393
+ self.origin[(src, _t(name))] = 0
394
+ elif prop == "slice" and obj is not None and obj.type == "identifier" and args is not None \
395
+ and args.named_children and args.named_children[0].type == "number" and _t(args.named_children[0]).isdigit():
396
+ self.alias[(src, _t(name))] = (_t(obj), int(_t(args.named_children[0]))) # rest = parts.slice(2)
397
+ elif name.type == "array_pattern" and value.type == "call_expression":
398
+ f = value.child_by_field_name("function")
399
+ if f is not None and f.type == "member_expression" and _t(f.child_by_field_name("property")) == "split":
400
+ arr = f"[{name.start_point[0]}]"
401
+ self.origin[(src, arr)] = 0
402
+ for i, c in enumerate(name.named_children):
403
+ if c.type == "identifier":
404
+ self.alias[(src, _t(c))] = (arr, i)
405
+ elif c.type == "rest_pattern" and c.named_children and c.named_children[0].type == "identifier":
406
+ self.alias[(src, _t(c.named_children[0]))] = (arr, i) # [kind, id, ...rest]
407
+
408
+ def _call(self, n) -> None:
409
+ f = n.child_by_field_name("function")
410
+ args = n.child_by_field_name("arguments")
411
+ if f is None or f.type != "member_expression" or args is None or not args.named_children:
412
+ return
413
+ prop = _t(f.child_by_field_name("property"))
414
+ first = args.named_children[0]
415
+ if prop == "split" and first.type == "string":
416
+ s = _t(first)[1:-1]
417
+ if s in SEPS:
418
+ src = self.fn(n)
419
+ if src:
420
+ self.splits[src].add(s)
421
+ elif prop in PREFIX_TESTS and first.type == "string":
422
+ got = literal_shape(_t(first)[1:-1])
423
+ if got:
424
+ self.reader(n, got[0], got[1], PREFIX_TESTS[prop], PREFIX_TESTS[prop] == "start")
425
+
426
+
427
+ class _Python(_Base):
428
+ def pieces(self, node) -> Optional[list]:
429
+ t = node.type
430
+ if t == "string":
431
+ out = []
432
+ for c in node.children:
433
+ if c.type in ("string_content", "escape_sequence"):
434
+ out.append(("c", _t(c)))
435
+ elif c.type == "interpolation":
436
+ e = c.named_children[0] if c.named_children else None
437
+ out.append(self.hole(e))
438
+ return out
439
+ if t == "concatenated_string":
440
+ return [p for c in node.named_children for p in (self.pieces(c) or [])]
441
+ if t == "parenthesized_expression" and node.named_children:
442
+ return self.pieces(node.named_children[0])
443
+ if t == "binary_operator" and _t(node.child_by_field_name("operator")) == "+":
444
+ a, b = self.pieces(node.child_by_field_name("left")), self.pieces(node.child_by_field_name("right"))
445
+ return (a or [("h", None)]) + (b or [("h", None)]) if (a or b) else None
446
+ return None
447
+
448
+ def hole(self, e):
449
+ if e is None:
450
+ return ("h", None)
451
+ if e.type == "identifier" and _t(e) in self.consts:
452
+ return ("v", self.consts[_t(e)])
453
+ if e.type == "call":
454
+ f = e.child_by_field_name("function")
455
+ if f is not None and f.type == "identifier":
456
+ return ("call", _t(f))
457
+ return ("h", None)
458
+
459
+ @staticmethod
460
+ def holes(text: str) -> list:
461
+ """A %-format or str.format template as pieces: "%s/nodes/%s" -> hole, "/nodes/", hole."""
462
+ out, last = [], 0
463
+ for m in re.finditer(r"%[-#0 +]*\d*(?:\.\d+)?[sdirf]|\{[^{}]*\}", text):
464
+ out.append(("c", text[last:m.start()]))
465
+ out.append(("h", None))
466
+ last = m.end()
467
+ out.append(("c", text[last:]))
468
+ return [p for p in out if p != ("c", "")]
469
+
470
+ def run(self) -> None:
471
+ stack = [self.tree.root_node]
472
+ while stack:
473
+ n = stack.pop()
474
+ t = n.type
475
+ if t == "string" and any(c.type == "interpolation" for c in n.children):
476
+ self.writer(n, self.pieces(n) or [])
477
+ elif t == "binary_operator":
478
+ op = _t(n.child_by_field_name("operator"))
479
+ left = n.child_by_field_name("left")
480
+ if op == "%" and left is not None and left.type == "string":
481
+ self.writer(n, self.holes("".join(v for _, v in (self.pieces(left) or []) if v)))
482
+ elif op == "+" and not (n.parent is not None and n.parent.type == "binary_operator"
483
+ and _t(n.parent.child_by_field_name("operator")) == "+"):
484
+ p = self.pieces(n)
485
+ if p and any(k == "c" for k, _ in p) and any(k != "c" for k, _ in p):
486
+ self.writer(n, p)
487
+ elif t == "comparison_operator":
488
+ self._compare(n)
489
+ elif t == "call":
490
+ self._call(n)
491
+ elif t == "assignment":
492
+ self._assign(n)
493
+ stack.extend(reversed(n.children))
494
+ self.finish()
495
+
496
+ def _sub(self, node):
497
+ if node is not None and node.type == "subscript":
498
+ obj, idx = node.child_by_field_name("value"), node.child_by_field_name("subscript")
499
+ if obj is not None and obj.type == "identifier" and idx is not None and idx.type == "integer":
500
+ return _t(obj), int(_t(idx))
501
+ if node is not None and node.type == "identifier":
502
+ key = (self.fn(node), _t(node))
503
+ if key in self.alias:
504
+ return self.alias[key]
505
+ return None
506
+
507
+ def _compare(self, n) -> None:
508
+ kids = n.children
509
+ if len(kids) != 3:
510
+ return
511
+ a, op, b = kids
512
+ if _t(op) not in ("==", "!=", "in", "not in"):
513
+ return
514
+ for side, other in ((a, b), (b, a)):
515
+ at = self._sub(side)
516
+ if at is None:
517
+ continue
518
+ if other.type == "string":
519
+ words = ["".join(_t(c) for c in other.children if c.type == "string_content")]
520
+ elif other.type in ("tuple", "list", "set") and _t(op) in ("in", "not in"):
521
+ words = ["".join(_t(c) for c in s.children if c.type == "string_content") for s in other.named_children
522
+ if s.type == "string"]
523
+ else:
524
+ continue
525
+ self.compare(n, at[0], at[1], words)
526
+ return
527
+
528
+ def _assign(self, n) -> None:
529
+ left, right = n.child_by_field_name("left"), n.child_by_field_name("right")
530
+ if left is None or right is None:
531
+ return
532
+ src = self.fn(n)
533
+ if left.type == "identifier":
534
+ at = self._sub(right)
535
+ if at is not None:
536
+ self.alias[(src, _t(left))] = at
537
+ return
538
+ if right.type == "call":
539
+ f = right.child_by_field_name("function")
540
+ if f is not None and f.type == "attribute" and _t(f.child_by_field_name("attribute")) in ("split", "rsplit"):
541
+ self.origin[(src, _t(left))] = 0
542
+ elif right.type == "subscript":
543
+ obj, sl = right.child_by_field_name("value"), right.child_by_field_name("subscript")
544
+ if obj is not None and obj.type == "identifier" and sl is not None and sl.type == "slice":
545
+ lo = sl.named_children[0] if sl.named_children else None
546
+ if lo is not None and lo.type == "integer" and _t(sl).startswith(_t(lo)):
547
+ self.alias[(src, _t(left))] = (_t(obj), int(_t(lo)))
548
+ elif left.type in ("pattern_list", "tuple_pattern") and right.type == "call":
549
+ f = right.child_by_field_name("function")
550
+ if f is not None and f.type == "attribute" and _t(f.child_by_field_name("attribute")) == "split":
551
+ arr = f"[{left.start_point[0]}]"
552
+ self.origin[(src, arr)] = 0
553
+ for i, c in enumerate(left.named_children):
554
+ if c.type == "identifier":
555
+ self.alias[(src, _t(c))] = (arr, i)
556
+
557
+ def _call(self, n) -> None:
558
+ f = n.child_by_field_name("function")
559
+ args = n.child_by_field_name("arguments")
560
+ if f is None or args is None or not args.named_children:
561
+ return
562
+ first = args.named_children[0]
563
+ name = _t(f.child_by_field_name("attribute")) if f.type == "attribute" else _t(f)
564
+ if f.type == "attribute" and name == "format" and f.child_by_field_name("object") is not None \
565
+ and f.child_by_field_name("object").type == "string":
566
+ obj = f.child_by_field_name("object")
567
+ self.writer(n, self.holes("".join(v for _, v in (self.pieces(obj) or []) if v)))
568
+ return
569
+ if first.type != "string" or any(c.type == "interpolation" for c in first.children):
570
+ return
571
+ text = "".join(_t(c) for c in first.children if c.type == "string_content")
572
+ if f.type == "attribute" and name in ("split", "rsplit") and text in SEPS:
573
+ src = self.fn(n)
574
+ if src:
575
+ self.splits[src].add(text)
576
+ elif f.type == "attribute" and name in PREFIX_TESTS:
577
+ got = literal_shape(text)
578
+ if got:
579
+ self.reader(n, got[0], got[1], PREFIX_TESTS[name], PREFIX_TESTS[name] == "start")
580
+ elif name in REGEX_FNS and f.type == "attribute" and _t(f.child_by_field_name("object")) in ("re", "regex"):
581
+ got = regex_shape(text)
582
+ if got:
583
+ self.reader(n, got[0], got[1], "regex", got[2] or name in ("match", "fullmatch"))
584
+
585
+
586
+ class _CSharp(_Base):
587
+ def pieces(self, node) -> Optional[list]:
588
+ t = node.type
589
+ if t == "interpolated_string_expression":
590
+ out = []
591
+ for c in node.children:
592
+ if c.type == "interpolation":
593
+ e = next((x for x in c.named_children if not x.type.startswith("interpolation_")), None)
594
+ out.append(self.hole(e))
595
+ elif c.is_named and not c.type.startswith("interpolation_"):
596
+ out.append(("c", _t(c)))
597
+ return out
598
+ if t in ("string_literal", "verbatim_string_literal", "raw_string_literal"):
599
+ return [("c", self.text(node))]
600
+ if t == "parenthesized_expression" and node.named_children:
601
+ return self.pieces(node.named_children[0])
602
+ if t == "binary_expression" and _t(node.child_by_field_name("operator")) == "+":
603
+ a, b = self.pieces(node.child_by_field_name("left")), self.pieces(node.child_by_field_name("right"))
604
+ return (a or [("h", None)]) + (b or [("h", None)]) if (a or b) else None
605
+ return None
606
+
607
+ @staticmethod
608
+ def text(node) -> str:
609
+ body = "".join(_t(c) for c in node.children if c.type.endswith("content"))
610
+ return body or _t(node).lstrip("@").strip('"')
611
+
612
+ def hole(self, e):
613
+ if e is None:
614
+ return ("h", None)
615
+ if e.type == "identifier" and _t(e) in self.consts:
616
+ return ("v", self.consts[_t(e)])
617
+ if e.type == "invocation_expression":
618
+ f = e.child_by_field_name("function")
619
+ if f is not None and f.type == "identifier":
620
+ return ("call", _t(f))
621
+ return ("h", None)
622
+
623
+ def run(self) -> None:
624
+ stack = [self.tree.root_node]
625
+ while stack:
626
+ n = stack.pop()
627
+ t = n.type
628
+ if t == "interpolated_string_expression":
629
+ self.writer(n, self.pieces(n) or [])
630
+ elif t == "binary_expression":
631
+ op = _t(n.child_by_field_name("operator"))
632
+ if op == "+" and not (n.parent is not None and n.parent.type == "binary_expression"
633
+ and _t(n.parent.child_by_field_name("operator")) == "+"):
634
+ p = self.pieces(n)
635
+ if p and any(k == "c" for k, _ in p) and any(k != "c" for k, _ in p):
636
+ self.writer(n, p)
637
+ elif op in ("==", "!="):
638
+ for side, other in ((n.child_by_field_name("left"), n.child_by_field_name("right")),
639
+ (n.child_by_field_name("right"), n.child_by_field_name("left"))):
640
+ at = self._sub(side)
641
+ if at is not None and other is not None and other.type == "string_literal":
642
+ self.compare(n, at[0], at[1], [self.text(other)])
643
+ break
644
+ elif t == "invocation_expression":
645
+ self._call(n)
646
+ elif t == "object_creation_expression":
647
+ ty = _t(n.child_by_field_name("type")).rsplit(".", 1)[-1]
648
+ args = n.child_by_field_name("arguments")
649
+ first = self._args(args)[:1]
650
+ if ty == "Regex" and first and first[0].type in ("string_literal", "verbatim_string_literal", "raw_string_literal"):
651
+ got = regex_shape(self.text(first[0]))
652
+ if got:
653
+ self.reader(n, got[0], got[1], "regex", got[2])
654
+ elif t == "variable_declarator":
655
+ self._declare(n)
656
+ elif t == "attribute" and _t(n.child_by_field_name("name")) in ("GeneratedRegex", "GeneratedRegexAttribute"):
657
+ for a in n.named_children:
658
+ for s in a.named_children if a.type == "attribute_argument_list" else ():
659
+ lit = s.named_children[0] if s.named_children else None
660
+ if lit is not None and lit.type in ("string_literal", "verbatim_string_literal"):
661
+ got = regex_shape(self.text(lit))
662
+ if got:
663
+ self.reader(n, got[0], got[1], "regex", got[2])
664
+ break
665
+ stack.extend(reversed(n.children))
666
+ self.finish()
667
+
668
+ @staticmethod
669
+ def _args(args) -> list:
670
+ return [a.named_children[-1] for a in args.children if a.type == "argument" and a.named_children] if args is not None else []
671
+
672
+ def fn(self, node):
673
+ return self.where.fn(node.start_point[0] + 1) or self.where.type(node.start_point[0] + 1)
674
+
675
+ def _sub(self, node):
676
+ if node is not None and node.type == "element_access_expression":
677
+ obj = node.child_by_field_name("expression")
678
+ sub = node.child_by_field_name("subscript")
679
+ idx = _t(sub).strip("[] ") if sub is not None else ""
680
+ if obj is not None and obj.type == "identifier" and idx.isdigit():
681
+ return _t(obj), int(idx)
682
+ if node is not None and node.type == "identifier":
683
+ key = (self.fn(node), _t(node))
684
+ if key in self.alias:
685
+ return self.alias[key]
686
+ return None
687
+
688
+ def _declare(self, n) -> None:
689
+ name = n.child_by_field_name("name") or next((c for c in n.named_children if c.type == "identifier"), None)
690
+ value = n.named_children[-1] if n.named_child_count > 1 else None
691
+ if value is not None and value.type == "equals_value_clause":
692
+ value = value.named_children[-1] if value.named_children else None
693
+ if name is None or value is None:
694
+ return
695
+ src = self.fn(n)
696
+ at = self._sub(value)
697
+ if at is not None:
698
+ self.alias[(src, _t(name))] = at
699
+ elif value.type == "invocation_expression":
700
+ f = value.child_by_field_name("function")
701
+ if f is not None and f.type == "member_access_expression" and _t(f.child_by_field_name("name")) == "Split":
702
+ self.origin[(src, _t(name))] = 0
703
+
704
+ def _call(self, n) -> None:
705
+ f = n.child_by_field_name("function")
706
+ args = self._args(n.child_by_field_name("arguments"))
707
+ if f is None or not args:
708
+ return
709
+ name = _t(f.child_by_field_name("name")) if f.type == "member_access_expression" else _t(f)
710
+ owner = _t(f.child_by_field_name("expression")) if f.type == "member_access_expression" else ""
711
+ first = args[0]
712
+ if name == "Format" and owner in ("string", "String") and first.type in ("string_literal", "verbatim_string_literal"):
713
+ text = self.text(first)
714
+ out, last = [], 0
715
+ for m in re.finditer(r"\{\d+(?:[,:][^}]*)?\}", text):
716
+ out += [("c", text[last:m.start()]), ("h", None)]
717
+ last = m.end()
718
+ out.append(("c", text[last:]))
719
+ self.writer(n, [p for p in out if p != ("c", "")])
720
+ return
721
+ if name == "Split" and first.type in ("character_literal", "string_literal"):
722
+ sep = _t(first)[1:-1]
723
+ src = self.fn(n)
724
+ if sep in SEPS and src:
725
+ self.splits[src].add(sep)
726
+ elif name in PREFIX_TESTS and first.type in ("string_literal", "verbatim_string_literal"):
727
+ got = literal_shape(self.text(first))
728
+ if got:
729
+ self.reader(n, got[0], got[1], PREFIX_TESTS[name], PREFIX_TESTS[name] == "start")
730
+ elif name in CS_REGEX_FNS and owner == "Regex" and len(args) > 1 \
731
+ and args[1].type in ("string_literal", "verbatim_string_literal", "raw_string_literal"):
732
+ got = regex_shape(self.text(args[1]))
733
+ if got:
734
+ self.reader(n, got[0], got[1], "regex", got[2])
735
+
736
+
737
+ def extract(lang: str, tree, where, out, consts: Optional[dict] = None) -> None:
738
+ """Record the format ends in one parsed file (see the module docstring). A failure loses this file's formats."""
739
+ cls = {"typescript": _TypeScript, "python": _Python, "csharp": _CSharp}.get(lang)
740
+ if cls is None:
741
+ return
742
+ try:
743
+ cls(tree, where, out, consts or {}).run()
744
+ except RecursionError:
745
+ pass
746
+
747
+
748
+ # -- linking -----------------------------------------------------------------------------------------------
749
+
750
+ def _parse_addr(addr: str) -> tuple[str, list[str]]:
751
+ sep = next((s for s in SEPS if s in addr), "/")
752
+ return sep, addr.split(sep)
753
+
754
+
755
+ def _fits(writer: list[str], reader: list[str], anchored: bool) -> int:
756
+ """How many fixed parts of a reader's shape sit at the same places in a writer's shape, at the best alignment
757
+ where no fixed part disagrees. 0 when none fits."""
758
+ best = 0
759
+ offsets = [0] if anchored else range(0, max(1, len(writer) - 1))
760
+ for k in offsets:
761
+ hits, bad = 0, False
762
+ for i, r in enumerate(reader):
763
+ if i + k >= len(writer):
764
+ break
765
+ w = writer[i + k]
766
+ if r == HOLE or w == HOLE:
767
+ continue
768
+ if r == w:
769
+ hits += WORD.match(r) is not None
770
+ else:
771
+ bad = True
772
+ break
773
+ if not bad:
774
+ best = max(best, hits)
775
+ return best
776
+
777
+
778
+ def _fits_index(writer: list[str], at: dict[int, set], offset: Optional[int]) -> tuple[int, int]:
779
+ """(hits, offset) for a reader that compares a split key's pieces by position: the writer's part at position
780
+ p + offset must be one of the words compared at p."""
781
+ best = (0, -1)
782
+ offsets = [offset] if offset is not None else range(0, len(writer))
783
+ for k in offsets:
784
+ hits, bad = 0, False
785
+ for p, words in at.items():
786
+ if p + k >= len(writer) or writer[p + k] == HOLE:
787
+ continue
788
+ if writer[p + k] in words:
789
+ hits += 1
790
+ else:
791
+ bad = True
792
+ break
793
+ if not bad and hits > best[0]:
794
+ best = (hits, k)
795
+ return best
796
+
797
+
798
+ def _in_tests(ix, nid: str) -> bool:
799
+ from .channels import _test_path
800
+ n = ix.nodes.get(nid)
801
+ cur = n
802
+ while cur is not None and cur.kind in ("callable", "test"):
803
+ if cur.kind == "test" or cur.attrs.get("is_test") or cur.attrs.get("is_fixture"):
804
+ return True
805
+ cur = ix.nodes.get(cur.parent_id)
806
+ return n is not None and bool(n.path) and _test_path(n.path)
807
+
808
+
809
+ def resolve(ix) -> None:
810
+ """Link each key writer to the readers of its shape, across the workspace (one repository at a time)."""
811
+ st = ix.channel_stats["format"]
812
+ writers, readers = [], []
813
+ for fid, res in ix.results.items():
814
+ for e in res.endpoints:
815
+ if e.channel != "format" or e.src_id not in ix.nodes:
816
+ continue
817
+ (writers if e.role == "write" else readers).append(e)
818
+ # A hole that calls another key builder takes that builder's shape: `${dialogueNodeKey(d, n)}/text`.
819
+ by_name: dict[tuple, list] = defaultdict(list)
820
+ for e in writers:
821
+ n = ix.nodes[e.src_id]
822
+ by_name[(e.src_id.split(":", 1)[0], n.name)].append(e)
823
+ done: dict[int, Optional[tuple]] = {}
824
+
825
+ def expand(e, depth=0):
826
+ if id(e) in done:
827
+ return done[id(e)]
828
+ done[id(e)] = None
829
+ sep, parts = _parse_addr(e.address)
830
+ out = []
831
+ for p in parts:
832
+ if p.startswith("@"):
833
+ cands = [w for w in by_name.get((e.src_id.split(":", 1)[0], p[1:]), []) if w is not e]
834
+ same = [w for w in cands if ix.file_of.get(w.src_id) == ix.file_of.get(e.src_id)]
835
+ cands = same or cands
836
+ shapes = {expand(w, depth + 1) for w in cands} if depth < 4 else set()
837
+ shapes.discard(None)
838
+ if len(shapes) != 1 or len({w.src_id for w in cands}) != 1:
839
+ if not out:
840
+ return None # what the key starts with is not known
841
+ out.append(HOLE)
842
+ continue
843
+ s2, p2 = next(iter(shapes))
844
+ if s2 != sep:
845
+ return None
846
+ out.extend(p2)
847
+ else:
848
+ out.append(p)
849
+ got = (sep, tuple(out)) if is_key(out) else None
850
+ done[id(e)] = got
851
+ return got
852
+
853
+ shaped = []
854
+ for e in writers:
855
+ if _in_tests(ix, e.src_id):
856
+ st["written_in_tests"] += 1 # a test writes a sample key to feed the reader; it does not set the format
857
+ continue
858
+ got = expand(e)
859
+ if got is not None:
860
+ shaped.append((e, got[0], list(got[1])))
861
+ st["writers"] = len(shaped)
862
+ st["readers"] = len(readers)
863
+ per_shape = Counter((ix.nodes[e.src_id].id.split(":", 1)[0], sep, tuple(p)) for e, sep, p in shaped)
864
+ pairs = {}
865
+ for r in readers:
866
+ rrepo = r.src_id.split(":", 1)[0]
867
+ if r.method == "index":
868
+ sep = r.address.split(" ", 1)[0][-1]
869
+ at: dict[int, set] = {}
870
+ offset = None
871
+ for lit in r.literals:
872
+ k, _, v = lit.partition("=")
873
+ if k == "offset":
874
+ offset = int(v)
875
+ else:
876
+ at[int(k)] = set(v.split("|"))
877
+ else:
878
+ sep, rparts = _parse_addr(r.address)
879
+ found = [] # (writer, parts, hits, offset)
880
+ for e, wsep, wparts in shaped:
881
+ if wsep != sep or e.src_id.split(":", 1)[0] != rrepo or e.src_id == r.src_id:
882
+ continue
883
+ k = 0
884
+ if r.method == "index":
885
+ hits, k = _fits_index(wparts, at, offset)
886
+ elif r.method == "end":
887
+ hits = _fits(list(reversed(wparts)), list(reversed(rparts)), True)
888
+ else:
889
+ hits = _fits(wparts, rparts, "anchored" in r.literals)
890
+ if hits >= 2:
891
+ found.append((e, wparts, hits, k))
892
+ if r.method == "index" and offset is None and len({k for *_, k in found}) > 1:
893
+ # The pieces come from a parameter, so where they start in the key is not seen. Two formats can share a
894
+ # tail (dialogue/<d>/nodes/<n>/text and nodes/<n>/text): the reader is taken to read the one its own
895
+ # file writes, else the one written in the most files, and not both.
896
+ here = ix.file_of.get(r.src_id)
897
+ own = {k for e, _p, _h, k in found if ix.file_of.get(e.src_id) == here}
898
+ votes = Counter(k for k, _f in {(k, ix.file_of.get(e.src_id)) for e, _p, _h, k in found})
899
+ top = votes.most_common()
900
+ if len(own) == 1:
901
+ keep = own
902
+ elif len(top) > 1 and top[0][1] == top[1][1]:
903
+ st["readers_of_two_formats"] += 1
904
+ keep = set()
905
+ else:
906
+ keep = {top[0][0]}
907
+ found = [f for f in found if f[3] in keep]
908
+ for e, wparts, hits, _k in found:
909
+ key = (e.src_id, r.src_id)
910
+ prev = pairs.get(key)
911
+ if prev is None or hits > prev[1]:
912
+ pairs[key] = (address(sep, wparts), hits, r.method, r.line, e.line)
913
+ fan_in = Counter(r for (_w, r) in pairs)
914
+ for (w, r), (addr, hits, how, rline, wline) in sorted(pairs.items()):
915
+ sep, parts = _parse_addr(addr)
916
+ if per_shape[(w.split(":", 1)[0], sep, tuple(parts))] > TOO_MANY_WRITERS or fan_in[r] > TOO_MANY_READERS:
917
+ st["too_common_to_link"] += 1
918
+ continue
919
+ if w == r or w not in ix.nodes or r not in ix.nodes:
920
+ continue
921
+ ix.edges.append(Edge("communicates", w, r, "heuristic" if hits >= 3 else "guess",
922
+ {"channel": "format", "address": addr, "read_by": how, "line": wline, "read_at": rline}))
923
+ st["links"] += 1