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.
- seamcheck/AGENTS.md +96 -0
- seamcheck/__init__.py +0 -0
- seamcheck/api.py +315 -0
- seamcheck/apps.py +21 -0
- seamcheck/attribution.py +45 -0
- seamcheck/classifier.py +116 -0
- seamcheck/console.py +150 -0
- seamcheck/coverage.py +38 -0
- seamcheck/css_tools/parse_css.bundle.mjs +6192 -0
- seamcheck/css_tools/parse_css.mjs +41 -0
- seamcheck/diff.py +79 -0
- seamcheck/dom_matcher.py +145 -0
- seamcheck/extractors/__init__.py +0 -0
- seamcheck/extractors/asgi_extractor.py +79 -0
- seamcheck/extractors/css_extractor.py +139 -0
- seamcheck/extractors/django_extractor.py +106 -0
- seamcheck/extractors/django_models_extractor.py +77 -0
- seamcheck/extractors/dom_js_extractor.py +337 -0
- seamcheck/extractors/entry_points_extractor.py +114 -0
- seamcheck/extractors/js_extractor.py +378 -0
- seamcheck/extractors/reachability.py +96 -0
- seamcheck/extractors/template_scanner.py +84 -0
- seamcheck/field_matcher.py +206 -0
- seamcheck/filetree.py +111 -0
- seamcheck/graph.py +124 -0
- seamcheck/history.py +289 -0
- seamcheck/js_tools/parse_js.bundle.mjs +5679 -0
- seamcheck/js_tools/parse_js.mjs +34 -0
- seamcheck/management/__init__.py +0 -0
- seamcheck/management/commands/__init__.py +0 -0
- seamcheck/management/commands/seamcheck.py +256 -0
- seamcheck/mapdata.py +274 -0
- seamcheck/matcher.py +84 -0
- seamcheck/mcp_server.py +37 -0
- seamcheck/nodetools.py +56 -0
- seamcheck/pagenames.py +143 -0
- seamcheck/pipeline.py +185 -0
- seamcheck/renderers/__init__.py +0 -0
- seamcheck/renderers/_shared.py +29 -0
- seamcheck/renderers/html.py +182 -0
- seamcheck/renderers/map_html.py +1115 -0
- seamcheck/renderers/markdown.py +73 -0
- seamcheck/renderers/terminal.py +61 -0
- seamcheck/report.py +167 -0
- seamcheck/roots.py +91 -0
- seamcheck/serve.py +106 -0
- seamcheck/snapshot.py +37 -0
- seamcheck/triage.py +126 -0
- seamcheck-0.1.0.dist-info/METADATA +170 -0
- seamcheck-0.1.0.dist-info/RECORD +52 -0
- seamcheck-0.1.0.dist-info/WHEEL +4 -0
- 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"
|
seamcheck/attribution.py
ADDED
|
@@ -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()}
|
seamcheck/classifier.py
ADDED
|
@@ -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
|