cg-code-graph 0.10.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 (174) hide show
  1. cg_code_graph-0.10.1.dist-info/METADATA +678 -0
  2. cg_code_graph-0.10.1.dist-info/RECORD +174 -0
  3. cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
  4. cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
  5. cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
  6. cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
  7. codegraph/__init__.py +2 -0
  8. codegraph/aitools.py +129 -0
  9. codegraph/apps.py +76 -0
  10. codegraph/blindspots.py +428 -0
  11. codegraph/bridges.py +1701 -0
  12. codegraph/cli.py +725 -0
  13. codegraph/concepts.py +362 -0
  14. codegraph/config.py +559 -0
  15. codegraph/core/__init__.py +0 -0
  16. codegraph/core/cache.py +375 -0
  17. codegraph/core/detect.py +80 -0
  18. codegraph/core/extractors.py +187 -0
  19. codegraph/core/fsutil.py +61 -0
  20. codegraph/core/generated.py +575 -0
  21. codegraph/core/model.py +174 -0
  22. codegraph/core/paths.py +175 -0
  23. codegraph/core/plugin.py +160 -0
  24. codegraph/core/store.py +80 -0
  25. codegraph/core/syntax_errors.py +132 -0
  26. codegraph/coverage.py +928 -0
  27. codegraph/doctor.py +453 -0
  28. codegraph/external.py +613 -0
  29. codegraph/indexer.py +336 -0
  30. codegraph/link.py +434 -0
  31. codegraph/lint_async.py +524 -0
  32. codegraph/mcp_server.py +1303 -0
  33. codegraph/parity.py +473 -0
  34. codegraph/parity_structure.py +307 -0
  35. codegraph/payload.py +321 -0
  36. codegraph/plans.py +1285 -0
  37. codegraph/platform_scan.py +643 -0
  38. codegraph/platforms.py +1369 -0
  39. codegraph/plugins/__init__.py +0 -0
  40. codegraph/plugins/cfamily/__init__.py +0 -0
  41. codegraph/plugins/cfamily/plugin.py +930 -0
  42. codegraph/plugins/cfamily/syntax.py +881 -0
  43. codegraph/plugins/dart/__init__.py +0 -0
  44. codegraph/plugins/dart/bridges.py +345 -0
  45. codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
  46. codegraph/plugins/dart/extractor/pubspec.lock +149 -0
  47. codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
  48. codegraph/plugins/dart/http.py +904 -0
  49. codegraph/plugins/dart/models.py +308 -0
  50. codegraph/plugins/dart/plugin.py +625 -0
  51. codegraph/plugins/dart/program.py +907 -0
  52. codegraph/plugins/django/__init__.py +0 -0
  53. codegraph/plugins/django/extras.py +378 -0
  54. codegraph/plugins/django/models.py +508 -0
  55. codegraph/plugins/django/plugin.py +728 -0
  56. codegraph/plugins/django/schemas.py +339 -0
  57. codegraph/plugins/django/shapes.py +216 -0
  58. codegraph/plugins/django/urls.py +603 -0
  59. codegraph/plugins/express/__init__.py +0 -0
  60. codegraph/plugins/express/plugin.py +428 -0
  61. codegraph/plugins/flutter/__init__.py +0 -0
  62. codegraph/plugins/flutter/plugin.py +538 -0
  63. codegraph/plugins/kotlin/__init__.py +0 -0
  64. codegraph/plugins/kotlin/exact.py +457 -0
  65. codegraph/plugins/kotlin/plugin.py +1961 -0
  66. codegraph/plugins/kotlin/reparse.py +234 -0
  67. codegraph/plugins/laravel/__init__.py +0 -0
  68. codegraph/plugins/laravel/broadcast.py +351 -0
  69. codegraph/plugins/laravel/plugin.py +863 -0
  70. codegraph/plugins/laravel/tests.py +262 -0
  71. codegraph/plugins/laravel/values.py +728 -0
  72. codegraph/plugins/native/__init__.py +0 -0
  73. codegraph/plugins/native/gates.py +286 -0
  74. codegraph/plugins/native/runner.py +183 -0
  75. codegraph/plugins/native/scipread.py +194 -0
  76. codegraph/plugins/native/ts.py +54 -0
  77. codegraph/plugins/nest/__init__.py +0 -0
  78. codegraph/plugins/nest/plugin.py +654 -0
  79. codegraph/plugins/nextjs/__init__.py +0 -0
  80. codegraph/plugins/nextjs/plugin.py +336 -0
  81. codegraph/plugins/nuxt/__init__.py +0 -0
  82. codegraph/plugins/nuxt/plugin.py +308 -0
  83. codegraph/plugins/php/__init__.py +0 -0
  84. codegraph/plugins/php/extractor/composer.json +5 -0
  85. codegraph/plugins/php/extractor/composer.lock +76 -0
  86. codegraph/plugins/php/extractor/extract.php +743 -0
  87. codegraph/plugins/php/gating.py +573 -0
  88. codegraph/plugins/php/plugin.py +668 -0
  89. codegraph/plugins/php/strings.py +197 -0
  90. codegraph/plugins/python/__init__.py +0 -0
  91. codegraph/plugins/python/aitools.py +664 -0
  92. codegraph/plugins/python/external.py +245 -0
  93. codegraph/plugins/python/fields.py +107 -0
  94. codegraph/plugins/python/plugin.py +1733 -0
  95. codegraph/plugins/python/refs.py +485 -0
  96. codegraph/plugins/python/roots.py +412 -0
  97. codegraph/plugins/python/socketio.py +210 -0
  98. codegraph/plugins/python/subproc.py +864 -0
  99. codegraph/plugins/python/tests.py +1040 -0
  100. codegraph/plugins/python/values.py +179 -0
  101. codegraph/plugins/pyweb/__init__.py +0 -0
  102. codegraph/plugins/pyweb/plugin.py +1334 -0
  103. codegraph/plugins/pyweb/values.py +68 -0
  104. codegraph/plugins/rust/__init__.py +0 -0
  105. codegraph/plugins/rust/cargo.py +226 -0
  106. codegraph/plugins/rust/plugin.py +980 -0
  107. codegraph/plugins/rust/syntax.py +678 -0
  108. codegraph/plugins/scip/__init__.py +0 -0
  109. codegraph/plugins/scip/importer.py +129 -0
  110. codegraph/plugins/scip/scip.proto +962 -0
  111. codegraph/plugins/scip/scip_pb2.py +97 -0
  112. codegraph/plugins/stubs/__init__.py +0 -0
  113. codegraph/plugins/stubs/plugins.py +38 -0
  114. codegraph/plugins/swift/__init__.py +0 -0
  115. codegraph/plugins/swift/baseurl.py +109 -0
  116. codegraph/plugins/swift/exact.py +415 -0
  117. codegraph/plugins/swift/indexstore.py +209 -0
  118. codegraph/plugins/swift/packages.py +174 -0
  119. codegraph/plugins/swift/plugin.py +2890 -0
  120. codegraph/plugins/ts/__init__.py +0 -0
  121. codegraph/plugins/ts/baseurl.py +185 -0
  122. codegraph/plugins/ts/extractor/extract.mjs +2652 -0
  123. codegraph/plugins/ts/extractor/fw.mjs +685 -0
  124. codegraph/plugins/ts/extractor/package-lock.json +205 -0
  125. codegraph/plugins/ts/extractor/package.json +9 -0
  126. codegraph/plugins/ts/plugin.py +480 -0
  127. codegraph/plugins/tsweb/__init__.py +0 -0
  128. codegraph/plugins/tsweb/common.py +290 -0
  129. codegraph/plugins/tsweb/data.py +276 -0
  130. codegraph/presets/__init__.py +146 -0
  131. codegraph/presets/c_cpp.yaml +9 -0
  132. codegraph/presets/common.yaml +66 -0
  133. codegraph/presets/dart.yaml +9 -0
  134. codegraph/presets/django-ninja.yaml +15 -0
  135. codegraph/presets/django.yaml +25 -0
  136. codegraph/presets/djangorestframework.yaml +17 -0
  137. codegraph/presets/express.yaml +17 -0
  138. codegraph/presets/kotlin.yaml +11 -0
  139. codegraph/presets/laravel.yaml +40 -0
  140. codegraph/presets/nest.yaml +11 -0
  141. codegraph/presets/nextjs.yaml +15 -0
  142. codegraph/presets/nuxt.yaml +9 -0
  143. codegraph/presets/php.yaml +5 -0
  144. codegraph/presets/python.yaml +10 -0
  145. codegraph/presets/rust.yaml +5 -0
  146. codegraph/presets/swift.yaml +10 -0
  147. codegraph/presets/typescript.yaml +13 -0
  148. codegraph/process_runs.py +328 -0
  149. codegraph/protocols/__init__.py +299 -0
  150. codegraph/protocols/builtin.py +67 -0
  151. codegraph/protocols/matchers.py +144 -0
  152. codegraph/protocols/view.py +334 -0
  153. codegraph/query.py +2089 -0
  154. codegraph/realtime.py +260 -0
  155. codegraph/roundtrip.py +346 -0
  156. codegraph/routes.py +442 -0
  157. codegraph/starters.py +218 -0
  158. codegraph/tests_index.py +117 -0
  159. codegraph/viz/__init__.py +0 -0
  160. codegraph/viz/graph.py +369 -0
  161. codegraph/viz/server.py +198 -0
  162. codegraph/viz/static/app.css +148 -0
  163. codegraph/viz/static/app.js +1082 -0
  164. codegraph/viz/static/index.html +81 -0
  165. codegraph/viz/static/layered.js +237 -0
  166. codegraph/viz/static/vendor/VERSIONS.txt +4 -0
  167. codegraph/viz/static/vendor/cose-base.js +3214 -0
  168. codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
  169. codegraph/viz/static/vendor/cytoscape.min.js +31 -0
  170. codegraph/viz/static/vendor/layout-base.js +5230 -0
  171. codegraph/viz/tools/package-lock.json +303 -0
  172. codegraph/viz/tools/package.json +7 -0
  173. codegraph/viz/tools/shoot.mjs +165 -0
  174. codegraph/xcode.py +251 -0
