seamcheck 0.1.0__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 (52) hide show
  1. seamcheck/AGENTS.md +96 -0
  2. seamcheck/__init__.py +0 -0
  3. seamcheck/api.py +315 -0
  4. seamcheck/apps.py +21 -0
  5. seamcheck/attribution.py +45 -0
  6. seamcheck/classifier.py +116 -0
  7. seamcheck/console.py +150 -0
  8. seamcheck/coverage.py +38 -0
  9. seamcheck/css_tools/parse_css.bundle.mjs +6192 -0
  10. seamcheck/css_tools/parse_css.mjs +41 -0
  11. seamcheck/diff.py +79 -0
  12. seamcheck/dom_matcher.py +145 -0
  13. seamcheck/extractors/__init__.py +0 -0
  14. seamcheck/extractors/asgi_extractor.py +79 -0
  15. seamcheck/extractors/css_extractor.py +139 -0
  16. seamcheck/extractors/django_extractor.py +106 -0
  17. seamcheck/extractors/django_models_extractor.py +77 -0
  18. seamcheck/extractors/dom_js_extractor.py +337 -0
  19. seamcheck/extractors/entry_points_extractor.py +114 -0
  20. seamcheck/extractors/js_extractor.py +378 -0
  21. seamcheck/extractors/reachability.py +96 -0
  22. seamcheck/extractors/template_scanner.py +84 -0
  23. seamcheck/field_matcher.py +206 -0
  24. seamcheck/filetree.py +111 -0
  25. seamcheck/graph.py +124 -0
  26. seamcheck/history.py +289 -0
  27. seamcheck/js_tools/parse_js.bundle.mjs +5679 -0
  28. seamcheck/js_tools/parse_js.mjs +34 -0
  29. seamcheck/management/__init__.py +0 -0
  30. seamcheck/management/commands/__init__.py +0 -0
  31. seamcheck/management/commands/seamcheck.py +256 -0
  32. seamcheck/mapdata.py +274 -0
  33. seamcheck/matcher.py +84 -0
  34. seamcheck/mcp_server.py +37 -0
  35. seamcheck/nodetools.py +56 -0
  36. seamcheck/pagenames.py +143 -0
  37. seamcheck/pipeline.py +185 -0
  38. seamcheck/renderers/__init__.py +0 -0
  39. seamcheck/renderers/_shared.py +29 -0
  40. seamcheck/renderers/html.py +182 -0
  41. seamcheck/renderers/map_html.py +1115 -0
  42. seamcheck/renderers/markdown.py +73 -0
  43. seamcheck/renderers/terminal.py +61 -0
  44. seamcheck/report.py +167 -0
  45. seamcheck/roots.py +91 -0
  46. seamcheck/serve.py +106 -0
  47. seamcheck/snapshot.py +37 -0
  48. seamcheck/triage.py +126 -0
  49. seamcheck-0.1.0.dist-info/METADATA +170 -0
  50. seamcheck-0.1.0.dist-info/RECORD +52 -0
  51. seamcheck-0.1.0.dist-info/WHEEL +4 -0
  52. seamcheck-0.1.0.dist-info/licenses/LICENSE +21 -0
