since-dev 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.
- since_dev/__init__.py +3 -0
- since_dev/__main__.py +23 -0
- since_dev/cli.py +631 -0
- since_dev/init.py +119 -0
- since_dev/local.py +75 -0
- since_dev/realio.py +134 -0
- since_dev/render.py +337 -0
- since_dev-0.1.0.dist-info/METADATA +147 -0
- since_dev-0.1.0.dist-info/RECORD +13 -0
- since_dev-0.1.0.dist-info/WHEEL +5 -0
- since_dev-0.1.0.dist-info/entry_points.txt +3 -0
- since_dev-0.1.0.dist-info/licenses/LICENSE +21 -0
- since_dev-0.1.0.dist-info/top_level.txt +1 -0
since_dev/init.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""`since init`: the files that put Since in front of a coding agent, written into the repository so a clone carries them.
|
|
2
|
+
|
|
3
|
+
It adds its own entry and nothing else: other servers stay as they are, and a file it cannot read as JSON is left alone.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
MCP_URL = "https://api.since.dev/mcp"
|
|
10
|
+
TARGETS = [
|
|
11
|
+
{"file": ".mcp.json", "key": "mcpServers", "entry": {"type": "http", "url": MCP_URL}, "what": "Claude Code"},
|
|
12
|
+
{"file": ".cursor/mcp.json", "key": "mcpServers", "entry": {"url": MCP_URL}, "what": "Cursor"},
|
|
13
|
+
{"file": ".vscode/mcp.json", "key": "servers", "entry": {"type": "http", "url": MCP_URL}, "what": "VS Code"},
|
|
14
|
+
]
|
|
15
|
+
RULE_FILE = "AGENTS.md"
|
|
16
|
+
START = "<!-- since:start -->"
|
|
17
|
+
END = "<!-- since:end -->"
|
|
18
|
+
# The rule every listing ships; a test holds this and the JavaScript copy to the same words.
|
|
19
|
+
RULE = "\n".join([
|
|
20
|
+
"When you change a dependency version, a manifest or lockfile, an AI model ID, a runtime version, a Docker base image or a CI runner label, check it with the Since.dev MCP tools first:",
|
|
21
|
+
"",
|
|
22
|
+
"- `check_code` with the manifest, lockfile, model ID or `surface` (`runtime:<product>@<version>`) reports what is affected, on which line, the days left and the replacement.",
|
|
23
|
+
"- `whats_changed` with the package and `since` set to the current version lists every breaking change and deprecation between that version and today, each with its source.",
|
|
24
|
+
"",
|
|
25
|
+
"Treat `needs_review` and `unknown` as not settled, never as safe. Use the replacement the answer names. `fix_code` opens a fix pull request for a connected repository, and the editor asks before it runs.",
|
|
26
|
+
])
|
|
27
|
+
BLOCK = "%s\n## Since.dev\n\n%s\n%s" % (START, RULE, END)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _refuse_constant(name):
|
|
31
|
+
raise ValueError(name)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def loads(text):
|
|
35
|
+
"""json.loads as JSON.parse reads it: NaN and Infinity are not JSON."""
|
|
36
|
+
return json.loads(text, parse_constant=_refuse_constant)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def dumps(value, indent):
|
|
40
|
+
return json.dumps(value, indent=indent, ensure_ascii=False)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _indent_of(text):
|
|
44
|
+
"""The indent a JSON file already uses: its first indented line decides, two spaces when there is none."""
|
|
45
|
+
match = re.search(r"^([ \t]+)\S", text, re.M)
|
|
46
|
+
if not match:
|
|
47
|
+
return 2
|
|
48
|
+
return "\t" if "\t" in match.group(1) else len(match.group(1))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def merge_config(text, key, entry):
|
|
52
|
+
"""A config file with the Since entry in it. status: new, updated, same, or skipped when the file is not plain JSON."""
|
|
53
|
+
if text is None or text.strip() == "":
|
|
54
|
+
return {"status": "new", "text": dumps({key: {"since": entry}}, 2) + "\n"}
|
|
55
|
+
try:
|
|
56
|
+
data = loads(text)
|
|
57
|
+
except ValueError:
|
|
58
|
+
return {"status": "skipped", "text": text}
|
|
59
|
+
if not isinstance(data, dict) or (key in data and not isinstance(data[key], dict)):
|
|
60
|
+
return {"status": "skipped", "text": text}
|
|
61
|
+
if key in data and isinstance(data[key].get("since"), dict) and data[key]["since"] == entry:
|
|
62
|
+
return {"status": "same", "text": text}
|
|
63
|
+
if key not in data:
|
|
64
|
+
data[key] = {}
|
|
65
|
+
data[key]["since"] = entry
|
|
66
|
+
return {"status": "updated", "text": dumps(data, _indent_of(text)) + "\n"}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def merge_rule(text):
|
|
70
|
+
"""AGENTS.md with the rule between its two markers: replaced when the markers are there, added at the end when they are not."""
|
|
71
|
+
if text is None or text.strip() == "":
|
|
72
|
+
return {"status": "new", "text": BLOCK + "\n"}
|
|
73
|
+
start = text.find(START)
|
|
74
|
+
end = -1 if start < 0 else text.find(END, start)
|
|
75
|
+
if start < 0 or end < 0:
|
|
76
|
+
return {"status": "updated", "text": "%s\n\n%s\n" % (text.rstrip("\n"), BLOCK)}
|
|
77
|
+
merged = text[:start] + BLOCK + text[end + len(END):]
|
|
78
|
+
return {"status": "same", "text": text} if merged == text else {"status": "updated", "text": merged}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
WORDS = {
|
|
82
|
+
"new": ["Wrote", "Would write"],
|
|
83
|
+
"updated": ["Updated", "Would update"],
|
|
84
|
+
"same": ["Unchanged", "Unchanged"],
|
|
85
|
+
"skipped": ["Skipped", "Skipped"],
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def init(io, program, dry_run):
|
|
90
|
+
root = io.root()
|
|
91
|
+
lines = [""]
|
|
92
|
+
skipped = []
|
|
93
|
+
changed = 0
|
|
94
|
+
rows = [dict(target, result=merge_config(io.read_file("%s/%s" % (root, target["file"])), target["key"], target["entry"])) for target in TARGETS]
|
|
95
|
+
rows.append({"file": RULE_FILE, "what": "the rule every coding agent reads", "result": merge_rule(io.read_file("%s/%s" % (root, RULE_FILE)))})
|
|
96
|
+
for row in rows:
|
|
97
|
+
status = row["result"]["status"]
|
|
98
|
+
if status in ("new", "updated"):
|
|
99
|
+
changed += 1
|
|
100
|
+
if not dry_run:
|
|
101
|
+
io.write_file("%s/%s" % (root, row["file"]), row["result"]["text"], None)
|
|
102
|
+
if status == "skipped":
|
|
103
|
+
skipped.append(row)
|
|
104
|
+
lines.append(" %s%s%s" % (WORDS[status][1 if dry_run else 0].ljust(14), row["file"].ljust(20), row["what"]))
|
|
105
|
+
lines.append("")
|
|
106
|
+
for row in skipped:
|
|
107
|
+
lines.append(' %s is not plain JSON, so it was left alone. Add this under "%s" by hand:' % (row["file"], row["key"]))
|
|
108
|
+
lines.append(' "since": %s' % json.dumps(row["entry"], separators=(",", ":"), ensure_ascii=False))
|
|
109
|
+
lines.append("")
|
|
110
|
+
if dry_run:
|
|
111
|
+
lines.append(" Nothing was written. Run %s init to write %s." % (program, "it" if changed == 1 else "them") if changed else " Everything is already set up.")
|
|
112
|
+
elif changed:
|
|
113
|
+
lines.append(" Commit these files: everyone who clones this repository gets the same setup.")
|
|
114
|
+
lines.append(" The first tool call opens a sign-in in the browser. No key goes in any file.")
|
|
115
|
+
else:
|
|
116
|
+
lines.append(" Everything is already set up.")
|
|
117
|
+
lines.append("")
|
|
118
|
+
io.out("\n".join(lines))
|
|
119
|
+
return 0
|
since_dev/local.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""What the CLI reads from the folder it runs in: which repository this is, and which manifests to send.
|
|
2
|
+
|
|
3
|
+
The manifest rules are the GitHub Action's: the same names, the same folders skipped.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
PATTERNS = [
|
|
9
|
+
"**/package.json",
|
|
10
|
+
"**/requirements*.txt",
|
|
11
|
+
"**/pyproject.toml",
|
|
12
|
+
"**/go.mod",
|
|
13
|
+
"**/Cargo.toml",
|
|
14
|
+
"**/Gemfile",
|
|
15
|
+
"**/composer.json",
|
|
16
|
+
"**/pom.xml",
|
|
17
|
+
"**/build.gradle",
|
|
18
|
+
"**/build.gradle.kts",
|
|
19
|
+
"**/*.csproj",
|
|
20
|
+
]
|
|
21
|
+
LOCKFILES = {
|
|
22
|
+
"package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock",
|
|
23
|
+
"poetry.lock", "Pipfile.lock", "uv.lock", "Cargo.lock", "Gemfile.lock", "composer.lock",
|
|
24
|
+
"packages.lock.json", "gradle.lockfile",
|
|
25
|
+
}
|
|
26
|
+
SKIP = {".git", "node_modules", "vendor", "dist", "build", ".venv", "venv", "target", ".next", "__pycache__"}
|
|
27
|
+
MAX_CHARACTERS = 500000
|
|
28
|
+
|
|
29
|
+
_SHORT = re.compile(r"([A-Za-z0-9_-][A-Za-z0-9_.-]*)/([A-Za-z0-9_.-]+)")
|
|
30
|
+
_REMOTE = re.compile(
|
|
31
|
+
r"(?:https?://|ssh://|git://)?(?:[^@/]+@)?([A-Za-z0-9.-]+\.[A-Za-z]+)(?::[0-9]+)?[:/]([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+?)(?:\.git)?(?:/.*)?"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_repo(text):
|
|
36
|
+
"""owner and name from "owner/repo", a GitHub URL or a git remote; None for anything else. A bare "owner/repo" means GitHub."""
|
|
37
|
+
value = ("" if text is None else str(text)).strip()
|
|
38
|
+
if not value:
|
|
39
|
+
return None
|
|
40
|
+
short = _SHORT.fullmatch(value)
|
|
41
|
+
if short:
|
|
42
|
+
name = short.group(2)
|
|
43
|
+
return {"host": "github.com", "owner": short.group(1), "name": name[:-4] if name.endswith(".git") else name}
|
|
44
|
+
remote = _REMOTE.fullmatch(value)
|
|
45
|
+
if not remote:
|
|
46
|
+
return None
|
|
47
|
+
return {"host": remote.group(1).lower(), "owner": remote.group(2), "name": remote.group(3)}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def pattern_regexp(pattern):
|
|
51
|
+
parts = re.split(r"(\*\*/|\*)", re.sub(r"^\./", "", pattern))
|
|
52
|
+
return re.compile("".join("(?:.*/)?" if part == "**/" else "[^/]*" if part == "*" else re.escape(part) for part in parts))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def find_manifests(io):
|
|
56
|
+
"""The manifests under the folder, nearest the top first, then by name."""
|
|
57
|
+
patterns = [pattern_regexp(pattern) for pattern in PATTERNS]
|
|
58
|
+
found = []
|
|
59
|
+
|
|
60
|
+
def walk(folder):
|
|
61
|
+
for entry in io.list("%s/%s" % (io.cwd, folder) if folder else io.cwd):
|
|
62
|
+
relative = "%s/%s" % (folder, entry["name"]) if folder else entry["name"]
|
|
63
|
+
if entry["dir"]:
|
|
64
|
+
if entry["name"] not in SKIP:
|
|
65
|
+
walk(relative)
|
|
66
|
+
elif any(pattern.fullmatch(relative) for pattern in patterns):
|
|
67
|
+
found.append(relative)
|
|
68
|
+
|
|
69
|
+
walk("")
|
|
70
|
+
return sorted(found, key=lambda file: (file.count("/"), file))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def check_body(file, content):
|
|
74
|
+
name = file.split("/")[-1]
|
|
75
|
+
return {"lockfile" if name in LOCKFILES else "manifest": {"path": file, "content": content}, "detail": "summary"}
|
since_dev/realio.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""The real world behind the CLI: the network, the folder, git, the terminal. The tests swap all of it for a fixture."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.request
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _git(cwd, args):
|
|
12
|
+
try:
|
|
13
|
+
done = subprocess.run(["git"] + args, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, timeout=10)
|
|
14
|
+
except (OSError, subprocess.SubprocessError):
|
|
15
|
+
return None
|
|
16
|
+
return done.stdout.decode("utf-8", "replace").strip() or None if done.returncode == 0 else None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
20
|
+
"""The API never redirects, so a redirect is never followed: a key goes to the host it was meant for and nowhere else."""
|
|
21
|
+
|
|
22
|
+
def redirect_request(self, *args, **kwargs):
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_OPENER = urllib.request.build_opener(_NoRedirect)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _program():
|
|
30
|
+
"""The command that runs this again: through uvx or pipx run there is no `since` on the PATH."""
|
|
31
|
+
script = (sys.argv[0] if sys.argv else "").replace("\\", "/")
|
|
32
|
+
if "/uv/" in script and "/archive-" in script:
|
|
33
|
+
return "uvx since-dev"
|
|
34
|
+
if "/pipx/" in script and "/.cache/" in script:
|
|
35
|
+
return "pipx run since-dev"
|
|
36
|
+
return "since"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RealIo:
|
|
40
|
+
runtime = "python"
|
|
41
|
+
|
|
42
|
+
def __init__(self):
|
|
43
|
+
self.env = os.environ
|
|
44
|
+
self.cwd = os.getcwd()
|
|
45
|
+
self.home = os.path.expanduser("~")
|
|
46
|
+
self.platform = "win32" if sys.platform.startswith("win") else sys.platform
|
|
47
|
+
self.tty = sys.stdout.isatty()
|
|
48
|
+
self.program = _program()
|
|
49
|
+
|
|
50
|
+
def out(self, text):
|
|
51
|
+
sys.stdout.write(text)
|
|
52
|
+
sys.stdout.flush()
|
|
53
|
+
|
|
54
|
+
def err(self, text):
|
|
55
|
+
sys.stderr.write(text)
|
|
56
|
+
sys.stderr.flush()
|
|
57
|
+
|
|
58
|
+
def now(self):
|
|
59
|
+
return int(time.time() * 1000)
|
|
60
|
+
|
|
61
|
+
def sleep(self, ms):
|
|
62
|
+
time.sleep(ms / 1000)
|
|
63
|
+
|
|
64
|
+
def fetch(self, method, url, headers, body):
|
|
65
|
+
request = urllib.request.Request(url, data=None if body is None else body.encode("utf-8"), headers=headers, method=method)
|
|
66
|
+
try:
|
|
67
|
+
with _OPENER.open(request, timeout=120) as response:
|
|
68
|
+
return {"status": response.status, "retryAfter": response.headers.get("Retry-After"), "text": response.read().decode("utf-8", "replace")}
|
|
69
|
+
except urllib.error.HTTPError as error:
|
|
70
|
+
return {"status": error.code, "retryAfter": error.headers.get("Retry-After"), "text": error.read().decode("utf-8", "replace")}
|
|
71
|
+
|
|
72
|
+
def origin(self):
|
|
73
|
+
return _git(self.cwd, ["remote", "get-url", "origin"])
|
|
74
|
+
|
|
75
|
+
def root(self):
|
|
76
|
+
return _git(self.cwd, ["rev-parse", "--show-toplevel"]) or self.cwd
|
|
77
|
+
|
|
78
|
+
def open(self, url):
|
|
79
|
+
if self.env.get("CI"):
|
|
80
|
+
return
|
|
81
|
+
try:
|
|
82
|
+
if self.platform == "darwin":
|
|
83
|
+
command = ["open", url]
|
|
84
|
+
elif self.platform == "win32":
|
|
85
|
+
command = ["rundll32", "url.dll,FileProtocolHandler", url]
|
|
86
|
+
else:
|
|
87
|
+
command = ["xdg-open", url]
|
|
88
|
+
subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL)
|
|
89
|
+
except OSError:
|
|
90
|
+
pass # No browser to open: the link is printed, which is enough.
|
|
91
|
+
|
|
92
|
+
def prompt(self, question):
|
|
93
|
+
sys.stderr.write(question)
|
|
94
|
+
sys.stderr.flush()
|
|
95
|
+
return sys.stdin.readline()
|
|
96
|
+
|
|
97
|
+
def read_file(self, file):
|
|
98
|
+
try:
|
|
99
|
+
with open(file, encoding="utf-8") as handle:
|
|
100
|
+
return handle.read()
|
|
101
|
+
except (OSError, UnicodeDecodeError):
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
def write_file(self, file, text, mode):
|
|
105
|
+
"""mode is None for a file of the repository, or the permission bits for a private one (its folder is made private too)."""
|
|
106
|
+
folder = os.path.dirname(file)
|
|
107
|
+
if folder:
|
|
108
|
+
if mode is None:
|
|
109
|
+
os.makedirs(folder, exist_ok=True)
|
|
110
|
+
else:
|
|
111
|
+
os.makedirs(folder, mode=0o700, exist_ok=True)
|
|
112
|
+
if mode is None:
|
|
113
|
+
with open(file, "w", encoding="utf-8", newline="") as handle:
|
|
114
|
+
handle.write(text)
|
|
115
|
+
return
|
|
116
|
+
descriptor = os.open(file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, mode)
|
|
117
|
+
with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as handle:
|
|
118
|
+
handle.write(text)
|
|
119
|
+
os.chmod(file, mode)
|
|
120
|
+
|
|
121
|
+
def remove(self, file):
|
|
122
|
+
try:
|
|
123
|
+
os.remove(file)
|
|
124
|
+
return True
|
|
125
|
+
except OSError:
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
def list(self, folder):
|
|
129
|
+
"""A folder's entries by name; a link is never followed into a folder."""
|
|
130
|
+
try:
|
|
131
|
+
with os.scandir(folder) as entries:
|
|
132
|
+
return sorted(({"name": entry.name, "dir": entry.is_dir(follow_symlinks=False)} for entry in entries), key=lambda entry: entry["name"])
|
|
133
|
+
except OSError:
|
|
134
|
+
return []
|
since_dev/render.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Text only: every function here turns an API answer into lines. No network, no files, no clock.
|
|
2
|
+
|
|
3
|
+
src/render.mjs is the same code in JavaScript, and the shared fixtures hold both to the same output.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
SEVERITIES = ["low", "medium", "high", "critical"]
|
|
7
|
+
PAD = " " * 12
|
|
8
|
+
SHOWN = 10
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Palette:
|
|
12
|
+
def __init__(self, on):
|
|
13
|
+
self.on = on
|
|
14
|
+
|
|
15
|
+
def _paint(self, code, text):
|
|
16
|
+
return "\x1b[%dm%s\x1b[0m" % (code, text) if self.on else text
|
|
17
|
+
|
|
18
|
+
def bold(self, text):
|
|
19
|
+
return self._paint(1, text)
|
|
20
|
+
|
|
21
|
+
def dim(self, text):
|
|
22
|
+
return self._paint(2, text)
|
|
23
|
+
|
|
24
|
+
def red(self, text):
|
|
25
|
+
return self._paint(31, text)
|
|
26
|
+
|
|
27
|
+
def yellow(self, text):
|
|
28
|
+
return self._paint(33, text)
|
|
29
|
+
|
|
30
|
+
def plain(self, text):
|
|
31
|
+
return text
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_number(value):
|
|
35
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def num(value):
|
|
39
|
+
return "{:,}".format(value)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def plural(count, word):
|
|
43
|
+
return "%s %s%s" % (num(count), word, "" if count == 1 else "s")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def wait(seconds):
|
|
47
|
+
"""How long to wait, in words: seconds under a minute and a half, then minutes, then hours, rounded up."""
|
|
48
|
+
if seconds < 90:
|
|
49
|
+
return plural(max(1, seconds), "second")
|
|
50
|
+
if seconds < 5400:
|
|
51
|
+
return plural(-(-seconds // 60), "minute")
|
|
52
|
+
return plural(-(-seconds // 3600), "hour")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def ago(ms):
|
|
56
|
+
"""How long ago, from milliseconds; "" when the time is not known."""
|
|
57
|
+
if ms is None:
|
|
58
|
+
return ""
|
|
59
|
+
minutes = ms // 60000
|
|
60
|
+
if minutes < 1:
|
|
61
|
+
return "just now"
|
|
62
|
+
if minutes < 60:
|
|
63
|
+
return "%d min ago" % minutes
|
|
64
|
+
hours = minutes // 60
|
|
65
|
+
if hours < 48:
|
|
66
|
+
return "%s ago" % plural(hours, "hour")
|
|
67
|
+
return "%s ago" % plural(hours // 24, "day")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _place_of(path, line):
|
|
71
|
+
return "%s:%d" % (path, line) if is_number(line) and line > 0 else str(path)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _join(parts, separator):
|
|
75
|
+
return separator.join(str(part) for part in parts if part)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def scan_items(findings):
|
|
79
|
+
"""A public scan's findings, as the rows the CLI prints."""
|
|
80
|
+
items = []
|
|
81
|
+
for finding in findings:
|
|
82
|
+
where = finding.get("where")
|
|
83
|
+
where = [place for place in where if isinstance(place, dict) and place.get("path")] if isinstance(where, list) else []
|
|
84
|
+
source = finding.get("source")
|
|
85
|
+
source = source.get("url") if isinstance(source, dict) else None
|
|
86
|
+
title = finding.get("title")
|
|
87
|
+
items.append({
|
|
88
|
+
"title": "" if title is None else str(title),
|
|
89
|
+
"review": finding.get("state") == "needs_review",
|
|
90
|
+
"severity": finding.get("severity"),
|
|
91
|
+
"daysLeft": finding.get("daysLeft") if is_number(finding.get("daysLeft")) else None,
|
|
92
|
+
"place": _place_of(where[0]["path"], where[0].get("line")) if where else None,
|
|
93
|
+
"extra": max(0, len(where) - 1) + (finding["more"] if is_number(finding.get("more")) else 0),
|
|
94
|
+
"replacement": finding.get("replacement") or None,
|
|
95
|
+
"source": source or None,
|
|
96
|
+
})
|
|
97
|
+
return items
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _merge_rows(group, program):
|
|
101
|
+
"""Many undated findings about one dependency at one place, as one row: the count, the worst severity, and how to list them."""
|
|
102
|
+
members = group["members"]
|
|
103
|
+
proven = [row for row in members if not row["review"]]
|
|
104
|
+
pool = proven or members
|
|
105
|
+
|
|
106
|
+
def rank(level):
|
|
107
|
+
return SEVERITIES.index(level) if level in SEVERITIES else -1
|
|
108
|
+
|
|
109
|
+
severity = pool[0]["severity"]
|
|
110
|
+
for row in pool:
|
|
111
|
+
if rank(row["severity"]) > rank(severity):
|
|
112
|
+
severity = row["severity"]
|
|
113
|
+
tally = [[level, len([row for row in members if row["severity"] == level])] for level in reversed(SEVERITIES)]
|
|
114
|
+
tally = [entry for entry in tally if entry[1]]
|
|
115
|
+
unrated = len(members) - sum(entry[1] for entry in tally)
|
|
116
|
+
if unrated:
|
|
117
|
+
tally.append(["unrated", unrated])
|
|
118
|
+
breakdown = " (%s)" % ", ".join("%s %s" % (num(count), level) for level, count in tally) if len(tally) > 1 else ""
|
|
119
|
+
since = " --since %s" % group["version"] if group["version"] else ""
|
|
120
|
+
registry = " --ecosystem %s" % group["ecosystem"] if group["ecosystem"] and group["ecosystem"] != "npm" else ""
|
|
121
|
+
return {
|
|
122
|
+
"title": "%s: %s changes between this version and the latest%s" % (group["subject"], num(len(members)), breakdown),
|
|
123
|
+
"review": not proven,
|
|
124
|
+
"severity": severity,
|
|
125
|
+
"daysLeft": None,
|
|
126
|
+
"place": members[0]["place"],
|
|
127
|
+
"extra": 0,
|
|
128
|
+
"replacement": None,
|
|
129
|
+
"source": None,
|
|
130
|
+
"hint": "%s changed %s%s%s" % (program, group["name"], since, registry) if group["isPackage"] and group["name"] else None,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def check_items(findings, fallback_path, program):
|
|
135
|
+
"""A keyed check's findings as the same rows. They carry no title, so one is built from the name, the version and the reason.
|
|
136
|
+
|
|
137
|
+
A manifest check answers with every recorded change between the pinned version and the latest, hundreds for an old pin:
|
|
138
|
+
the undated ones about one dependency at one place become one row, and `since changed` lists them.
|
|
139
|
+
"""
|
|
140
|
+
rows = []
|
|
141
|
+
groups = {}
|
|
142
|
+
for finding in findings:
|
|
143
|
+
surface = finding["surface"] if isinstance(finding.get("surface"), dict) else {}
|
|
144
|
+
name = str(surface.get("name") or "")
|
|
145
|
+
version = finding.get("version") or None
|
|
146
|
+
subject = _join([name, version], " ")
|
|
147
|
+
reason = finding.get("reason")
|
|
148
|
+
reason = ("" if reason is None else str(reason)).strip()
|
|
149
|
+
where = finding.get("where")
|
|
150
|
+
where = [place for place in where if isinstance(place, dict) and place.get("file")] if isinstance(where, list) else []
|
|
151
|
+
first = _place_of(where[0]["file"], where[0].get("line")) if where else fallback_path
|
|
152
|
+
source = finding.get("source")
|
|
153
|
+
if isinstance(source, dict):
|
|
154
|
+
source = source.get("url")
|
|
155
|
+
places = finding["placeCount"] if is_number(finding.get("placeCount")) else len(where)
|
|
156
|
+
row = {
|
|
157
|
+
"title": reason if name and name.lower() in reason.lower() else _join([subject, reason], ": "),
|
|
158
|
+
"review": finding.get("state") == "needs_review",
|
|
159
|
+
"severity": finding.get("severity"),
|
|
160
|
+
"daysLeft": finding.get("daysLeft") if is_number(finding.get("daysLeft")) else None,
|
|
161
|
+
"place": first or None,
|
|
162
|
+
"extra": max(0, places - 1),
|
|
163
|
+
"replacement": finding.get("replacement") or None,
|
|
164
|
+
"source": source if isinstance(source, str) and source else None,
|
|
165
|
+
"hint": None,
|
|
166
|
+
}
|
|
167
|
+
if row["daysLeft"] is not None:
|
|
168
|
+
rows.append(row)
|
|
169
|
+
continue
|
|
170
|
+
key = "%s|%s|%s" % (surface.get("key") or name, version or "", row["place"] or "")
|
|
171
|
+
if key in groups:
|
|
172
|
+
groups[key]["members"].append(row)
|
|
173
|
+
else:
|
|
174
|
+
groups[key] = {"members": [row], "subject": subject, "name": name, "version": version, "isPackage": surface.get("kind") == "package", "ecosystem": surface.get("ecosystem") or None}
|
|
175
|
+
rows.append(groups[key])
|
|
176
|
+
return [(row["members"][0] if len(row["members"]) == 1 else _merge_rows(row, program)) if "members" in row else row for row in rows]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def order(items):
|
|
180
|
+
"""Dated findings first (the next deadline, then the ones already passed, newest first), then undated, then the ones to review."""
|
|
181
|
+
def group(item):
|
|
182
|
+
return 2 if item["review"] else 1 if item["daysLeft"] is None else 0
|
|
183
|
+
|
|
184
|
+
def within(item):
|
|
185
|
+
if group(item) != 0:
|
|
186
|
+
return 0
|
|
187
|
+
return item["daysLeft"] if item["daysLeft"] >= 0 else 100000 - item["daysLeft"]
|
|
188
|
+
|
|
189
|
+
return sorted(items, key=lambda item: (group(item), within(item)))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def fails(item, fail_on):
|
|
193
|
+
"""The CI rule, the same as the GitHub Action's: proven, and at or above the severity asked for."""
|
|
194
|
+
if fail_on not in SEVERITIES or item["review"] or item["severity"] not in SEVERITIES:
|
|
195
|
+
return False
|
|
196
|
+
return SEVERITIES.index(item["severity"]) >= SEVERITIES.index(fail_on)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def day_label(days_left):
|
|
200
|
+
if days_left < 0:
|
|
201
|
+
return "passed"
|
|
202
|
+
if days_left == 0:
|
|
203
|
+
return "today"
|
|
204
|
+
return "1 day" if days_left == 1 else "%s days" % num(days_left)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _label(item):
|
|
208
|
+
if item["review"]:
|
|
209
|
+
return "review"
|
|
210
|
+
if item["daysLeft"] is None:
|
|
211
|
+
return item["severity"] if item["severity"] in SEVERITIES else "no date"
|
|
212
|
+
return day_label(item["daysLeft"])
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _day_tone(days_left, c):
|
|
216
|
+
return c.red if days_left <= 30 else c.yellow if days_left <= 90 else c.plain
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _tone(item, c):
|
|
220
|
+
if item["review"]:
|
|
221
|
+
return c.dim
|
|
222
|
+
if item["daysLeft"] is None:
|
|
223
|
+
return c.yellow if item["severity"] in ("critical", "high") else c.plain
|
|
224
|
+
return _day_tone(item["daysLeft"], c)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def counts_line(items, within):
|
|
228
|
+
""""3 with a date 2 undated 1 to review", without the counts that are zero."""
|
|
229
|
+
dated = len([item for item in items if not item["review"] and item["daysLeft"] is not None])
|
|
230
|
+
undated = len([item for item in items if not item["review"] and item["daysLeft"] is None])
|
|
231
|
+
review = len([item for item in items if item["review"]])
|
|
232
|
+
parts = []
|
|
233
|
+
if dated:
|
|
234
|
+
parts.append("%s with a date" % num(dated))
|
|
235
|
+
if undated:
|
|
236
|
+
parts.append("%s undated" % num(undated))
|
|
237
|
+
if review:
|
|
238
|
+
parts.append("%s to review" % num(review))
|
|
239
|
+
if parts:
|
|
240
|
+
return " ".join(parts)
|
|
241
|
+
return "No findings" if within is None else "Nothing dated within %s" % plural(within, "day")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def finding_lines(items, show_all, c, more_hint):
|
|
245
|
+
"""The findings, three lines each: when and what, where and the replacement, the source."""
|
|
246
|
+
lines = []
|
|
247
|
+
shown = items if show_all else items[:SHOWN]
|
|
248
|
+
for item in shown:
|
|
249
|
+
lines.append(" %s%s" % (_tone(item, c)(_label(item).ljust(10)), item["title"]))
|
|
250
|
+
place = item["place"] + (" and %s more" % num(item["extra"]) if item["extra"] else "") if item["place"] else None
|
|
251
|
+
detail = _join([place, "use %s" % item["replacement"] if item["replacement"] else None, "list them: %s" % item["hint"] if item.get("hint") else None], " ")
|
|
252
|
+
if detail:
|
|
253
|
+
lines.append(c.dim(PAD + detail))
|
|
254
|
+
if item["source"]:
|
|
255
|
+
lines.append(c.dim(PAD + item["source"]))
|
|
256
|
+
lines.append("")
|
|
257
|
+
if len(items) > len(shown):
|
|
258
|
+
lines.extend([" and %s more %s" % (num(len(items) - len(shown)), more_hint), ""])
|
|
259
|
+
return lines
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _count(value, fallback):
|
|
263
|
+
return int(value) if is_number(value) and value else fallback
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def answer_notes(answers):
|
|
267
|
+
"""What keyed answers say about their own gaps: still being read, cut short, or names that did not resolve."""
|
|
268
|
+
notes = []
|
|
269
|
+
scanning = 0
|
|
270
|
+
reading = False
|
|
271
|
+
releases = 0
|
|
272
|
+
truncated = False
|
|
273
|
+
unresolved = []
|
|
274
|
+
for answer in answers:
|
|
275
|
+
if isinstance(answer.get("scanning"), dict):
|
|
276
|
+
scanning = max(scanning, _count(answer["scanning"].get("retryAfterSec"), 60))
|
|
277
|
+
if isinstance(answer.get("reading"), dict):
|
|
278
|
+
reading = True
|
|
279
|
+
releases += _count(answer["reading"].get("releases"), 0)
|
|
280
|
+
if answer.get("truncated"):
|
|
281
|
+
truncated = True
|
|
282
|
+
if isinstance(answer.get("unresolved"), list):
|
|
283
|
+
unresolved.extend(answer["unresolved"])
|
|
284
|
+
if scanning:
|
|
285
|
+
notes.append("Since is still reading this repository. Run this again in %s." % wait(scanning))
|
|
286
|
+
if reading:
|
|
287
|
+
what = plural(releases, "newer release") if releases else "newer releases"
|
|
288
|
+
notes.append("Since is reading %s now. Run this again in a few minutes to include them." % what)
|
|
289
|
+
if truncated:
|
|
290
|
+
notes.append("The answer was cut short: there are more findings than one answer holds.")
|
|
291
|
+
if unresolved:
|
|
292
|
+
names = ", ".join(str(entry.get("name")) for entry in unresolved[:3])
|
|
293
|
+
notes.append("Not fully checked yet: %s%s. --json says why." % (names, " and %s more" % num(len(unresolved) - 3) if len(unresolved) > 3 else ""))
|
|
294
|
+
return notes
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def calendar_lines(entries, site, c):
|
|
298
|
+
"""One line per calendar entry, with its replacement and its page under it."""
|
|
299
|
+
lines = []
|
|
300
|
+
for entry in entries:
|
|
301
|
+
days = entry.get("daysLeft") if is_number(entry.get("daysLeft")) else None
|
|
302
|
+
when = "no date" if days is None else day_label(days)
|
|
303
|
+
paint = c.plain if days is None else _day_tone(days, c)
|
|
304
|
+
lines.append(" %s%s" % (paint(when.ljust(10)), entry.get("title")))
|
|
305
|
+
path = entry.get("path")
|
|
306
|
+
link = "%s%s?utm_source=cli" % (site, path) if isinstance(path, str) and path.startswith("/") else None
|
|
307
|
+
detail = _join(["use %s" % entry["replacement"] if entry.get("replacement") else None, link], " ")
|
|
308
|
+
if detail:
|
|
309
|
+
lines.append(c.dim(PAD + detail))
|
|
310
|
+
return lines
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def unique_changes(changes):
|
|
314
|
+
# One change can be recorded once per function it touches, with the same words: say it once.
|
|
315
|
+
seen = set()
|
|
316
|
+
unique = []
|
|
317
|
+
for change in changes:
|
|
318
|
+
key = (change.get("kind"), change.get("title"), change.get("introducedIn") or change.get("removedIn"))
|
|
319
|
+
if key not in seen:
|
|
320
|
+
seen.add(key)
|
|
321
|
+
unique.append(change)
|
|
322
|
+
return unique
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def change_lines(changes, c):
|
|
326
|
+
"""One line per change of a package, with the version, the date and the source under it."""
|
|
327
|
+
lines = []
|
|
328
|
+
for change in changes:
|
|
329
|
+
kind = change.get("kind")
|
|
330
|
+
version = change.get("introducedIn") or change.get("removedIn")
|
|
331
|
+
lines.append(" %s%s" % (("" if kind is None else str(kind)).replace("_", " ").ljust(19), change.get("title")))
|
|
332
|
+
evidence = change.get("evidence")
|
|
333
|
+
evidence = evidence[0].get("url") if isinstance(evidence, list) and evidence and isinstance(evidence[0], dict) else None
|
|
334
|
+
detail = _join(["in %s" % version if version else None, change.get("effectiveDate") or None, evidence or None], " ")
|
|
335
|
+
if detail:
|
|
336
|
+
lines.append(c.dim(" " * 21 + detail))
|
|
337
|
+
return lines
|