Files
soleprint/soleprint/common/slice/scan.py
2026-09-24 00:43:18 -03:00

191 lines
7.9 KiB
Python

"""
What a consumer uses of the framework, read from the consumer itself.
This is the check that used to be done by hand — grep an app for its imports,
diff its framework copy against spr — made repeatable, and made the input of a
slice. Two questions, answered separately:
**Usage.** Which exports, files and custom properties the consumer's own code
reads. Both import styles in use are understood:
import { Panel, type LogEntry } from 'soleprint-ui' barrel (mpr, nvi, unt)
import Panel from '@framework/components/Panel.vue' deep path (mts)
import '@framework/tokens.css' a stylesheet
Its own `var(--…)` reads count too: every consumer imports tokens.css directly
and styles its own components with it, so a slice that carried only what the
framework reads would leave the app's own CSS unstyled.
**Copy state.** When the consumer holds a copy of the framework (found through
its package.json `link:` or its Vite alias, or named with --copy), every file is
compared with current spr: same, differs, missing from the copy, or only in the
copy. A copy that is *ahead* shows up here first — which is how mts's maximize
was found before anything could overwrite it.
"""
import difflib
import json
import re
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from . import graph
from .manifest import commit_exists, git, read_stamp
DEFAULT_ALIASES = ("soleprint-ui", "@framework")
SKIP_DIRS = {"node_modules", "dist", ".git", "__pycache__", ".venv", "out", "gen"}
CODE = {".vue", ".ts", ".tsx", ".js", ".jsx", ".mjs"}
STYLE = {".css"}
MARKUP = {".html", ".htm", ".jinja", ".j2"}
VAR = re.compile(r"var\(\s*(--[\w-]+)")
LINK = re.compile(r"^(?:link|file):(.+)$")
VITE_ALIAS = re.compile(r"""['"]([^'"]+)['"]\s*:\s*[^,\n]*?['"](\.{1,2}/[^'"]*)['"]""")
@dataclass
class Usage:
names: set = field(default_factory=set) # barrel names imported
files: set = field(default_factory=set) # SRC-relative files deep-imported
tokens: set = field(default_factory=set) # custom properties the consumer reads
texts: list = field(default_factory=list) # (path, text) of every file read
barrel: int = 0 # imports through the barrel
deep: int = 0 # imports by deep path
unknown: set = field(default_factory=set) # deep paths spr does not have
def markup(self) -> list:
return [(p, t) for p, t in self.texts if p.suffix in MARKUP]
def _files(paths, exclude=()):
excluded = [Path(e).resolve() for e in exclude]
for base in paths:
base = Path(base)
candidates = [base] if base.is_file() else sorted(base.rglob("*"))
for p in candidates:
if not p.is_file() or SKIP_DIRS & set(p.parts):
continue
rp = p.resolve()
if any(rp == e or e in rp.parents for e in excluded):
continue
if p.suffix in CODE | STYLE | MARKUP:
yield p
def _deep_target(rest: str, nodes: dict) -> str | None:
rest = rest.lstrip("/")
if rest.startswith("src/"):
rest = rest[4:]
for candidate in (rest, rest + ".ts", rest + ".vue", rest + "/index.ts"):
if candidate in nodes or candidate in graph.GENERATED | {"base.css"}:
return candidate
return None
def usage(paths, aliases=DEFAULT_ALIASES, exclude=(), nodes=None) -> Usage:
"""Read every file under `paths` for its use of the framework."""
nodes = nodes if nodes is not None else graph.load()
alias_group = "|".join(re.escape(a) for a in sorted(aliases, key=len, reverse=True))
named = re.compile(
r"import\s+(type\s+)?\{([^}]*)\}\s*from\s*['\"](" + alias_group + r")(/[^'\"]*)?['\"]", re.S)
any_import = re.compile(
r"(?:import|export)\s+(?:[^'\";]*?\s*from\s*)?['\"](" + alias_group + r")(/[^'\"]*)?['\"]",
re.S)
out = Usage()
for path in _files(paths, exclude):
text = path.read_text(errors="replace")
out.texts.append((path, text))
out.tokens |= set(VAR.findall(text))
if path.suffix not in CODE:
continue
for _, names, _, rest in named.findall(text):
if rest:
continue # `{ x } from '@framework/…'` — counted as deep below
for item in names.split(","):
item = item.strip()
if item.startswith("type "):
item = item[5:].strip()
if item:
out.names.add(item.split(" as ")[0].strip())
for _, rest in any_import.findall(text):
if not rest:
out.barrel += 1
continue
out.deep += 1
target = _deep_target(rest, nodes)
if target:
out.files.add(target)
else:
out.unknown.add(rest.lstrip("/"))
return out
def find_copy(app: Path) -> Path | None:
"""The framework copy an app resolves to: package.json `link:`, else a Vite alias."""
pkg = app / "package.json"
if pkg.exists():
try:
data = json.loads(pkg.read_text())
except json.JSONDecodeError:
data = {}
deps = {**data.get("devDependencies", {}), **data.get("dependencies", {})}
for name in DEFAULT_ALIASES:
m = LINK.match(str(deps.get(name, "")))
if m:
return (app / m.group(1)).resolve()
for config in sorted(app.glob("vite.config.*")):
for alias, target in VITE_ALIAS.findall(config.read_text()):
if alias in DEFAULT_ALIASES:
found = (app / target).resolve()
return found.parent if found.name == "src" else found
return None
@dataclass
class CopyState:
root: Path
rows: list = field(default_factory=list) # (rel, status, detail)
stamp: dict = field(default_factory=dict)
stamp_known: bool = False
def compare(copy_root: Path, src: Path = graph.SRC) -> CopyState:
"""Every file of spr's src against the copy's src, both ways."""
copy_src = copy_root / "src" if (copy_root / "src").is_dir() else copy_root
state = CopyState(root=copy_root, stamp=read_stamp(copy_root))
state.stamp_known = commit_exists(state.stamp.get("sha", ""))
def listing(root):
return {p.relative_to(root).as_posix() for p in root.rglob("*")
if p.is_file() and not graph.SKIP & set(p.relative_to(root).parts)
and p.suffix in (".ts", ".vue", ".css")}
ours, theirs = listing(src), listing(copy_src)
for rel in sorted(ours | theirs):
if rel not in theirs:
state.rows.append((rel, "missing", "in spr, not in the copy"))
elif rel not in ours:
state.rows.append((rel, "copy-only", "in the copy, not in spr"))
else:
a = (src / rel).read_text().splitlines()
b = (copy_src / rel).read_text().splitlines()
if a == b:
state.rows.append((rel, "same", ""))
continue
added = removed = 0
for line in difflib.unified_diff(a, b, lineterm="", n=0):
if line.startswith("+") and not line.startswith("+++"):
added += 1
elif line.startswith("-") and not line.startswith("---"):
removed += 1
# Dates, not a verdict. A worktree's mtimes are checkout times, so
# spr's side is dated by its last commit; the copy has no history,
# so its mtime is all there is.
ours_date = git("log", "-1", "--format=%cs", "--", str((src / rel).resolve()))
theirs_date = datetime.fromtimestamp((copy_src / rel).stat().st_mtime).strftime("%Y-%m-%d")
state.rows.append((rel, "differs",
f"+{added} -{removed} in the copy · copy {theirs_date}, "
f"spr last commit {ours_date or '?'}"))
return state