@@ -0,0 +1,524 @@
1
+ """`cg lint async-state` (#88 phase 3), rule `stale-async-result`.
2
+
3
+ Flags a write of stored / UI state (a WRITES_PROP edge) inside an async block — Swift `Task { }` / `Task.detached`,
4
+ Kotlin `launch { }` / `async { }`, a React `useEffect` callback, a JS / TS `async` function or `.then(...)`, Python
5
+ `async def` / `asyncio.create_task` — that comes after an `await` (Kotlin: `.await()`, `withContext`, `delay`,
6
+ `.first()`), with no cancellation check (`Task.isCancelled`, `checkCancellation`, `isActive`, `ensureActive`, a
7
+ `cancelled` / `ignore` flag, `signal.aborted`) and no comparison against a token, ID or generation captured before the
8
+ await between the await and the write. The write must use the awaited result, the awaited request must depend on an
9
+ input (a parameter, prop, state or loop variable: a fixed `client.post("/auth.config")` cannot be overtaken by a
10
+ newer request with other inputs), and the block must not be wrapped in a single-flight helper (`bundleAsync`,
11
+ `dedupe`, `debounce`, `throttle`, `once`). A result that lands after the user moved on overwrites newer state.
12
+
13
+ Findings are heuristic, read from the indexed source text with file:line evidence; they add no edges and change no
14
+ counts.
15
+
16
+ Rule `two-writers`: the same state is written with a real value both by lifecycle code (an initializer / `init`
17
+ block, `onAppear`, `.task` before its first await, `useEffect`, `onMounted`, `LaunchedEffect`, `viewDidLoad`) and
18
+ by async code after an await or by a completion / subscription callback (`.sink`, `.then`, `.collect`,
19
+ `completion: {`). Defaults and flag resets (`= []`, `setLoading(true)`, `setError(null)`) and in-place mutations are
20
+ not competing values and are skipped.
21
+
22
+ Not implemented yet: incomplete cache key, echo suppression."""
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import re
27
+ from pathlib import Path
28
+
29
+ from .core.store import GraphStore
30
+
31
+ OPEN_RES = (
32
+ ("async func", re.compile(r"\)\s*async\b(?:\s+throws)?(?:\s*->\s*[^{]+)?\s*\{")),
33
+ ("Task", re.compile(r"\bTask(?:\.detached)?\s*(?:\([^)]*\))?\s*\{")),
34
+ ("launch", re.compile(r"(?<![)\s])\s*\b(?:launch|async)\s*(?:\([^)]*\))?\s*\{|^\s*(?:launch|async)\s*(?:\([^)]*\))?\s*\{|[.=(]\s*(?:launch|async)\s*(?:\([^)]*\))?\s*\{")),
35
+ ("useEffect", re.compile(r"\buse(?:Layout)?Effect\s*\(")),
36
+ ("async function", re.compile(r"\basync\s+(?:function\b|\([^)]*\)\s*=>|\w+\s*=>|def\s+\w+|[\w$]+\s*\([^)]*\)\s*\{)")),
37
+ ("then", re.compile(r"\.then\s*\(")),
38
+ ("create_task", re.compile(r"\b(?:asyncio\.)?(?:create_task|ensure_future)\s*\(")),
39
+ )
40
+ AWAIT_RE = re.compile(r"\bawait\b|\.await\(\)|\bwithContext\s*\(|\bdelay\s*\(|\.first\s*\(\s*\)|\.single\s*\(\s*\)|\.then\s*\(")
41
+ GUARD_RE = re.compile(r"\bisCancelled\b|checkCancellation|\bisActive\b|ensureActive|\bcancel+ed\b|\bignore\b|\baborted\b|"
42
+ r"\bstale\b|\bisMounted\b|\bmounted\b|\bgeneration\b|\btoken\b|\blatest\w*\b|\brequestId\b|"
43
+ r"\bisCurrent\w*\b", re.I)
44
+ CMP_RE = re.compile(r"(?:===?|!==?)")
45
+ DECL_RE = re.compile(r"\b(?:let|var|val|const)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=|^\s*([a-z_]\w*)\s*=(?!=)")
46
+ UI_HINT = ("wrapper", "hook")
47
+ # the async block is wrapped in a single-flight / dedupe / rate limiter: concurrent runs cannot overtake each other
48
+ SINGLE_FLIGHT_RE = re.compile(r"\b(?:bundle\w*|dedup\w*|single[Ff]light\w*|debounce\w*|throttle\w*|exhaust\w*|once)\s*\(")
49
+ KEYWORDS = frozenset("""await try async let var val const self this it nil null None true false True False new return
50
+ in of is as await_ undefined void typeof""".split())
51
+
52
+
53
+ def _input_dependent(text: str) -> bool:
54
+ """Does the awaited request depend on an input (a parameter, prop, state or loop variable) rather than being a
55
+ fixed call (`client.post("/auth.config")`, `requestPhotoAccessIfNeeded()`)? Only then can a newer request
56
+ overtake it with different inputs."""
57
+ t = re.sub(r"""(['"`])(?:\\.|(?!\1).)*\1""", "''", text) # string literals
58
+ t = re.sub(r"(?<![:\w])([A-Za-z_$][\w$]*)\s*:(?!:)", " ", t) # labels / object keys (`url:`, `id:`)
59
+ t = re.sub(r"\b(?:this|self)\s*\.\s*", " SELFREF_", t) # `this.x` / `self.x`: the instance's state
60
+ t = re.sub(r"\.\s*[A-Za-z_$][\w$]*", " ", t) # member names
61
+ t = re.sub(r"(?<![\w$])[A-Za-z_$][\w$]*\s*(?:<[^<>]*>)?\s*\(", " (", t) # call names
62
+ ids = re.findall(r"(?<![\w$])[A-Za-z_$][\w$]*", t)
63
+ return any(i.startswith("SELFREF_") or (i not in KEYWORDS and not i[0].isupper()) for i in ids)
64
+ TEST_PATH_RE = re.compile(r"(?:^|/)(?:tests?|__tests__|spec|testing|fixtures?|factories|Tests|UITests)/|"
65
+ r"[._-](?:test|spec)s?\.\w+:|Tests?\.swift:|Test\.kt:|(?:^|/)test_\w+\.py:")
66
+
67
+
68
+ def _lines(root, rel, cache):
69
+ if rel not in cache:
70
+ try:
71
+ cache[rel] = Path(root, rel).read_text("utf-8", "replace").split("\n")
72
+ except OSError:
73
+ cache[rel] = []
74
+ return cache[rel]
75
+
76
+
77
+ def _opener(ls: list[str], line: int, lo: int):
78
+ """The innermost async opener around `line` (1-based), not above `lo`: (kind, opener line) when `line` is still
79
+ inside the block that opens there (brackets still open), else None. Python blocks are indentation-based."""
80
+ for o in range(line, max(lo, line - 60, 1) - 1, -1):
81
+ t = ls[o - 1] if o <= len(ls) else ""
82
+ for kind, rx in OPEN_RES:
83
+ m = rx.search(t)
84
+ if not m:
85
+ continue
86
+ if re.search(r"\basync\s+def\b", t): # Python: an `async def` whose body indents past it
87
+ ind = len(t) - len(t.lstrip())
88
+ body = ls[o:line]
89
+ if all((not x.strip()) or (len(x) - len(x.lstrip())) > ind for x in body):
90
+ return ("async def", o)
91
+ continue
92
+ depth = 0
93
+ for i in range(o, line + 1):
94
+ x = ls[i - 1]
95
+ if i == o:
96
+ x = x[m.start():]
97
+ depth += sum(x.count(c) for c in "({[") - sum(x.count(c) for c in ")}]")
98
+ if depth <= 0 and i < line:
99
+ break
100
+ else:
101
+ if depth > 0 or (o == line):
102
+ return (kind, o)
103
+ return None
104
+
105
+
106
+ def stale_async(st: GraphStore, include_tests: bool = False, limit: int = 500) -> list[dict]:
107
+ root = st.meta().get("root")
108
+ cache: dict = {}
109
+ fields = {r["id"]: json.loads(r["attrs"] or "{}") for r in st.q("SELECT id, attrs FROM nodes WHERE kind IN ('field','property')")}
110
+ rows = st.q("""SELECT e.src, e.dst, e.file, e.line, e.attrs, n.line AS fl, n.end_line AS fe, n.fqn
111
+ FROM edges e JOIN nodes n ON n.id = e.src
112
+ WHERE e.kind = 'WRITES_PROP' ORDER BY e.file, e.line""")
113
+ out, seen = [], set()
114
+ for r in rows:
115
+ if not r["file"] or not root:
116
+ continue
117
+ ls = _lines(root, r["file"], cache)
118
+ if not ls or r["line"] > len(ls):
119
+ continue
120
+ op = _opener(ls, r["line"], r["fl"] or 1)
121
+ if op is None:
122
+ continue
123
+ kind, o = op
124
+ body = ls[o - 1:r["line"] - 1]
125
+ aw = next((o + i for i, t in enumerate(body) if AWAIT_RE.search(t if i else t[t.find("{") + 1:] if "{" in t else t)), None)
126
+ if aw is None:
127
+ continue
128
+ before = "\n".join(ls[o - 1:aw - 1]) + "\n" + ls[aw - 1].split("await")[0]
129
+ after = "\n".join(ls[aw - 1:r["line"]])
130
+ captured = {m.group(1) or m.group(2) for m in DECL_RE.finditer(before)} - {None}
131
+ if GUARD_RE.search(after) or SINGLE_FLIGHT_RE.search(ls[o - 1]):
132
+ continue
133
+ aw_line = ls[aw - 1]
134
+ k = max(aw_line.find("await"), 0)
135
+ stmt, depth = [], 0
136
+ for i in range(aw - 1, min(len(ls), aw + 5)):
137
+ x = ls[i][k:] if i == aw - 1 else ls[i]
138
+ stmt.append(x)
139
+ depth += sum(x.count(c) for c in "({[") - sum(x.count(c) for c in ")}]")
140
+ if depth <= 0:
141
+ break
142
+ if not _input_dependent(re.sub(r"^\s*await\b", "", "\n".join(stmt))):
143
+ continue
144
+ # the written value must come from the awaited result: a name bound on the await line (or derived from one
145
+ # after it), or the await itself on the write line; a loading flag reset after the await is not a stale result
146
+ res_vars = set()
147
+ for t in ls[aw - 1:r["line"] - 1]:
148
+ m = DECL_RE.search(t) or re.search(r"^\s*(?:self\.)?([A-Za-z_$][\w$]*)\s*=(?!=)", t)
149
+ nm = m and (m.group(1) or (m.group(2) if m.lastindex and m.lastindex >= 2 else None))
150
+ if nm and (AWAIT_RE.search(t) or any(re.search(rf"(?<![\w$.]){re.escape(v)}\b", t.split("=", 1)[-1]) for v in res_vars)):
151
+ res_vars.add(nm)
152
+ wl = ls[r["line"] - 1]
153
+ if r["line"] - 1 == aw - 1:
154
+ pass # `results = await fetch()` on one line
155
+ elif not any(re.search(rf"(?<![\w$.]){re.escape(v)}\b", wl.split("=", 1)[-1] if "=" in wl else wl) for v in res_vars):
156
+ continue
157
+ if any(CMP_RE.search(t) and any(re.search(rf"(?<![\w$.]){re.escape(c)}\b", t) for c in captured)
158
+ for t in after.split("\n")):
159
+ continue
160
+ key = (r["src"], r["dst"], r["line"])
161
+ if key in seen:
162
+ continue
163
+ seen.add(key)
164
+ fa = fields.get(r["dst"], {})
165
+ ea = json.loads(r["attrs"] or "{}")
166
+ ui = bool(any(k in fa for k in UI_HINT) or ea.get("via") in ("setter", "value"))
167
+ if not ui and ea.get("receiver") not in (None, "this", "self", "$this"):
168
+ continue # `doc.x = …` on an object the function just fetched: not shared or UI state
169
+ out.append({"rule": "stale-async-result", "confidence": "heuristic", "writer": r["fqn"] or r["src"],
170
+ "state": r["dst"], "ui_state": ui, "async": kind, "async_at": f"{r['file']}:{o}",
171
+ "await_at": f"{r['file']}:{aw}", "write_at": f"{r['file']}:{r['line']}",
172
+ "await_code": ls[aw - 1].strip()[:120], "write_code": ls[r["line"] - 1].strip()[:120]})
173
+ if len(out) >= limit:
174
+ break
175
+ if not include_tests:
176
+ out = [f for f in out if not TEST_PATH_RE.search(f["write_at"])]
177
+ return out
178
+
179
+
180
+ LIFECYCLE_RE = re.compile(r"\.onAppear\b|\bonAppear\s*\(|\.task\s*[({]|\bonMounted\s*\(|\bmounted\s*\(|\bLaunchedEffect\s*\(|"
181
+ r"\buse(?:Layout)?Effect\s*\(|\bviewDidLoad\b|\bviewWillAppear\b|\bonCreate\b|\bonStart\b|\binitState\b")
182
+ CALLBACK_RE = re.compile(r"\.sink\s*\{|\.onReceive\s*\(|\.subscribe\s*[({]|\.collect(?:Latest)?\s*\{|\.observe\w*\s*\(|"
183
+ r"\bcompletion(?:Handler)?\s*:\s*\{|\{\s*\[?\s*(?:weak|unowned)\s+self\s*\]?|\.then\s*\(|\.on\s*\(\s*['\"]")
184
+ LITERAL_RHS_RE = re.compile(r"(?<![=!<>])=\s*(?:\[\s*\]|\[:\]|\{\s*\}|nil|null|None|undefined|0(?:\.0+)?[fFdDL]?|false|False|true|True|"
185
+ r"''|\"\"|emptyList\(\)|emptyMap\(\)|mutableListOf\(\)|\.init\(\)|\w+\(\)|\.\w+)\s*;?\s*$")
186
+ LITERAL_ARG_RE = re.compile(r"\(\s*(?:nil|null|None|undefined|true|false|True|False|-?\d+(?:\.\d+)?|'[^']*'|\"[^\"]*\"|\[\s*\]|\{\s*\})\s*\)\s*;?\s*$")
187
+ INIT_NAMES = {"init", "__init__", "constructor", "<init>", "viewDidLoad", "onCreate", "initState", "mounted", "created", "setup"}
188
+
189
+
190
+ def _rhs(t: str) -> str | None:
191
+ """Right-hand side of an assignment (comments dropped), or the argument of a `setX(...)` call."""
192
+ t = re.sub(r"\s(?://|#).*$", "", t).strip().rstrip(";")
193
+ m = re.search(r"(?<![=!<>])=(?!=)\s*(.+)$", t) or re.search(r"\bset[A-Z]\w*\s*\((.*)\)\s*$", t)
194
+ return m.group(1).strip() if m else None
195
+
196
+
197
+ def _lifecycle(st, ls, r, node) -> str | None:
198
+ """Is this write lifecycle code: an initializer with a real (non-default) value, or code directly inside
199
+ onAppear / .task (before any await) / onMounted / LaunchedEffect / useEffect / viewDidLoad?"""
200
+ t = ls[r["line"] - 1]
201
+ if AWAIT_RE.search(t):
202
+ return None
203
+ nm = ((node["name"] if node is not None else "") or "").split(".")[-1]
204
+ if nm in INIT_NAMES or (node is not None and node["kind"] == "class"):
205
+ # constructor injection (`self.x = x`, `_x = State(initialValue: x)`, `this.y = AppState.currentState`)
206
+ # seeds the state; only a computed / loaded value competes with a later async one
207
+ rhs = _rhs(t)
208
+ if rhs is None or not re.search(r"\w\s*\(", rhs) or re.match(r"(?:State|Published|StateObject|ObservedObject|"
209
+ r"Binding)\s*\(\s*(?:initialValue|wrappedValue)\s*:", rhs):
210
+ return None
211
+ return f"initializer ({nm or 'class'})"
212
+ lo = max(1, r["line"] - 6)
213
+ for i in range(r["line"], lo - 1, -1):
214
+ x = ls[i - 1]
215
+ if i < r["line"] and AWAIT_RE.search(x):
216
+ return None
217
+ m = LIFECYCLE_RE.search(x)
218
+ if m:
219
+ return m.group(0).strip(" .({")
220
+ return None
221
+
222
+
223
+ def _async_callback(ls, r, lo) -> str | None:
224
+ """Is this write inside async code after an await, or inside a completion / subscription callback?"""
225
+ op = _opener(ls, r["line"], lo)
226
+ if op is not None:
227
+ body = ls[op[1] - 1:r["line"] - 1]
228
+ if any(AWAIT_RE.search(x) for x in body[1:]) or AWAIT_RE.search(body[0][body[0].find("{") + 1:] if body else ""):
229
+ return f"after await in {op[0]}"
230
+ for i in range(r["line"] - 1, max(lo, r["line"] - 8) - 1, -1):
231
+ m = CALLBACK_RE.search(ls[i - 1]) if i >= 1 else None
232
+ if m:
233
+ return f"callback {m.group(0).strip()[:24]}"
234
+ return None
235
+
236
+
237
+ def two_writers(st: GraphStore, include_tests: bool = False) -> list[dict]:
238
+ root = st.meta().get("root")
239
+ cache: dict = {}
240
+ rows = st.q("""SELECT e.src, e.dst, e.file, e.line, e.attrs, n.line AS fl FROM edges e JOIN nodes n ON n.id = e.src
241
+ WHERE e.kind = 'WRITES_PROP' ORDER BY e.dst, e.file, e.line""")
242
+ by: dict = {}
243
+ for r in rows:
244
+ if not r["file"] or not root or (not include_tests and TEST_PATH_RE.search(f"{r['file']}:")):
245
+ continue
246
+ ls = _lines(root, r["file"], cache)
247
+ if not ls or r["line"] > len(ls):
248
+ continue
249
+ # a default / flag reset (`= []`, `setLoading(true)`, `setError(null)`) or an in-place mutation is not a
250
+ # competing value: only real values written from both sides can make the UI jump
251
+ t = re.sub(r"\s//.*$", "", ls[r["line"] - 1])
252
+ if (LITERAL_RHS_RE.search(t) or LITERAL_ARG_RE.search(t) or re.search(r"&\s*[\w.]+|\.store\s*\(\s*in\s*:", t)
253
+ or json.loads(r["attrs"] or "{}").get("via") in ("mutating", "item")):
254
+ continue
255
+ node = st.node(r["src"])
256
+ d = by.setdefault(r["dst"], {"life": [], "async": []})
257
+ lc = _lifecycle(st, ls, r, node)
258
+ if lc:
259
+ d["life"].append({"at": f"{r['file']}:{r['line']}", "how": lc, "code": ls[r["line"] - 1].strip()[:110]})
260
+ continue
261
+ ac = _async_callback(ls, r, r["fl"] or 1)
262
+ if ac:
263
+ d["async"].append({"at": f"{r['file']}:{r['line']}", "how": ac, "code": ls[r["line"] - 1].strip()[:110]})
264
+ out = []
265
+ for fid, d in by.items():
266
+ # the same expression from both sides (`selectedID = ids.first` on appear and on change) is a refresh, not a race
267
+ life_rhs = {_rhs(w["code"]) for w in d["life"]}
268
+ short = fid.rsplit(".", 1)[-1].rsplit("#", 1)[-1]
269
+ # ... and an update derived from the state itself (`presets = presets.filter(...)`) does not compete either
270
+ d["async"] = [w for w in d["async"] if _rhs(w["code"]) not in life_rhs
271
+ and not re.search(rf"(?<![\w$]){re.escape(short)}\b", _rhs(w["code"]) or "")]
272
+ if d["life"] and d["async"]:
273
+ out.append({"rule": "two-writers", "confidence": "heuristic", "state": fid,
274
+ "lifecycle_writes": d["life"][:5], "async_writes": d["async"][:5]})
275
+ return out
276
+
277
+
278
+ # ---- rule incomplete-cache-key
279
+ CACHE_NAME = r"[\w$.]*(?:[cC]ache|[mM]emo\w*|lru\w*|LRU\w*)[\w$]*"
280
+ STORE_RES = (
281
+ re.compile(rf"(?P<c>{CACHE_NAME})\s*\.\s*(?:set|put|store|setItem|setObject|setValue)\s*\((?P<args>.*)\)"),
282
+ re.compile(rf"(?P<c>{CACHE_NAME})\s*\[(?P<key>[^\]]+)\]\s*=(?!=)\s*(?P<val>.+)"),
283
+ )
284
+ WORD_RE = re.compile(r"(?<![\w$.])([A-Za-z_$][\w$]*)")
285
+ SKIP_IDS = KEYWORDS | {"let", "var", "val", "const", "if", "else", "for", "while", "guard", "return", "self", "this",
286
+ "String", "Int", "Double", "Float", "Bool", "JSON", "Math", "Object", "Array", "str", "int", "len",
287
+ "f", "fun", "func", "def", "await", "try", "async"}
288
+
289
+
290
+ PAYLOAD_RE = re.compile(r"(?:data|response|resp|res|result|value|payload|body|json|item|entry|element|model|obj|object)")
291
+
292
+
293
+ def _split_args(s: str) -> list[str]:
294
+ out, depth, cur = [], 0, ""
295
+ for ch in s:
296
+ if ch in "([{":
297
+ depth += 1
298
+ elif ch in ")]}":
299
+ if depth == 0:
300
+ break
301
+ depth -= 1
302
+ if ch == "," and depth == 0:
303
+ out.append(cur); cur = ""
304
+ continue
305
+ cur += ch
306
+ if cur.strip():
307
+ out.append(cur)
308
+ return out
309
+
310
+
311
+ INTERP_RE = re.compile(r"\$\{([^}]*)\}|\\\(([^)]*)\)|\$([A-Za-z_]\w*)")
312
+
313
+
314
+ def _strip_strings(expr: str) -> str:
315
+ """Drop string literals but keep what they interpolate (`${id}`, `\\(id)`, `$id`, f-string `{id}`)."""
316
+ def keep(m):
317
+ body = m.group(0)
318
+ inner = [g for x in INTERP_RE.finditer(body) for g in x.groups()[:3] if g]
319
+ if body[:1] == "f":
320
+ inner = re.findall(r"\{([^{}]*)\}", body)
321
+ return " (" + ", ".join(inner) + ") " if inner else "''"
322
+ return re.sub(r"""f?(['"`])(?:\\.|(?!\1).)*\1""", keep, expr)
323
+
324
+
325
+ def _idents(expr: str) -> set[str]:
326
+ e = _strip_strings(expr)
327
+ e = re.sub(r"(?<![:\w])([A-Za-z_$][\w$]*)\s*:(?!:)", " ", e) # labels / keys
328
+ e = re.sub(r"(?:\$this\s*->|\b(?:this|self)\s*\.)\s*([A-Za-z_$][\w$]*)", r" self_\1 ", e) # instance state: `self_x`
329
+ e = e.replace(".$", ". $")
330
+ e = re.sub(r"(?<![\w$])[A-Za-z_$][\w$]*\s*(?:<[^<>]*>)?\s*\(", " (", e) # call names
331
+ e = re.sub(r"(?:\.|->|::)\s*[A-Za-z_][\w$]*", " ", e) # other member names (`.$x` is PHP concat)
332
+ return {w for w in WORD_RE.findall(e) if w not in SKIP_IDS and not w[0].isupper()}
333
+
334
+
335
+ def _expand(ids: set[str], assigns: dict, depth: int = 0) -> set[str]:
336
+ """Identifiers a value depends on, through locals assigned earlier in the function."""
337
+ out = set()
338
+ for i in ids:
339
+ if i in assigns and depth < 3:
340
+ out |= _expand(assigns[i], assigns, depth + 1) or {i}
341
+ else:
342
+ out.add(i)
343
+ return out
344
+
345
+
346
+ def incomplete_cache_key(st: GraphStore, include_tests: bool = False) -> list[dict]:
347
+ """A cache store whose key leaves out a parameter or instance field that the cached value's computation uses
348
+ (within one function): `cache[userId] = render(userId, theme)` serves one theme's render for another."""
349
+ root = st.meta().get("root")
350
+ cache: dict = {}
351
+ out = []
352
+ fns = st.q("""SELECT id, fqn, file, line, end_line FROM nodes WHERE kind IN ('function','method')
353
+ AND file IS NOT NULL AND end_line > line ORDER BY file, line""")
354
+ for fn in fns:
355
+ if not root or (not include_tests and TEST_PATH_RE.search(f"{fn['file']}:")):
356
+ continue
357
+ ls = _lines(root, fn["file"], cache)
358
+ body = ls[fn["line"] - 1:fn["end_line"]]
359
+ if not any(re.search(CACHE_NAME, x) for x in body):
360
+ continue
361
+ sig = "\n".join(body[:3])
362
+ sig = sig[sig.find("("):] if "(" in sig else ""
363
+ params = _idents(_split_args(sig[1:]) and ",".join(re.sub(r"[:=].*", "", a, flags=re.S) for a in _split_args(sig[1:])) or "")
364
+ assigns: dict = {}
365
+ for off, t in enumerate(body):
366
+ m = re.search(r"\b(?:let|var|val|const)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=(?!=)(.*)|^\s*([a-z_$][\w$]*)\s*=(?!=)(.*)", t)
367
+ if m:
368
+ assigns[m.group(1) or m.group(3)] = _idents(m.group(2) or m.group(4) or "")
369
+ for rx in STORE_RES:
370
+ s = rx.search(t)
371
+ if not s or not re.fullmatch(CACHE_NAME, s.group("c").split(".")[-1] or ""):
372
+ continue
373
+ if "args" in s.groupdict() and s.group("args") is not None:
374
+ a = _split_args(s.group("args"))
375
+ if len(a) < 2:
376
+ continue
377
+ if re.search(r"forKey\s*:", s.group("args")): # setObject(v, forKey: k)
378
+ key = next(x for x in a if "forKey" in x)
379
+ val = next(x for x in a if "forKey" not in x)
380
+ else:
381
+ key, val = a[0], a[1]
382
+ else:
383
+ key, val = s.group("key"), s.group("val")
384
+ vb = val.strip().rstrip(";")
385
+ if re.fullmatch(r"[A-Za-z_$][\w$]*", vb) and vb not in assigns:
386
+ continue # `put(key, value)` passes a value through; nothing is computed here
387
+ kids = _expand(_idents(key), assigns)
388
+ vids = _expand(_idents(val), assigns) - {vb}
389
+ setter = re.match(r"(?:set|put|insert|store|save|cache|add|update|write|remember|record)(?![a-z])",
390
+ (fn["fqn"] or "").split(".")[-1].split("(")[0])
391
+ inputs = {i for i in vids if (i in params and not setter and not PAYLOAD_RE.fullmatch(i.lstrip("$")))
392
+ or i.startswith("self_")}
393
+ missing = sorted(i for i in inputs - kids if i.replace("self_", "") not in {k.replace("self_", "") for k in kids})
394
+ src = "\n".join(body[:off + 1])
395
+ missing = [m for m in missing if not any(re.search( # `xs[i]` / `xs.getOrNull(i)` keyed by i
396
+ rf"{re.escape(m)}\s*(?:\[\s*{re.escape(k)}\s*\]|\??\.\s*(?:get\w*|elementAt\w*|at|item)\s*\(\s*{re.escape(k)}\b)",
397
+ src) for k in kids)]
398
+ kin = {i for i in kids if i in params or i.startswith("self_")}
399
+ if missing and (kin or kids):
400
+ ln = fn["line"] + off
401
+ out.append({"rule": "incomplete-cache-key", "confidence": "heuristic", "function": fn["fqn"] or fn["id"],
402
+ "at": f"{fn['file']}:{ln}", "cache": s.group("c")[-40:], "key": key.strip()[:80],
403
+ "key_uses": sorted(kids)[:8], "value_uses": sorted(inputs)[:8],
404
+ "missing": [m.replace("self_", "self.") for m in missing], "code": t.strip()[:120]})
405
+ return out
406
+
407
+
408
+ # ---- rule echo-suppression
409
+ GUARD_FIELD_RE = re.compile(r"(?:last(?:Written|Sent|Saved|Synced|Applied|Pushed|Emitted|Published|Local)\w*|"
410
+ r"isUpdating(?:From|Programmatic|Internal|Selection|Text|Value)\w*|is(?:Applying|Programmatic|Syncing|Internal|Setting|Restoring)\w*|"
411
+ r"(?:suppress|ignoreNext|skipNext|muteNext|ignoring|suppressing)\w*)", re.I)
412
+ EARLY_RE = re.compile(r"\b(?:return|continue|break)\b|\bguard\b.*\belse\b")
413
+
414
+
415
+ def _owner(fid: str) -> str:
416
+ return fid.rsplit(".", 1)[0]
417
+
418
+
419
+ def echo_suppression(st: GraphStore, include_tests: bool = False, patterns: list | None = None) -> list[dict]:
420
+ """Echo suppression: a guard field (`isApplyingRemote`, `lastSentText`, `suppressNextChange`) is set around
421
+ programmatic writes of some state so the change observer, which checks the guard and returns early, does not
422
+ echo them back. Reports state of that pattern that is also written from an async path / callback *without*
423
+ setting the guard: that write reaches the observer and is sent back (a loop or a clobbered edit)."""
424
+ root = st.meta().get("root")
425
+ cache: dict = {}
426
+ guards = [r["id"] for r in st.q("SELECT id, name FROM nodes WHERE kind = 'field'") if GUARD_FIELD_RE.fullmatch((r["name"] or "").rsplit(".", 1)[-1].replace("_", ""))]
427
+ out = []
428
+
429
+ def keep(r):
430
+ return r["file"] and root and (include_tests or not TEST_PATH_RE.search(f"{r['file']}:"))
431
+
432
+ for g in guards:
433
+ name = g.rsplit(".", 1)[-1]
434
+ sites = []
435
+ for r in st.q("SELECT src, file, line FROM edges WHERE kind = 'READS_PROP' AND dst = ?", (g,)):
436
+ if not keep(r):
437
+ continue
438
+ ls = _lines(root, r["file"], cache)
439
+ if not ls or r["line"] > len(ls):
440
+ continue
441
+ t = ls[r["line"] - 1]
442
+ if re.search(r"\b(?:if|guard|unless|when)\b", t) and EARLY_RE.search("\n".join(ls[r["line"] - 1:r["line"] + 2])):
443
+ sites.append({"at": f"{r['file']}:{r['line']}", "fn": r["src"], "code": t.strip()[:110]})
444
+ if not sites:
445
+ if patterns is not None:
446
+ patterns.append((g, None, 0, 0, 0))
447
+ continue
448
+ gw = []
449
+ for r in st.q("SELECT src, file, line FROM edges WHERE kind = 'WRITES_PROP' AND dst = ?", (g,)):
450
+ ls = _lines(root, r["file"], cache) if keep(r) else None
451
+ # the guard's default / reset (`= false`, `= None`) does not suppress anything; setting it (or recording
452
+ # the value about to be sent) does
453
+ if ls and r["line"] <= len(ls) and not re.search(r"=\s*(?:false|False|nil|None|null|undefined|0)\s*;?\s*$",
454
+ ls[r["line"] - 1]):
455
+ gw.append(r)
456
+ setters = {r["src"] for r in gw}
457
+ observers = {s["fn"] for s in sites}
458
+ targets: dict = {}
459
+ for r in gw:
460
+ for w in st.q("""SELECT dst, line FROM edges WHERE kind = 'WRITES_PROP' AND src = ? AND file = ?
461
+ AND line BETWEEN ? AND ?""", (r["src"], r["file"], r["line"] - 4, r["line"] + 4)):
462
+ if w["dst"] != g and _owner(w["dst"]) == _owner(g) and not GUARD_FIELD_RE.fullmatch(w["dst"].rsplit(".", 1)[-1].replace("_", "")):
463
+ targets.setdefault(w["dst"], []).append(f"{r['file']}:{w['line']}")
464
+ for t, sup in targets.items():
465
+ bad = []
466
+ for r in st.q("SELECT e.src, e.file, e.line, n.line AS fl FROM edges e JOIN nodes n ON n.id = e.src "
467
+ "WHERE e.kind = 'WRITES_PROP' AND e.dst = ?", (t,)):
468
+ if not keep(r) or r["src"] in setters or r["src"] in observers:
469
+ continue
470
+ ls = _lines(root, r["file"], cache)
471
+ if not ls or r["line"] > len(ls):
472
+ continue
473
+ how = _async_callback(ls, r, r["fl"] or 1)
474
+ if how:
475
+ bad.append({"at": f"{r['file']}:{r['line']}", "how": how, "code": ls[r["line"] - 1].strip()[:110]})
476
+ if patterns is not None:
477
+ patterns.append((g, t, len(sites), len(sup), len(bad)))
478
+ if bad:
479
+ out.append({"rule": "echo-suppression", "confidence": "heuristic", "guard": g, "state": t,
480
+ "guard_sites": sites[:3], "suppressed_writes": sorted(set(sup))[:5], "unsuppressed_writes": bad[:5]})
481
+ return out
482
+
483
+
484
+ RULES = {"stale-async-result": stale_async, "two-writers": two_writers, "incomplete-cache-key": incomplete_cache_key,
485
+ "echo-suppression": echo_suppression}
486
+
487
+
488
+ def lint(st: GraphStore, include_tests: bool = False, rules: list[str] | None = None) -> dict:
489
+ run = [r for r in RULES if rules is None or r in rules]
490
+ unknown = sorted(set(rules or []) - set(RULES))
491
+ if unknown:
492
+ raise ValueError(f"unknown rule(s) {', '.join(unknown)}; known: {', '.join(RULES)}")
493
+ f = [x for r in run for x in RULES[r](st, include_tests)]
494
+ return {"lint": "async-state", "confidence": "heuristic", "rules": run, "not_implemented": [], "findings": f}
495
+
496
+
497
+ def render(res: dict) -> str:
498
+ fs = res["findings"]
499
+ nyi = f" (not yet: {', '.join(res['not_implemented'])})" if res.get("not_implemented") else ""
500
+ out = [f"lint async-state — heuristic; rules: {', '.join(res['rules'])}{nyi}; {len(fs)} findings"]
501
+ for f in fs:
502
+ if f["rule"] == "echo-suppression":
503
+ out.append(f" [echo-suppression] {f['state']}: written with guard {f['guard'].rsplit('.', 1)[-1]} set at "
504
+ f"{', '.join(f['suppressed_writes'][:2])} (checked at {f['guard_sites'][0]['at']}), but not at:")
505
+ for w in f["unsuppressed_writes"]:
506
+ out.append(f" {w['at']} ({w['how']}): {w['code']}")
507
+ continue
508
+ if f["rule"] == "incomplete-cache-key":
509
+ out.append(f" [incomplete-cache-key] {f['function']} @{f['at']}: {f['cache']} key `{f['key']}` leaves out "
510
+ f"{', '.join(f['missing'])} (the value uses {', '.join(f['value_uses'])})")
511
+ out.append(f" {f['code']}")
512
+ continue
513
+ if f["rule"] == "two-writers":
514
+ out.append(f" [two-writers] {f['state']}: written by lifecycle code and by an async callback")
515
+ for w in f["lifecycle_writes"]:
516
+ out.append(f" lifecycle {w['how']} @{w['at']}: {w['code']}")
517
+ for w in f["async_writes"]:
518
+ out.append(f" async {w['how']} @{w['at']}: {w['code']}")
519
+ continue
520
+ out.append(f" [stale-async-result{' ui' if f['ui_state'] else ''}] {f['writer']} writes {f['state']} @{f['write_at']} "
521
+ f"after await @{f['await_at']} in {f['async']} @{f['async_at']}; no cancellation / token check")
522
+ out.append(f" await: {f['await_code']}")
523
+ out.append(f" write: {f['write_code']}")
524
+ return "\n".join(out)