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.
- cg_code_graph-0.10.1.dist-info/METADATA +678 -0
- cg_code_graph-0.10.1.dist-info/RECORD +174 -0
- cg_code_graph-0.10.1.dist-info/WHEEL +5 -0
- cg_code_graph-0.10.1.dist-info/entry_points.txt +3 -0
- cg_code_graph-0.10.1.dist-info/licenses/LICENSE +21 -0
- cg_code_graph-0.10.1.dist-info/top_level.txt +1 -0
- codegraph/__init__.py +2 -0
- codegraph/aitools.py +129 -0
- codegraph/apps.py +76 -0
- codegraph/blindspots.py +428 -0
- codegraph/bridges.py +1701 -0
- codegraph/cli.py +725 -0
- codegraph/concepts.py +362 -0
- codegraph/config.py +559 -0
- codegraph/core/__init__.py +0 -0
- codegraph/core/cache.py +375 -0
- codegraph/core/detect.py +80 -0
- codegraph/core/extractors.py +187 -0
- codegraph/core/fsutil.py +61 -0
- codegraph/core/generated.py +575 -0
- codegraph/core/model.py +174 -0
- codegraph/core/paths.py +175 -0
- codegraph/core/plugin.py +160 -0
- codegraph/core/store.py +80 -0
- codegraph/core/syntax_errors.py +132 -0
- codegraph/coverage.py +928 -0
- codegraph/doctor.py +453 -0
- codegraph/external.py +613 -0
- codegraph/indexer.py +336 -0
- codegraph/link.py +434 -0
- codegraph/lint_async.py +524 -0
- codegraph/mcp_server.py +1303 -0
- codegraph/parity.py +473 -0
- codegraph/parity_structure.py +307 -0
- codegraph/payload.py +321 -0
- codegraph/plans.py +1285 -0
- codegraph/platform_scan.py +643 -0
- codegraph/platforms.py +1369 -0
- codegraph/plugins/__init__.py +0 -0
- codegraph/plugins/cfamily/__init__.py +0 -0
- codegraph/plugins/cfamily/plugin.py +930 -0
- codegraph/plugins/cfamily/syntax.py +881 -0
- codegraph/plugins/dart/__init__.py +0 -0
- codegraph/plugins/dart/bridges.py +345 -0
- codegraph/plugins/dart/extractor/bin/extract.dart +717 -0
- codegraph/plugins/dart/extractor/pubspec.lock +149 -0
- codegraph/plugins/dart/extractor/pubspec.yaml +7 -0
- codegraph/plugins/dart/http.py +904 -0
- codegraph/plugins/dart/models.py +308 -0
- codegraph/plugins/dart/plugin.py +625 -0
- codegraph/plugins/dart/program.py +907 -0
- codegraph/plugins/django/__init__.py +0 -0
- codegraph/plugins/django/extras.py +378 -0
- codegraph/plugins/django/models.py +508 -0
- codegraph/plugins/django/plugin.py +728 -0
- codegraph/plugins/django/schemas.py +339 -0
- codegraph/plugins/django/shapes.py +216 -0
- codegraph/plugins/django/urls.py +603 -0
- codegraph/plugins/express/__init__.py +0 -0
- codegraph/plugins/express/plugin.py +428 -0
- codegraph/plugins/flutter/__init__.py +0 -0
- codegraph/plugins/flutter/plugin.py +538 -0
- codegraph/plugins/kotlin/__init__.py +0 -0
- codegraph/plugins/kotlin/exact.py +457 -0
- codegraph/plugins/kotlin/plugin.py +1961 -0
- codegraph/plugins/kotlin/reparse.py +234 -0
- codegraph/plugins/laravel/__init__.py +0 -0
- codegraph/plugins/laravel/broadcast.py +351 -0
- codegraph/plugins/laravel/plugin.py +863 -0
- codegraph/plugins/laravel/tests.py +262 -0
- codegraph/plugins/laravel/values.py +728 -0
- codegraph/plugins/native/__init__.py +0 -0
- codegraph/plugins/native/gates.py +286 -0
- codegraph/plugins/native/runner.py +183 -0
- codegraph/plugins/native/scipread.py +194 -0
- codegraph/plugins/native/ts.py +54 -0
- codegraph/plugins/nest/__init__.py +0 -0
- codegraph/plugins/nest/plugin.py +654 -0
- codegraph/plugins/nextjs/__init__.py +0 -0
- codegraph/plugins/nextjs/plugin.py +336 -0
- codegraph/plugins/nuxt/__init__.py +0 -0
- codegraph/plugins/nuxt/plugin.py +308 -0
- codegraph/plugins/php/__init__.py +0 -0
- codegraph/plugins/php/extractor/composer.json +5 -0
- codegraph/plugins/php/extractor/composer.lock +76 -0
- codegraph/plugins/php/extractor/extract.php +743 -0
- codegraph/plugins/php/gating.py +573 -0
- codegraph/plugins/php/plugin.py +668 -0
- codegraph/plugins/php/strings.py +197 -0
- codegraph/plugins/python/__init__.py +0 -0
- codegraph/plugins/python/aitools.py +664 -0
- codegraph/plugins/python/external.py +245 -0
- codegraph/plugins/python/fields.py +107 -0
- codegraph/plugins/python/plugin.py +1733 -0
- codegraph/plugins/python/refs.py +485 -0
- codegraph/plugins/python/roots.py +412 -0
- codegraph/plugins/python/socketio.py +210 -0
- codegraph/plugins/python/subproc.py +864 -0
- codegraph/plugins/python/tests.py +1040 -0
- codegraph/plugins/python/values.py +179 -0
- codegraph/plugins/pyweb/__init__.py +0 -0
- codegraph/plugins/pyweb/plugin.py +1334 -0
- codegraph/plugins/pyweb/values.py +68 -0
- codegraph/plugins/rust/__init__.py +0 -0
- codegraph/plugins/rust/cargo.py +226 -0
- codegraph/plugins/rust/plugin.py +980 -0
- codegraph/plugins/rust/syntax.py +678 -0
- codegraph/plugins/scip/__init__.py +0 -0
- codegraph/plugins/scip/importer.py +129 -0
- codegraph/plugins/scip/scip.proto +962 -0
- codegraph/plugins/scip/scip_pb2.py +97 -0
- codegraph/plugins/stubs/__init__.py +0 -0
- codegraph/plugins/stubs/plugins.py +38 -0
- codegraph/plugins/swift/__init__.py +0 -0
- codegraph/plugins/swift/baseurl.py +109 -0
- codegraph/plugins/swift/exact.py +415 -0
- codegraph/plugins/swift/indexstore.py +209 -0
- codegraph/plugins/swift/packages.py +174 -0
- codegraph/plugins/swift/plugin.py +2890 -0
- codegraph/plugins/ts/__init__.py +0 -0
- codegraph/plugins/ts/baseurl.py +185 -0
- codegraph/plugins/ts/extractor/extract.mjs +2652 -0
- codegraph/plugins/ts/extractor/fw.mjs +685 -0
- codegraph/plugins/ts/extractor/package-lock.json +205 -0
- codegraph/plugins/ts/extractor/package.json +9 -0
- codegraph/plugins/ts/plugin.py +480 -0
- codegraph/plugins/tsweb/__init__.py +0 -0
- codegraph/plugins/tsweb/common.py +290 -0
- codegraph/plugins/tsweb/data.py +276 -0
- codegraph/presets/__init__.py +146 -0
- codegraph/presets/c_cpp.yaml +9 -0
- codegraph/presets/common.yaml +66 -0
- codegraph/presets/dart.yaml +9 -0
- codegraph/presets/django-ninja.yaml +15 -0
- codegraph/presets/django.yaml +25 -0
- codegraph/presets/djangorestframework.yaml +17 -0
- codegraph/presets/express.yaml +17 -0
- codegraph/presets/kotlin.yaml +11 -0
- codegraph/presets/laravel.yaml +40 -0
- codegraph/presets/nest.yaml +11 -0
- codegraph/presets/nextjs.yaml +15 -0
- codegraph/presets/nuxt.yaml +9 -0
- codegraph/presets/php.yaml +5 -0
- codegraph/presets/python.yaml +10 -0
- codegraph/presets/rust.yaml +5 -0
- codegraph/presets/swift.yaml +10 -0
- codegraph/presets/typescript.yaml +13 -0
- codegraph/process_runs.py +328 -0
- codegraph/protocols/__init__.py +299 -0
- codegraph/protocols/builtin.py +67 -0
- codegraph/protocols/matchers.py +144 -0
- codegraph/protocols/view.py +334 -0
- codegraph/query.py +2089 -0
- codegraph/realtime.py +260 -0
- codegraph/roundtrip.py +346 -0
- codegraph/routes.py +442 -0
- codegraph/starters.py +218 -0
- codegraph/tests_index.py +117 -0
- codegraph/viz/__init__.py +0 -0
- codegraph/viz/graph.py +369 -0
- codegraph/viz/server.py +198 -0
- codegraph/viz/static/app.css +148 -0
- codegraph/viz/static/app.js +1082 -0
- codegraph/viz/static/index.html +81 -0
- codegraph/viz/static/layered.js +237 -0
- codegraph/viz/static/vendor/VERSIONS.txt +4 -0
- codegraph/viz/static/vendor/cose-base.js +3214 -0
- codegraph/viz/static/vendor/cytoscape-fcose.js +1549 -0
- codegraph/viz/static/vendor/cytoscape.min.js +31 -0
- codegraph/viz/static/vendor/layout-base.js +5230 -0
- codegraph/viz/tools/package-lock.json +303 -0
- codegraph/viz/tools/package.json +7 -0
- codegraph/viz/tools/shoot.mjs +165 -0
- codegraph/xcode.py +251 -0
codegraph/lint_async.py
ADDED
|
@@ -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)
|