191 lines
7.9 KiB
Python
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
|