seamcheck/AGENTS.md ADDED
@@ -0,0 +1,96 @@
1
+ # Seamcheck — instructions for an AI agent
2
+
3
+ Seamcheck is a static-analysis tool that maps what a Django + JavaScript project connects
4
+ to what: which URLs exist, which views they route to, which `fetch()` calls resolve to
5
+ them, which DOM elements the JavaScript writes, and which CSS rules and design tokens
6
+ anything still references.
7
+
8
+ You are likely reading this because someone wants you to use it before committing, or to
9
+ act on something it reported. Read the four statuses first — the whole tool turns on them.
10
+
11
+ ## The four statuses, and the one that matters most
12
+
13
+ | Status | Means | What you should do |
14
+ |---|---|---|
15
+ | `connected` | Something reaches it, and the evidence is attached | Nothing |
16
+ | `unused` | **Both** sides of the contract are observable and nothing uses it | Safe to investigate for removal |
17
+ | `unresolved` | Something reaches for it and it does not exist | Usually a real bug — fix it |
18
+ | `uncertain` | The scan found **no evidence either way** | **Do not act on it** |
19
+
20
+ **`uncertain` is not a soft `unused`.** It means the scan cannot see the evidence that
21
+ would settle the question — a page URL reached by `<a href>`, a class applied by code the
22
+ extractor does not read. Deleting something because it is `uncertain` is the single most
23
+ damaging thing you can do with this tool's output. If a human asks you to "clean up
24
+ everything Seamcheck found", ask them whether they mean the `unused` and `unresolved`
25
+ findings, and say plainly that `uncertain` is not a finding.
26
+
27
+ Every symbol carries the file, the line, and the snippet that produced it. **Quote that
28
+ evidence when you report a finding.** A claim without its snippet is not actionable.
29
+
30
+ ## Commands
31
+
32
+ ```bash
33
+ python manage.py seamcheck # scan + human summary
34
+ python manage.py seamcheck --check # exit 1 on blocking findings
35
+ python manage.py seamcheck --check --format markdown # digest AND exit code
36
+ python manage.py seamcheck --format markdown # digest for a chat or PR
37
+ python manage.py seamcheck --format map # the UI: the map plus the
38
+ # review sections, one file
39
+ python manage.py seamcheck --format map --since REF # what changed
40
+ python manage.py seamcheck --format map --serve # open it from a phone on this wifi
41
+ python manage.py seamcheck --format map --serve --tunnel # ... or off it
42
+ python manage.py seamcheck --backfill 20 # scan the last 20 commits, so the
43
+ # map's commit picker has history
44
+ python manage.py seamcheck --backfill 20 --backfill-ref development
45
+ python manage.py seamcheck --format json # the whole graph
46
+ python manage.py seamcheck --explain <symbol-id> # one symbol's evidence
47
+ python manage.py seamcheck --triage <symbol-id> --status approved --reason "..."
48
+ ```
49
+
50
+ A full scan takes roughly 15 seconds. `--check` is the CI gate: it exits 1 only on
51
+ `unresolved`/`unused` findings that are untriaged or explicitly confirmed, and never on
52
+ `uncertain`.
53
+
54
+ ## Over MCP
55
+
56
+ If the MCP server is attached, prefer these over shelling out:
57
+
58
+ - `seamcheck_check(repo_root)` — `{passed, new_unresolved, new_unused, triage_invalidated, counts}`
59
+ - `seamcheck_report(fmt, repo_root)` — the rendered digest (`terminal`, `markdown`, `html`)
60
+ - `seamcheck_explain(symbol_id, repo_root)` — one symbol's evidence as markdown
61
+ - `seamcheck_triage(symbol_id, status, repo_root, reason)` — record a disposition
62
+
63
+ ## The workflow you are probably being asked to run
64
+
65
+ 1. `--check` before the commit. If it exits 0, say so in one line and stop.
66
+ 2. If it exits 1, `--explain` each reported id and decide, per finding:
67
+ - **a real defect** → fix it, quoting the evidence in your explanation
68
+ - **correct detection, deliberate code** → `--triage <id> --status approved --reason "..."`
69
+ - **the tool is wrong** → say so and why; that is a bug report worth making
70
+ 3. A `triage invalidated` line means a human dispositioned that finding earlier and the
71
+ underlying evidence has since changed. **Re-read the code before re-approving it.**
72
+
73
+ ## Known gaps — do not over-trust these
74
+
75
+ The tool is honest about what it cannot see, and so should you be:
76
+
77
+ - **CSS selectors reported `uncertain`** are not dead code. Class-applying JavaScript is
78
+ read (`className`, `classList`, `setAttribute`, markup strings), but coverage is not
79
+ proven complete.
80
+ - **Response fields** are matched one view against a whole consuming module, so a field can
81
+ be proven read but never proven unread.
82
+ - **WebSocket payloads, Celery tasks, Redis keys and Stripe hooks are not traced at all.**
83
+ Anything reached only through those is invisible to the scan.
84
+ - **A commit that changed no connection reports nothing.** The graph holds URLs, views,
85
+ calls, selectors and tokens - not values. A commit that adds an entry to a constant
86
+ correctly shows no change; that is not the picker failing.
87
+ - **Selectors and fetch targets built at runtime** are `uncertain` by design, never guessed.
88
+
89
+ ## Rules for you specifically
90
+
91
+ - Never delete code because a symbol is `uncertain`.
92
+ - Never report a finding without its `file:line` and snippet.
93
+ - Never claim the scan is clean because a command exited 0 — check that it actually ran and
94
+ what it counted.
95
+ - If you are asked to fix a multi-writer element, the fix is to pick one canonical owner and
96
+ route the others through it, not to patch the writer you happened to find first.
seamcheck/__init__.py ADDED
File without changes
seamcheck/api.py ADDED
@@ -0,0 +1,315 @@
1
+ """The one implementation the CLI, the MCP server, and any future UI all call."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ import glob
7
+ import os
8
+ import pathlib
9
+
10
+ from django.conf import settings
11
+
12
+ from seamcheck.diff import DiffResult, diff_graphs
13
+ from seamcheck.graph import Graph, Status, relativise
14
+ from seamcheck.pipeline import run_scan
15
+ from seamcheck.roots import discover_css_files, discover_js_roots, tailwind_classes
16
+ from seamcheck.snapshot import current_git_sha, load_snapshot, save_snapshot
17
+ from seamcheck.triage import (
18
+ TriageEntry,
19
+ TriageStatus,
20
+ apply_triage,
21
+ fingerprint_for_symbol,
22
+ has_blocking_findings,
23
+ load_triage,
24
+ save_triage,
25
+ )
26
+
27
+
28
+ def _config() -> dict:
29
+ return getattr(settings, "SEAMCHECK_CONFIG", {})
30
+
31
+
32
+ def _js_roots(config: dict, repo_root: str) -> tuple[list[str], str]:
33
+ if "js_entry_files" in config:
34
+ return list(config["js_entry_files"]), config.get("js_project_root", repo_root)
35
+ roots = discover_js_roots(
36
+ vite_config=os.path.join(repo_root, "vite.config.js"),
37
+ templates_root=os.path.join(repo_root, config["templates_root"]),
38
+ static_root=os.path.join(repo_root, "pointless", "static"),
39
+ )
40
+ return roots, repo_root
41
+
42
+
43
+ def _entry_point_files(config: dict, repo_root: str) -> set[str]:
44
+ if "entry_point_files" in config:
45
+ return set(config["entry_point_files"])
46
+ return {
47
+ os.path.abspath(path)
48
+ for prefix in config.get("first_party_prefixes", [])
49
+ for path in glob.glob(os.path.join(repo_root, prefix, "**", "*.py"), recursive=True)
50
+ if "migrations" not in path
51
+ }
52
+
53
+
54
+ def scan(repo_root: str = ".") -> Graph:
55
+ config = _config()
56
+ js_entry_files, js_project_root = _js_roots(config, repo_root)
57
+ asgi_module = config.get("asgi_module")
58
+ asgi_file = (
59
+ os.path.join(repo_root, asgi_module.replace(".", os.sep) + ".py") if asgi_module else None
60
+ )
61
+ templates_root = config.get("templates_root")
62
+ template_files = (
63
+ [str(p) for p in pathlib.Path(repo_root, templates_root).rglob("*.html")]
64
+ if templates_root and os.path.isdir(os.path.join(repo_root, templates_root))
65
+ else []
66
+ )
67
+ css_root = config.get("css_source_root")
68
+ css_files = (
69
+ discover_css_files(os.path.join(repo_root, css_root))
70
+ if css_root and os.path.isdir(os.path.join(repo_root, css_root))
71
+ else []
72
+ )
73
+ build_output = config.get("tailwind_build_output")
74
+
75
+ return relativise(run_scan(
76
+ urlconf_module=config["urlconf_module"],
77
+ js_entry_files=js_entry_files,
78
+ js_project_root=js_project_root,
79
+ entry_point_files=_entry_point_files(config, repo_root),
80
+ asgi_file=asgi_file if asgi_file and os.path.isfile(asgi_file) else None,
81
+ first_party_prefixes=config.get("first_party_prefixes"),
82
+ app_labels=[label.split(".")[0] for label in config.get("app_configs", [])] or None,
83
+ template_files=template_files,
84
+ css_files=css_files,
85
+ tailwind_build_classes=(
86
+ tailwind_classes(os.path.join(repo_root, build_output)) if build_output else set()
87
+ ),
88
+ ), repo_root)
89
+
90
+
91
+ def explain(graph: Graph, symbol_id: str) -> str:
92
+ symbol = next((s for s in graph.symbols if s.id == symbol_id), None)
93
+ if symbol is None:
94
+ return f"No symbol with id `{symbol_id}` in the current scan."
95
+ lines = [
96
+ f"## {symbol.label} ({symbol.kind})",
97
+ "",
98
+ f"- **status:** {symbol.status.value}",
99
+ f"- **where:** {symbol.file}:{symbol.line}" if symbol.file else "- **where:** (not a file)",
100
+ f"- **chain:** {' -> '.join(symbol.chain)}" if symbol.chain else "",
101
+ "",
102
+ "```",
103
+ symbol.snippet,
104
+ "```",
105
+ ]
106
+ if symbol.note:
107
+ lines += ["", f"> {symbol.note}"]
108
+ return "\n".join(line for line in lines if line != "")
109
+
110
+
111
+ def diff_against(graph: Graph, ref: str, repo_root: str = ".") -> tuple[DiffResult | None, str, str]:
112
+ """Diff `graph` against the snapshot for `ref`, or say plainly that there isn't one.
113
+
114
+ Returns `(result, sha, message)`. `sha` is the commit `ref` actually resolved to -
115
+ the baseline the diff describes, which is not necessarily HEAD - so a caller that
116
+ needs to name that commit (the report header, "NEW SINCE ...") never has to
117
+ re-resolve `ref` itself and risk naming the wrong commit. `sha` is `""` only when
118
+ `ref` itself could not be resolved at all.
119
+ """
120
+ try:
121
+ sha = current_git_sha(repo_root) if ref == "HEAD" else _rev_parse(ref, repo_root)
122
+ except Exception as error: # noqa: BLE001 - surfaced to the user, never swallowed
123
+ return None, "", f"No baseline: could not resolve `{ref}` ({error})."
124
+
125
+ baseline = load_snapshot(sha, repo_root)
126
+ if baseline is None:
127
+ return None, sha, f"No baseline snapshot stored for {sha[:12]} yet - nothing to diff against."
128
+ return diff_graphs(baseline, graph, load_triage(repo_root)), sha, ""
129
+
130
+
131
+ def _rev_parse(ref: str, repo_root: str) -> str:
132
+ import subprocess
133
+
134
+ return subprocess.run(
135
+ ["git", "-C", repo_root, "rev-parse", ref], capture_output=True, text=True, check=True
136
+ ).stdout.strip()
137
+
138
+
139
+ def check(repo_root: str = ".", graph: Graph | None = None) -> dict:
140
+ if graph is None:
141
+ graph = scan(repo_root)
142
+ entries = load_triage(repo_root)
143
+ graph = apply_triage(graph, entries)
144
+ result, _, message = diff_against(graph, "HEAD", repo_root)
145
+
146
+ def _ids(symbols):
147
+ return [{"id": s.id, "label": s.label, "kind": s.kind, "note": s.note} for s in symbols]
148
+
149
+ return {
150
+ "passed": not has_blocking_findings(graph, entries),
151
+ "message": message,
152
+ "new_unresolved": _ids(result.new_unresolved) if result else [],
153
+ "new_unused": _ids(result.new_unused) if result else [],
154
+ "triage_invalidated": result.triage_invalidated if result else [],
155
+ "counts": {
156
+ status.value: sum(1 for s in graph.symbols if s.status is status) for status in Status
157
+ },
158
+ }
159
+
160
+
161
+ def report(
162
+ repo_root: str = ".", fmt: str = "terminal", ref: str = "HEAD", graph: Graph | None = None
163
+ ) -> str:
164
+ """Render the report. One model, chosen serializer - ordering lives in report.py."""
165
+ from seamcheck.renderers import html as html_renderer
166
+ from seamcheck.renderers import markdown as markdown_renderer
167
+ from seamcheck.renderers import terminal as terminal_renderer
168
+ from seamcheck.report import build_report
169
+
170
+ renderers = {
171
+ "terminal": terminal_renderer.render,
172
+ "markdown": markdown_renderer.render,
173
+ "html": html_renderer.render,
174
+ }
175
+ if fmt == "map":
176
+ return _render_map(repo_root, ref)
177
+ if fmt == "console":
178
+ # The review sections live inside the map now: one document, one link, one render
179
+ # of the same scan. Kept as an alias so an existing caller does not break.
180
+ return _render_map(repo_root, ref)
181
+ if fmt not in renderers and fmt not in ("map", "console"):
182
+ raise ValueError(f"Unknown format {fmt!r}. Use one of: {', '.join(sorted(renderers))}.")
183
+
184
+ if graph is None:
185
+ graph = scan(repo_root)
186
+ diff, baseline_sha, message = diff_against(graph, ref, repo_root)
187
+ try:
188
+ sha = current_git_sha(repo_root)
189
+ except Exception: # noqa: BLE001 - a report is still useful outside a git checkout
190
+ sha = "unknown"
191
+
192
+ built = build_report(
193
+ graph=graph,
194
+ diff=diff,
195
+ entries=load_triage(repo_root),
196
+ git_sha=sha,
197
+ # baseline_sha names the commit the diff was actually taken against - the
198
+ # resolved `ref`, not `sha` (the commit just scanned). They coincide only when
199
+ # `ref == "HEAD"` and nothing changed since; conflating them is what made the
200
+ # header and the "NEW SINCE" heading print the same commit for a real --since.
201
+ baseline_sha=None if diff is None else baseline_sha,
202
+ baseline_message=message,
203
+ )
204
+ return renderers[fmt](built)
205
+
206
+
207
+ def triage(symbol_id: str, status: str, repo_root: str = ".", reason: str = "") -> dict:
208
+ try:
209
+ triage_status = TriageStatus(status)
210
+ except ValueError:
211
+ allowed = ", ".join(s.value for s in TriageStatus)
212
+ return {"ok": False, "message": f"Unknown status `{status}`. Use one of: {allowed}."}
213
+
214
+ graph = scan(repo_root)
215
+ symbol = next((s for s in graph.symbols if s.id == symbol_id), None)
216
+ if symbol is None:
217
+ return {"ok": False, "message": f"No symbol with id `{symbol_id}` in the current scan."}
218
+
219
+ entries = [e for e in load_triage(repo_root) if e.symbol_id != symbol_id]
220
+ entries.append(
221
+ TriageEntry(
222
+ symbol_id=symbol_id,
223
+ fingerprint=fingerprint_for_symbol(symbol),
224
+ status=triage_status,
225
+ who=os.environ.get("USER", "unknown"),
226
+ when=dt.date.today().isoformat(),
227
+ reason=reason,
228
+ )
229
+ )
230
+ save_triage(entries, repo_root)
231
+ return {"ok": True, "message": f"{symbol_id} marked {triage_status.value}."}
232
+
233
+
234
+ def write_map(graph: Graph, repo_root: str = ".") -> str:
235
+ import json
236
+
237
+ from seamcheck.graph import graph_to_dict
238
+
239
+ path = pathlib.Path(repo_root) / "docs" / "maps" / "connectivity-map.json"
240
+ path.parent.mkdir(parents=True, exist_ok=True)
241
+ path.write_text(json.dumps(graph_to_dict(graph), indent=2), encoding="utf-8")
242
+ save_snapshot(graph, current_git_sha(repo_root), repo_root)
243
+ return str(path)
244
+
245
+
246
+ def _page_files(repo_root: str) -> dict[str, set[str]]:
247
+ """Which JS files each page entry reaches. Computed only for the map: the import
248
+ walk costs ~13s and the CI path has no use for page attribution."""
249
+ import os as _os
250
+
251
+ from seamcheck.extractors.js_extractor import discover_js_files
252
+ from seamcheck.roots import discover_js_roots
253
+
254
+ config = _config()
255
+ roots = discover_js_roots(
256
+ vite_config=_os.path.join(repo_root, "vite.config.js"),
257
+ templates_root=_os.path.join(repo_root, config["templates_root"]),
258
+ static_root=_os.path.join(repo_root, "pointless", "static"),
259
+ )
260
+ return {
261
+ _os.path.splitext(_os.path.basename(root))[0]: set(discover_js_files([root], repo_root))
262
+ for root in roots
263
+ }
264
+
265
+
266
+ def _render_map(repo_root: str, ref: str) -> str:
267
+ js_entry_files, _ = _js_roots(_config(), repo_root)
268
+ from seamcheck.console import build_console
269
+ from seamcheck.filetree import build_file_tree
270
+ from seamcheck.history import commit_series
271
+ from seamcheck.mapdata import build_map
272
+ from seamcheck.pagenames import page_names
273
+ from seamcheck.renderers import map_html
274
+ from seamcheck.report import build_report
275
+
276
+ graph = scan(repo_root)
277
+ baseline = None
278
+ baseline_sha = None
279
+ if ref and ref != "HEAD":
280
+ try:
281
+ baseline_sha = _rev_parse(ref, repo_root)
282
+ baseline = load_snapshot(baseline_sha, repo_root)
283
+ except Exception: # noqa: BLE001 - a missing baseline degrades to current mode
284
+ baseline, baseline_sha = None, None
285
+ try:
286
+ sha = current_git_sha(repo_root)
287
+ except Exception: # noqa: BLE001
288
+ sha = "unknown"
289
+ # One document: the map and the review sections describe the same scan, and a second
290
+ # render of the same graph bought a second link and nothing else.
291
+ diff, baseline_message_sha, message = diff_against(graph, ref, repo_root)
292
+ console = build_console(graph, build_report(
293
+ graph=graph, diff=diff, entries=load_triage(repo_root), git_sha=sha,
294
+ baseline_sha=baseline_message_sha, baseline_message=message,
295
+ ))
296
+ return map_html.render(
297
+ build_map(graph, _page_files(repo_root), git_sha=sha,
298
+ baseline=baseline, baseline_sha=baseline_sha if baseline else None,
299
+ names=page_names(repo_root, _config(), graph),
300
+ commits=[
301
+ {"sha": entry.sha, "subject": entry.subject, "date": entry.date,
302
+ "symbols": entry.symbols, "changed": entry.changed,
303
+ "baseline": entry.baseline_sha, "head": entry.sha == sha,
304
+ # Capped: a large refactor can change thousands, and a browser
305
+ # needs the first screenful, not the whole set.
306
+ "changes": entry.changes[:300], "change_total": len(entry.changes)}
307
+ for entry in commit_series(repo_root)
308
+ ]),
309
+ console=console,
310
+ files=[
311
+ {"path": record.path, "counts": record.counts,
312
+ "declarations": record.declarations, "known": record.known}
313
+ for record in build_file_tree(graph, js_entry_files)
314
+ ],
315
+ )
seamcheck/apps.py ADDED
@@ -0,0 +1,21 @@
1
+ from django.apps import AppConfig
2
+ from django.conf import settings
3
+
4
+ # Without these, a scan cannot start: they name the URLconf to walk, the templates and
5
+ # static roots to read, and which import prefixes count as this project's own.
6
+ REQUIRED_CONFIG_KEYS = frozenset({
7
+ "urlconf_module",
8
+ "templates_root",
9
+ "first_party_prefixes",
10
+ })
11
+
12
+
13
+ def missing_config_keys() -> set[str]:
14
+ """Required keys absent from SEAMCHECK_CONFIG. Empty when the app can run."""
15
+ config = getattr(settings, "SEAMCHECK_CONFIG", {})
16
+ return set(REQUIRED_CONFIG_KEYS) - set(config)
17
+
18
+
19
+ class SeamcheckConfig(AppConfig):
20
+ default_auto_field = "django.db.models.BigAutoField"
21
+ name = "seamcheck"
@@ -0,0 +1,45 @@
1
+ """Labels that make a finding actionable: which feature, and which page, it belongs to."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+
7
+ from seamcheck.graph import Graph, Symbol
8
+
9
+
10
+ def attribute_by_feature(graph: Graph, dom_roots: list[Symbol]) -> dict[str, list[str]]:
11
+ """Walk outward from each interactive root, labelling everything it reaches.
12
+
13
+ "This selector is unresolved" is a fact; "the Purchase button reaches an unresolved
14
+ selector" is something a person can act on without reading the graph.
15
+ """
16
+ outgoing: dict[str, list[str]] = defaultdict(list)
17
+ for edge in graph.edges:
18
+ outgoing[edge.from_id].append(edge.to_id)
19
+ outgoing[edge.to_id].append(edge.from_id)
20
+
21
+ labels: dict[str, set[str]] = defaultdict(set)
22
+ for root in dom_roots:
23
+ seen = {root.id}
24
+ queue = [root.id]
25
+ while queue:
26
+ current = queue.pop()
27
+ labels[current].add(root.label)
28
+ for neighbour in outgoing.get(current, []):
29
+ if neighbour not in seen:
30
+ seen.add(neighbour)
31
+ queue.append(neighbour)
32
+ return {symbol_id: sorted(names) for symbol_id, names in labels.items()}
33
+
34
+
35
+ def attribute_by_page(js_symbols_by_entry: dict[str, list[Symbol]]) -> dict[str, list[str]]:
36
+ """Which bundler entry points reach each symbol.
37
+
38
+ A symbol reachable from several entries is shared, which is exactly what makes a
39
+ change to it risky - so the list is kept, never collapsed to a single owner.
40
+ """
41
+ pages: dict[str, set[str]] = defaultdict(set)
42
+ for entry, symbols in js_symbols_by_entry.items():
43
+ for symbol in symbols:
44
+ pages[symbol.id].add(entry)
45
+ return {symbol_id: sorted(entries) for symbol_id, entries in pages.items()}
@@ -0,0 +1,116 @@
1
+ """Final status per symbol, from the edges that actually constitute evidence of use."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+
7
+ from seamcheck.graph import Edge, Status, Symbol
8
+
9
+ # Kinds whose status this module decides. Everything else (signal_receiver, admin_action,
10
+ # template_tag, model, json_field, context_field) arrives already final from its extractor
11
+ # and is passed through untouched rather than re-guessed.
12
+ _OWNED_KINDS = frozenset({"view", "url", "js_call", "fetch_target"})
13
+
14
+ # DOM and CSS kinds carry their verdict on the edges the matchers produced, so the
15
+ # symbol has to read it back or every one of them reports "uncertain" forever.
16
+ # CONNECTED wins: an element JS writes but CSS never styles is still in use.
17
+ _EDGE_STATUS_KINDS = frozenset(
18
+ {"dom_attr", "dom_selector", "css_selector", "css_token_def", "css_token_use"}
19
+ )
20
+
21
+ # A CSS rule is matched against querySelector() calls and template class= attributes.
22
+ # Nothing yet reads the four ways JavaScript applies a class, so 'nothing uses this'
23
+ # is a claim the scan has not earned. Measured on this project: 5,318 selectors would
24
+ # be reported unused while 3,205 class-application sites went unread.
25
+ _UNPROVEN_UNUSED_KINDS = frozenset({"css_selector"})
26
+ _UNPROVEN_UNUSED_NOTE = (
27
+ "No CSS-side or template-side reference found, but JavaScript that applies classes "
28
+ "via className, classList.add or setAttribute is not yet scanned - so this is not "
29
+ "evidence the rule is dead."
30
+ )
31
+
32
+ _NO_CALLER_EVIDENCE_NOTE = (
33
+ "No fetch() call resolves here. Not claimed unused: page URLs are reached by browser "
34
+ "navigation, {% url %} tags and <a href>, and Core Pipeline has no extractor for those "
35
+ "yet (DOM Wiring plan). Absence of evidence is not evidence of absence."
36
+ )
37
+
38
+
39
+ def _from_edges(symbol: Symbol, incoming: dict, outgoing: dict) -> Symbol:
40
+ touching = incoming.get(symbol.id, []) + outgoing.get(symbol.id, [])
41
+ if not touching:
42
+ return symbol
43
+ statuses = {edge.status for edge in touching}
44
+ for status in (Status.CONNECTED, Status.UNRESOLVED, Status.UNUSED):
45
+ if status not in statuses:
46
+ continue
47
+ if status is Status.UNUSED and symbol.kind in _UNPROVEN_UNUSED_KINDS:
48
+ return replace(symbol, status=Status.UNCERTAIN, note=symbol.note or _UNPROVEN_UNUSED_NOTE)
49
+ return replace(symbol, status=status)
50
+ return symbol
51
+
52
+
53
+ def classify(symbols: list[Symbol], edges: list[Edge]) -> list[Symbol]:
54
+ by_id = {symbol.id: symbol for symbol in symbols}
55
+ incoming: dict[str, list[Edge]] = {}
56
+ outgoing: dict[str, list[Edge]] = {}
57
+ for edge in edges:
58
+ outgoing.setdefault(edge.from_id, []).append(edge)
59
+ incoming.setdefault(edge.to_id, []).append(edge)
60
+
61
+ def _sources(symbol_id: str, kind: str) -> list[Edge]:
62
+ return [
63
+ edge
64
+ for edge in incoming.get(symbol_id, [])
65
+ if (by_id.get(edge.from_id) or Symbol("", "", "", "", "", None, Status.UNUSED, "", [], "")).kind == kind
66
+ ]
67
+
68
+ # Pass 1: a URL is used when a fetch target resolves to it. A url -> view edge is
69
+ # routing, not usage, so it is deliberately not counted here.
70
+ url_status: dict[str, Status] = {}
71
+ for symbol in symbols:
72
+ if symbol.kind != "url":
73
+ continue
74
+ callers = _sources(symbol.id, "fetch_target")
75
+ url_status[symbol.id] = (
76
+ Status.CONNECTED
77
+ if any(edge.status == Status.CONNECTED for edge in callers)
78
+ else Status.UNCERTAIN
79
+ )
80
+
81
+ result: list[Symbol] = []
82
+ for symbol in symbols:
83
+ if symbol.kind in _EDGE_STATUS_KINDS:
84
+ result.append(_from_edges(symbol, incoming, outgoing))
85
+ continue
86
+ if symbol.status == Status.UNCERTAIN or symbol.kind not in _OWNED_KINDS:
87
+ result.append(symbol)
88
+ continue
89
+
90
+ if symbol.kind == "url":
91
+ status = url_status[symbol.id]
92
+ note = symbol.note or (_NO_CALLER_EVIDENCE_NOTE if status is Status.UNCERTAIN else "")
93
+ result.append(replace(symbol, status=status, note=note))
94
+ continue
95
+
96
+ if symbol.kind == "view":
97
+ # A view is used when at least one URL routing to it is used.
98
+ routes = [edge for edge in incoming.get(symbol.id, []) if edge.from_id in url_status]
99
+ status = (
100
+ Status.CONNECTED
101
+ if any(url_status[edge.from_id] is Status.CONNECTED for edge in routes)
102
+ else Status.UNCERTAIN
103
+ )
104
+ note = symbol.note or (_NO_CALLER_EVIDENCE_NOTE if status is Status.UNCERTAIN else "")
105
+ result.append(replace(symbol, status=status, note=note))
106
+ continue
107
+
108
+ touching = incoming.get(symbol.id, []) + outgoing.get(symbol.id, [])
109
+ if any(edge.status == Status.UNRESOLVED for edge in touching):
110
+ result.append(replace(symbol, status=Status.UNRESOLVED))
111
+ elif any(edge.status == Status.CONNECTED for edge in touching):
112
+ result.append(replace(symbol, status=Status.CONNECTED))
113
+ else:
114
+ result.append(symbol)
115
+
116
+ return result