agtmls-scorecard 0.0.3__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.
- agtmls_scorecard/__init__.py +5 -0
- agtmls_scorecard/__main__.py +10 -0
- agtmls_scorecard/checks/__init__.py +68 -0
- agtmls_scorecard/checks/correctness.py +108 -0
- agtmls_scorecard/checks/documentation.py +110 -0
- agtmls_scorecard/checks/dx.py +224 -0
- agtmls_scorecard/checks/governance.py +88 -0
- agtmls_scorecard/checks/interoperability.py +227 -0
- agtmls_scorecard/checks/maintainability.py +158 -0
- agtmls_scorecard/checks/observability.py +204 -0
- agtmls_scorecard/checks/performance.py +322 -0
- agtmls_scorecard/checks/release.py +132 -0
- agtmls_scorecard/checks/security.py +198 -0
- agtmls_scorecard/cli.py +119 -0
- agtmls_scorecard/model.py +174 -0
- agtmls_scorecard/repo.py +177 -0
- agtmls_scorecard-0.0.3.dist-info/METADATA +118 -0
- agtmls_scorecard-0.0.3.dist-info/RECORD +22 -0
- agtmls_scorecard-0.0.3.dist-info/WHEEL +4 -0
- agtmls_scorecard-0.0.3.dist-info/entry_points.txt +2 -0
- agtmls_scorecard-0.0.3.dist-info/licenses/LICENSE-APACHE +190 -0
- agtmls_scorecard-0.0.3.dist-info/licenses/LICENSE-MIT +21 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Sebastien Rousseau
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0 OR MIT
|
|
3
|
+
"""The ten categories of the rubric.
|
|
4
|
+
|
|
5
|
+
All ten are implemented. Four of them -- performance, developer experience,
|
|
6
|
+
observability and interoperability -- were whole-category placeholders until
|
|
7
|
+
now, which meant the tool reported 0.0 at zero coverage for 40 of the 100
|
|
8
|
+
criteria. That was honest, and it was also the reason a repository could read
|
|
9
|
+
9.3/10 while nothing had ever checked whether it was fast, diagnosable,
|
|
10
|
+
scriptable or conformant.
|
|
11
|
+
|
|
12
|
+
Implementing them is expected to *lower* published scores, because criteria
|
|
13
|
+
that were invisible become criteria that can fail. A number that only ever
|
|
14
|
+
moves up is not measuring anything.
|
|
15
|
+
|
|
16
|
+
PENDING is kept, empty, on purpose: it is the mechanism for declaring a
|
|
17
|
+
category nobody has written a checker for, and deleting it would make the next
|
|
18
|
+
omission silent instead of counted.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from ..model import CategoryReport, Result, Status
|
|
24
|
+
from ..repo import Repo
|
|
25
|
+
from . import (
|
|
26
|
+
correctness,
|
|
27
|
+
documentation,
|
|
28
|
+
dx,
|
|
29
|
+
governance,
|
|
30
|
+
interoperability,
|
|
31
|
+
maintainability,
|
|
32
|
+
observability,
|
|
33
|
+
performance,
|
|
34
|
+
release,
|
|
35
|
+
security,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
IMPLEMENTED = [
|
|
39
|
+
("correctness", correctness),
|
|
40
|
+
("security", security),
|
|
41
|
+
("performance", performance),
|
|
42
|
+
("dx", dx),
|
|
43
|
+
("documentation", documentation),
|
|
44
|
+
("observability", observability),
|
|
45
|
+
("release", release),
|
|
46
|
+
("interoperability", interoperability),
|
|
47
|
+
("maintainability", maintainability),
|
|
48
|
+
("governance", governance),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
#: (id, title, why no checker exists yet)
|
|
52
|
+
PENDING: list[tuple[str, str, str]] = []
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def evaluate(repo: Repo) -> list[CategoryReport]:
|
|
56
|
+
reports = [
|
|
57
|
+
CategoryReport(id=identifier, title=module.TITLE, results=module.run(repo))
|
|
58
|
+
for identifier, module in IMPLEMENTED
|
|
59
|
+
]
|
|
60
|
+
reports.extend(
|
|
61
|
+
CategoryReport(
|
|
62
|
+
id=identifier,
|
|
63
|
+
title=title,
|
|
64
|
+
results=[Result(f"{identifier}.*", f"All of {title}", Status.UNVERIFIED, reason)],
|
|
65
|
+
)
|
|
66
|
+
for identifier, title, reason in PENDING
|
|
67
|
+
)
|
|
68
|
+
return reports
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Sebastien Rousseau
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0 OR MIT
|
|
3
|
+
"""Category 1 — Correctness & Test Depth."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
from ..model import Result, Status
|
|
10
|
+
from ..repo import Repo
|
|
11
|
+
|
|
12
|
+
TITLE = "Correctness & Test Depth"
|
|
13
|
+
|
|
14
|
+
#: Backtick spans and string literals. Anything inside one is shown, not done.
|
|
15
|
+
QUOTED = re.compile(r"`[^`]*`|\"[^\"]*\"|'[^']*'")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _tests_exist(repo: Repo) -> Result:
|
|
19
|
+
candidates = [
|
|
20
|
+
p for p in repo.files
|
|
21
|
+
if "test" in p.name.lower() or "tests" in p.relative_to(repo.root).parts
|
|
22
|
+
]
|
|
23
|
+
if not candidates:
|
|
24
|
+
return Result("1.0", "Tests exist", Status.FAIL, "no test files found")
|
|
25
|
+
return Result("1.0", "Tests exist", Status.PASS, f"{len(candidates)} test file(s)")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _discovery(repo: Repo) -> Result:
|
|
29
|
+
"""A hand-maintained list of tests is a list someone will forget to update.
|
|
30
|
+
|
|
31
|
+
The repository this tool was written for had exactly that: a `CASES` tuple
|
|
32
|
+
that a new test class had to be added to, or it silently never ran.
|
|
33
|
+
"""
|
|
34
|
+
offenders: list[str] = []
|
|
35
|
+
for path in repo.by_suffix(".py"):
|
|
36
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
37
|
+
if "unittest" not in text:
|
|
38
|
+
continue
|
|
39
|
+
if re.search(r"^CASES\s*=\s*\(", text, re.MULTILINE) and "loadTestsFromModule" not in text:
|
|
40
|
+
offenders.append(str(path.relative_to(repo.root)))
|
|
41
|
+
if offenders:
|
|
42
|
+
return Result("1.3", "Tests are discovered, not hand-registered", Status.FAIL,
|
|
43
|
+
f"hand-maintained registry in {offenders[0]}")
|
|
44
|
+
return Result("1.3", "Tests are discovered, not hand-registered", Status.PASS,
|
|
45
|
+
"no hand-maintained test registry")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _no_silent_skips(repo: Repo) -> Result:
|
|
49
|
+
"""A skipped test reports green while proving nothing."""
|
|
50
|
+
skips: list[str] = []
|
|
51
|
+
patterns = (
|
|
52
|
+
re.compile(r"#\[ignore\]"),
|
|
53
|
+
re.compile(r"@(unittest\.)?skip\b"),
|
|
54
|
+
# `it.skip(` / `test.skip(` / `describe.skip(` -- not `args.skip(1)`,
|
|
55
|
+
# which is how you drop argv[0] and not a skipped test at all.
|
|
56
|
+
re.compile(r"\b(it|test|describe|context)\.(skip|todo)\s*\("),
|
|
57
|
+
re.compile(r"\btodo!\s*\("),
|
|
58
|
+
)
|
|
59
|
+
linked = re.compile(r"(#\d+|issues?/\d+|reason\s*=)")
|
|
60
|
+
for path in repo.by_suffix(".rs", ".py", ".mjs", ".js", ".ts"):
|
|
61
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
62
|
+
for index, line in enumerate(lines):
|
|
63
|
+
# A marker inside a string or a backtick span is being quoted --
|
|
64
|
+
# a pattern that defines skip detection, or a fixture. This check
|
|
65
|
+
# matched its own regex before the strip.
|
|
66
|
+
bare = QUOTED.sub("", line)
|
|
67
|
+
if any(p.search(bare) for p in patterns) and not linked.search(bare):
|
|
68
|
+
skips.append(f"{path.relative_to(repo.root)}:{index + 1}")
|
|
69
|
+
if skips:
|
|
70
|
+
return Result("1.6", "No unexplained skipped test", Status.FAIL,
|
|
71
|
+
f"{len(skips)}, e.g. {skips[0]}")
|
|
72
|
+
return Result("1.6", "No unexplained skipped test", Status.PASS, "none")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _matrix(repo: Repo) -> Result:
|
|
76
|
+
text = repo.workflow_text
|
|
77
|
+
if not text:
|
|
78
|
+
return Result("1.9", "CI exercises the declared floor and ceiling", Status.NOT_APPLICABLE,
|
|
79
|
+
"no workflows")
|
|
80
|
+
if "matrix:" in text and re.search(r"(os|python-version|node|toolchain)", text):
|
|
81
|
+
return Result("1.9", "CI exercises the declared floor and ceiling", Status.PASS,
|
|
82
|
+
"a version or platform matrix is configured")
|
|
83
|
+
return Result("1.9", "CI exercises the declared floor and ceiling", Status.FAIL,
|
|
84
|
+
"no matrix: a single configuration proves one configuration")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def run(repo: Repo) -> list[Result]:
|
|
88
|
+
return [
|
|
89
|
+
_tests_exist(repo),
|
|
90
|
+
Result("1.1", "Every public entry point is executed by a test", Status.UNVERIFIED,
|
|
91
|
+
"enumerating entry points and matching them to invocations needs the language's "
|
|
92
|
+
"own tooling; the repository's dispatch test is the pattern that does it"),
|
|
93
|
+
Result("1.2", "Coverage of the public surface >= 90%", Status.UNVERIFIED,
|
|
94
|
+
"needs an instrumented run; no coverage artifact is read here"),
|
|
95
|
+
_discovery(repo),
|
|
96
|
+
Result("1.4", "Every fix lands with a test seen to fail first", Status.UNVERIFIED,
|
|
97
|
+
"would mean replaying each fix commit against its parent; possible, not implemented"),
|
|
98
|
+
Result("1.5", "Property or fuzz tests for every untrusted parser", Status.UNVERIFIED,
|
|
99
|
+
"identifying which parsers take untrusted input needs intent"),
|
|
100
|
+
_no_silent_skips(repo),
|
|
101
|
+
Result("1.7", "Error paths tested, not only happy paths", Status.UNVERIFIED,
|
|
102
|
+
"needs branch coverage, not a static read"),
|
|
103
|
+
Result("1.8", "Golden outputs byte-exact and regenerable", Status.UNVERIFIED,
|
|
104
|
+
"requires running each generator in --check mode"),
|
|
105
|
+
_matrix(repo),
|
|
106
|
+
Result("1.10", "Mutation score >= 70% on core logic", Status.UNVERIFIED,
|
|
107
|
+
"needs cargo-mutants or mutmut; both are long-running build-environment tools"),
|
|
108
|
+
]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Sebastien Rousseau
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0 OR MIT
|
|
3
|
+
"""Category 5 — Documentation."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from ..model import Result, Status
|
|
11
|
+
from ..repo import Repo
|
|
12
|
+
|
|
13
|
+
TITLE = "Documentation"
|
|
14
|
+
|
|
15
|
+
LINK = re.compile(r"\[[^\]]*\]\(([^)#][^)]*)\)")
|
|
16
|
+
#: A target inside a code span is an example of link syntax, not a link. A
|
|
17
|
+
#: target containing `::` is a language path -- Rust intra-doc links are the
|
|
18
|
+
#: common case -- and no file was ever meant to exist there.
|
|
19
|
+
CODE_SPAN = re.compile(r"`[^`]*`")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _adrs(repo: Repo) -> Result:
|
|
23
|
+
directory = repo.root / "docs" / "adr"
|
|
24
|
+
records = sorted(directory.glob("[0-9]*.md")) if directory.is_dir() else []
|
|
25
|
+
if records:
|
|
26
|
+
return Result("5.3", "Architectural decisions recorded", Status.PASS,
|
|
27
|
+
f"{len(records)} ADR(s)")
|
|
28
|
+
return Result("5.3", "Architectural decisions recorded", Status.FAIL, "no docs/adr")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _links(repo: Repo) -> Result:
|
|
32
|
+
"""Relative links that point at nothing.
|
|
33
|
+
|
|
34
|
+
Only local targets: a network check would make the score depend on whether
|
|
35
|
+
a site was up, which is not a property of the repository.
|
|
36
|
+
"""
|
|
37
|
+
broken: list[str] = []
|
|
38
|
+
for path in repo.by_suffix(".md"):
|
|
39
|
+
text = CODE_SPAN.sub("", path.read_text(encoding="utf-8", errors="replace"))
|
|
40
|
+
for target in LINK.findall(text):
|
|
41
|
+
if "::" in target:
|
|
42
|
+
continue
|
|
43
|
+
target = target.split("#", 1)[0].strip()
|
|
44
|
+
if not target or "://" in target or target.startswith("mailto:"):
|
|
45
|
+
continue
|
|
46
|
+
# A relative path does not contain a sentence. Markdown prose is
|
|
47
|
+
# full of bracket-then-paren sequences that are not links at all,
|
|
48
|
+
# and treating them as broken ones is how a checker earns its
|
|
49
|
+
# first blanket suppression.
|
|
50
|
+
if any(c in target for c in " \t") or len(target) > 200:
|
|
51
|
+
continue
|
|
52
|
+
resolved = (path.parent / target).resolve()
|
|
53
|
+
if not resolved.exists():
|
|
54
|
+
broken.append(f"{path.relative_to(repo.root)} -> {target}")
|
|
55
|
+
if not broken:
|
|
56
|
+
return Result("5.5", "No broken local link", Status.PASS, "all local targets resolve")
|
|
57
|
+
return Result("5.5", "No broken local link", Status.FAIL,
|
|
58
|
+
f"{len(broken)}: {broken[0]}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _readme(repo: Repo) -> Result:
|
|
62
|
+
text = repo.read("README.md")
|
|
63
|
+
if not text:
|
|
64
|
+
return Result("5.6", "README states what, why now, and non-goals", Status.FAIL, "no README.md")
|
|
65
|
+
lowered = text.lower()
|
|
66
|
+
has_why = any(k in lowered for k in ("## why", "why this exists", "why now", "# why"))
|
|
67
|
+
has_limits = any(
|
|
68
|
+
k in lowered
|
|
69
|
+
for k in ("not do", "non-goal", "when not to", "will not", "deliberately not", "does not")
|
|
70
|
+
)
|
|
71
|
+
if has_why and has_limits:
|
|
72
|
+
return Result("5.6", "README states what, why now, and non-goals", Status.PASS,
|
|
73
|
+
"rationale and boundaries both present")
|
|
74
|
+
missing = []
|
|
75
|
+
if not has_why:
|
|
76
|
+
missing.append("no rationale section")
|
|
77
|
+
if not has_limits:
|
|
78
|
+
missing.append("no stated non-goals: a README that lists only capabilities oversells")
|
|
79
|
+
return Result("5.6", "README states what, why now, and non-goals", Status.FAIL, "; ".join(missing))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _changelog(repo: Repo) -> Result:
|
|
83
|
+
text = repo.read("CHANGELOG.md")
|
|
84
|
+
if not text:
|
|
85
|
+
return Result("5.7", "Changelog maintained", Status.FAIL, "no CHANGELOG.md")
|
|
86
|
+
if re.search(r"^##\s", text, re.MULTILINE):
|
|
87
|
+
return Result("5.7", "Changelog maintained", Status.PASS, "has released or unreleased sections")
|
|
88
|
+
return Result("5.7", "Changelog maintained", Status.FAIL, "no version sections")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def run(repo: Repo) -> list[Result]:
|
|
92
|
+
return [
|
|
93
|
+
Result("5.1", "Documented commands exist, and commands are documented", Status.UNVERIFIED,
|
|
94
|
+
"needs the CLI surface; the repository's own surface validator covers this"),
|
|
95
|
+
Result("5.2", "Every documented example runs in CI", Status.UNVERIFIED,
|
|
96
|
+
"extracting and executing examples is a per-language job, not a static check"),
|
|
97
|
+
_adrs(repo),
|
|
98
|
+
Result("5.4", "Every public symbol documented", Status.UNVERIFIED,
|
|
99
|
+
"rustdoc and docstring coverage are toolchain jobs; missing_docs in CI covers it"),
|
|
100
|
+
_links(repo),
|
|
101
|
+
_readme(repo),
|
|
102
|
+
_changelog(repo),
|
|
103
|
+
Result("5.8", "Manpage generated from the same source as --help", Status.UNVERIFIED,
|
|
104
|
+
"requires running both and diffing; not attempted from a static read"),
|
|
105
|
+
Result("5.9", "No README claim without an enforcing check", Status.UNVERIFIED,
|
|
106
|
+
"mapping prose claims to checks needs judgement; it is the review step this "
|
|
107
|
+
"tool is meant to inform, not replace"),
|
|
108
|
+
Result("5.10", "Migration guide per breaking change", Status.UNVERIFIED,
|
|
109
|
+
"needs release history and a definition of breaking that varies by project"),
|
|
110
|
+
]
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Sebastien Rousseau
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0 OR MIT
|
|
3
|
+
"""Category 4 — API & Developer Experience.
|
|
4
|
+
|
|
5
|
+
The previous placeholder said this needed "the CLI surface enumerated and each
|
|
6
|
+
command invoked". Enumerating it does not need invoking it: an argparse
|
|
7
|
+
`add_parser` call and a clap `#[command]` attribute both name a subcommand in
|
|
8
|
+
the source, and a flag either appears beside it or does not.
|
|
9
|
+
|
|
10
|
+
Invoking every subcommand to read its `--help` would mean executing the
|
|
11
|
+
repository being scored, which is not a thing a scorer should do to an
|
|
12
|
+
arbitrary checkout. So the checks that need execution stay `unverified`, and
|
|
13
|
+
the ones that are a property of the declared surface are decided.
|
|
14
|
+
|
|
15
|
+
The distinction that matters throughout: a flag existing **somewhere** in the
|
|
16
|
+
repository is not the criterion. `--json` on one subcommand out of thirty-five
|
|
17
|
+
is the state agtmls was actually in, and a check that accepted it would have
|
|
18
|
+
reported developer experience as solved.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
|
|
25
|
+
from ..model import Result, Status
|
|
26
|
+
from ..repo import Repo
|
|
27
|
+
|
|
28
|
+
TITLE = "API & Developer Experience"
|
|
29
|
+
|
|
30
|
+
#: How a subcommand is declared, per ecosystem.
|
|
31
|
+
SUBCOMMAND = re.compile(
|
|
32
|
+
r"""add_parser\(\s*["']([a-z][a-z0-9-]*)["']""" # argparse
|
|
33
|
+
r"""|\.subcommand\(\s*Command::new\(\s*["']([a-z][a-z0-9-]*)["']""" # clap builder
|
|
34
|
+
r"""|\.command\(\s*["']([a-z][a-z0-9-]*)["']""" # commander / yargs
|
|
35
|
+
)
|
|
36
|
+
#: Commands that destroy or overwrite something the user did not hand us.
|
|
37
|
+
DESTRUCTIVE = ("install", "uninstall", "remove", "clean", "reset", "sync", "apply",
|
|
38
|
+
"write", "publish", "delete", "prune", "import")
|
|
39
|
+
EXIT_TAXONOMY = re.compile(r"EXIT_[A-Z_]+\s*=\s*\d|ExitCode::|exit_code|exit-codes")
|
|
40
|
+
#: A remediation names something to do, not only something that is wrong.
|
|
41
|
+
REMEDY = re.compile(r"\brun\b|\buse\b|\bsee\b|\bset\b|\btry\b|\badd\b|\bpass\b|\breinstall\b")
|
|
42
|
+
#: "FAIL: 3 issue(s)" is a tally printed under the errors that already
|
|
43
|
+
#: explained themselves. Asking it to repeat a remedy would make every summary
|
|
44
|
+
#: line worse, so tallies leave the denominator.
|
|
45
|
+
TALLY = re.compile(r"\{[^}]+\}[^\"']{0,40}\(s\)")
|
|
46
|
+
SEMVER_TOOL = re.compile(r"cargo-semver-checks|semver-checks|griffe|api-extractor|apidiff|gorelease")
|
|
47
|
+
COMPLETION_DIRS = ("completions", "completion", "contrib/completions")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _subcommands(repo: Repo) -> dict[str, str]:
|
|
51
|
+
"""Declared subcommand -> the file that declares it."""
|
|
52
|
+
found: dict[str, str] = {}
|
|
53
|
+
for path in repo.by_suffix(".py", ".rs", ".js", ".ts"):
|
|
54
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
55
|
+
for match in SUBCOMMAND.finditer(text):
|
|
56
|
+
name = next(g for g in match.groups() if g)
|
|
57
|
+
found.setdefault(name, str(path.relative_to(repo.root)))
|
|
58
|
+
return found
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _cli_text(repo: Repo) -> str:
|
|
62
|
+
return "".join(
|
|
63
|
+
p.read_text(encoding="utf-8", errors="replace")
|
|
64
|
+
for p in repo.by_suffix(".py", ".rs", ".js", ".ts")
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _no_cli(criterion: str, title: str) -> Result:
|
|
69
|
+
return Result(criterion, title, Status.NOT_APPLICABLE,
|
|
70
|
+
"no subcommand surface was found to hold to this")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _json_coverage(repo: Repo) -> Result:
|
|
74
|
+
title = "Machine-readable output on the commands that produce data"
|
|
75
|
+
commands = _subcommands(repo)
|
|
76
|
+
if not commands:
|
|
77
|
+
return _no_cli("4.1", title)
|
|
78
|
+
text = _cli_text(repo)
|
|
79
|
+
if "--json" not in text and '"json"' not in text:
|
|
80
|
+
return Result("4.1", title, Status.FAIL,
|
|
81
|
+
f"{len(commands)} subcommand(s) and no --json anywhere")
|
|
82
|
+
# Count the flag against the surface rather than asking whether it exists.
|
|
83
|
+
flags = len(re.findall(r"--json\b", text))
|
|
84
|
+
if flags * 2 < len(commands):
|
|
85
|
+
return Result("4.1", title, Status.FAIL,
|
|
86
|
+
f"--json appears {flags} time(s) across {len(commands)} subcommand(s); "
|
|
87
|
+
"a flag on a minority of the surface is not a machine interface")
|
|
88
|
+
return Result("4.1", title, Status.PASS,
|
|
89
|
+
f"--json across {flags} of {len(commands)} subcommand(s)")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _dry_run(repo: Repo) -> Result:
|
|
93
|
+
title = "Destructive commands offer a dry run"
|
|
94
|
+
commands = _subcommands(repo)
|
|
95
|
+
if not commands:
|
|
96
|
+
return _no_cli("4.2", title)
|
|
97
|
+
destructive = sorted(n for n in commands if any(d in n for d in DESTRUCTIVE))
|
|
98
|
+
if not destructive:
|
|
99
|
+
return Result("4.2", title, Status.NOT_APPLICABLE,
|
|
100
|
+
"no subcommand named for a destructive action")
|
|
101
|
+
if "--dry-run" not in _cli_text(repo):
|
|
102
|
+
return Result("4.2", title, Status.FAIL,
|
|
103
|
+
f"destructive subcommand(s) {', '.join(destructive)} with no --dry-run: "
|
|
104
|
+
"the only way to find out what happens is for it to happen")
|
|
105
|
+
return Result("4.2", title, Status.PASS,
|
|
106
|
+
f"--dry-run available beside {', '.join(destructive)}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _refuses_to_clobber(repo: Repo) -> Result:
|
|
110
|
+
title = "No command overwrites user data without refusing or backing up"
|
|
111
|
+
commands = _subcommands(repo)
|
|
112
|
+
if not commands or not any(any(d in n for d in DESTRUCTIVE) for n in commands):
|
|
113
|
+
return Result("4.3", title, Status.NOT_APPLICABLE, "nothing destructive declared")
|
|
114
|
+
text = _cli_text(repo)
|
|
115
|
+
guarded = re.search(r"--force\b", text) and re.search(r"backup|\.bak\b|refus", text, re.IGNORECASE)
|
|
116
|
+
if not guarded:
|
|
117
|
+
return Result("4.3", title, Status.FAIL,
|
|
118
|
+
"no --force/backup pairing: a destructive command with no refusal "
|
|
119
|
+
"path overwrites by default")
|
|
120
|
+
tested = any(
|
|
121
|
+
re.search(r"backup|refus|clobber|overwrit", p.read_text(encoding="utf-8", errors="replace"),
|
|
122
|
+
re.IGNORECASE)
|
|
123
|
+
for p in repo.files
|
|
124
|
+
if "test" in p.name.lower() or "smoke" in p.name.lower()
|
|
125
|
+
)
|
|
126
|
+
if not tested:
|
|
127
|
+
return Result("4.3", title, Status.FAIL,
|
|
128
|
+
"a refusal path exists but no test drives it; an untested refusal "
|
|
129
|
+
"is the one that stops working quietly")
|
|
130
|
+
return Result("4.3", title, Status.PASS, "refusal and backup, exercised by a test")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _exit_codes(repo: Repo) -> Result:
|
|
134
|
+
title = "Exit codes are a documented taxonomy"
|
|
135
|
+
if repo.kind == "docs" or not repo.has_cli:
|
|
136
|
+
return Result("4.4", title, Status.NOT_APPLICABLE, "no command to exit")
|
|
137
|
+
declared = EXIT_TAXONOMY.search(_cli_text(repo))
|
|
138
|
+
documented = any(
|
|
139
|
+
re.search(r"exit code", p.read_text(encoding="utf-8", errors="replace"), re.IGNORECASE)
|
|
140
|
+
for p in repo.by_suffix(".md")
|
|
141
|
+
)
|
|
142
|
+
if declared and documented:
|
|
143
|
+
return Result("4.4", title, Status.PASS, "named exit codes, and documented")
|
|
144
|
+
if declared:
|
|
145
|
+
return Result("4.4", title, Status.FAIL,
|
|
146
|
+
"exit codes are named in code but documented nowhere, so no caller "
|
|
147
|
+
"can branch on them without reading the source")
|
|
148
|
+
return Result("4.4", title, Status.FAIL,
|
|
149
|
+
"0 and 1 by accident: a caller cannot tell 'it failed' from "
|
|
150
|
+
"'you typed it wrong'")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _remediation(repo: Repo) -> Result:
|
|
154
|
+
title = "Error messages name a remediation"
|
|
155
|
+
messages: list[str] = []
|
|
156
|
+
for path in repo.by_suffix(".py", ".rs"):
|
|
157
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
158
|
+
messages.extend(re.findall(r'["\'](?:FAIL|ERROR|error)[:\s][^"\']{15,}["\']', text))
|
|
159
|
+
messages = [m for m in messages if not TALLY.search(m)]
|
|
160
|
+
if len(messages) < 5:
|
|
161
|
+
return Result("4.5", title, Status.UNVERIFIED,
|
|
162
|
+
f"only {len(messages)} error string(s) found; too few to judge a habit")
|
|
163
|
+
with_remedy = [m for m in messages if REMEDY.search(m)]
|
|
164
|
+
share = len(with_remedy) / len(messages)
|
|
165
|
+
if share < 0.4:
|
|
166
|
+
bare = [m.strip("\"'")[:60] for m in messages if not REMEDY.search(m)][:2]
|
|
167
|
+
return Result("4.5", title, Status.FAIL,
|
|
168
|
+
f"{len(with_remedy)} of {len(messages)} error message(s) suggest an "
|
|
169
|
+
f"action; the rest state a problem and stop, e.g. "
|
|
170
|
+
+ "; ".join(repr(b) for b in bare))
|
|
171
|
+
return Result("4.5", title, Status.PASS,
|
|
172
|
+
f"{len(with_remedy)} of {len(messages)} error message(s) name an action")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _completions(repo: Repo) -> Result:
|
|
176
|
+
title = "Shell completions are generated, not hand-written"
|
|
177
|
+
if repo.kind == "docs":
|
|
178
|
+
return Result("4.6", title, Status.NOT_APPLICABLE, "no executable")
|
|
179
|
+
present = [d for d in COMPLETION_DIRS if (repo.root / d).is_dir()]
|
|
180
|
+
if not present:
|
|
181
|
+
return Result("4.6", title, Status.NOT_APPLICABLE,
|
|
182
|
+
"ships no completions; this criterion grades how they are "
|
|
183
|
+
"produced, not whether they exist")
|
|
184
|
+
generator = any(
|
|
185
|
+
"completion" in p.name.lower() and re.search(r"--check|--write", p.read_text(encoding="utf-8", errors="replace"))
|
|
186
|
+
for p in repo.by_suffix(".py", ".rs")
|
|
187
|
+
)
|
|
188
|
+
if not generator:
|
|
189
|
+
return Result("4.6", title, Status.FAIL,
|
|
190
|
+
f"{present[0]}/ is hand-written: it will drift from the CLI "
|
|
191
|
+
"the first time a flag changes")
|
|
192
|
+
return Result("4.6", title, Status.PASS, f"{present[0]}/ generated with a --check mode")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _semver_gate(repo: Repo) -> Result:
|
|
196
|
+
title = "Public API changes are gated by a semver-diff tool"
|
|
197
|
+
if repo.kind == "docs":
|
|
198
|
+
return Result("4.9", title, Status.NOT_APPLICABLE, "no public API")
|
|
199
|
+
if SEMVER_TOOL.search(repo.workflow_text):
|
|
200
|
+
return Result("4.9", title, Status.PASS, "an API-diff tool runs in CI")
|
|
201
|
+
return Result("4.9", title, Status.FAIL,
|
|
202
|
+
"nothing compares the public API between releases, so a breaking "
|
|
203
|
+
"change ships as a patch")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def run(repo: Repo) -> list[Result]:
|
|
207
|
+
return [
|
|
208
|
+
_json_coverage(repo),
|
|
209
|
+
_dry_run(repo),
|
|
210
|
+
_refuses_to_clobber(repo),
|
|
211
|
+
_exit_codes(repo),
|
|
212
|
+
_remediation(repo),
|
|
213
|
+
_completions(repo),
|
|
214
|
+
Result("4.7", "Every subcommand has help with an example", Status.UNVERIFIED,
|
|
215
|
+
"reading --help means executing the repository under inspection, "
|
|
216
|
+
"which a scorer should not do to an arbitrary checkout"),
|
|
217
|
+
Result("4.8", "Config resolution order is documented and tested", Status.UNVERIFIED,
|
|
218
|
+
"which sources configure a tool, and in what order, is intent this "
|
|
219
|
+
"tool cannot infer from a source tree"),
|
|
220
|
+
_semver_gate(repo),
|
|
221
|
+
Result("4.10", "Install works from a clean machine by every documented path",
|
|
222
|
+
Status.UNVERIFIED,
|
|
223
|
+
"needs a container per documented install method"),
|
|
224
|
+
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Sebastien Rousseau
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0 OR MIT
|
|
3
|
+
"""Category 10 — Governance & Community."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
from ..model import Result, Status
|
|
10
|
+
from ..repo import Repo
|
|
11
|
+
|
|
12
|
+
TITLE = "Governance & Community"
|
|
13
|
+
|
|
14
|
+
REQUIRED = [
|
|
15
|
+
("LICENSE", ("LICENSE", "LICENSE.md", "LICENSE-APACHE", "LICENSE-MIT")),
|
|
16
|
+
("CODE_OF_CONDUCT", ("CODE_OF_CONDUCT.md",)),
|
|
17
|
+
("CONTRIBUTING", ("CONTRIBUTING.md",)),
|
|
18
|
+
("SECURITY", ("SECURITY.md",)),
|
|
19
|
+
("SUPPORT", ("SUPPORT.md",)),
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run(repo: Repo) -> list[Result]:
|
|
24
|
+
missing = [name for name, candidates in REQUIRED if not repo.exists(*candidates)]
|
|
25
|
+
files = (
|
|
26
|
+
Result("10.1", "Community health files present", Status.FAIL, f"missing {', '.join(missing)}")
|
|
27
|
+
if missing
|
|
28
|
+
else Result("10.1", "Community health files present", Status.PASS, "all five present")
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
citation = repo.read("CITATION.cff")
|
|
32
|
+
if citation is None:
|
|
33
|
+
cff = Result("10.2", "CITATION.cff present and parseable", Status.FAIL, "absent")
|
|
34
|
+
elif all(key in citation for key in ("cff-version", "title", "authors")):
|
|
35
|
+
cff = Result("10.2", "CITATION.cff present and parseable", Status.PASS, "required keys present")
|
|
36
|
+
else:
|
|
37
|
+
cff = Result("10.2", "CITATION.cff present and parseable", Status.FAIL,
|
|
38
|
+
"missing cff-version, title or authors")
|
|
39
|
+
|
|
40
|
+
templates = repo.root / ".github"
|
|
41
|
+
has_issue = (templates / "ISSUE_TEMPLATE").is_dir() or (templates / "issue_template.md").exists()
|
|
42
|
+
has_pr = any((templates / n).exists() for n in ("PULL_REQUEST_TEMPLATE.md", "pull_request_template.md"))
|
|
43
|
+
if has_issue and has_pr:
|
|
44
|
+
tmpl = Result("10.3", "Issue and pull request templates", Status.PASS, "both present")
|
|
45
|
+
else:
|
|
46
|
+
absent = [n for n, ok in (("issue", has_issue), ("pull request", has_pr)) if not ok]
|
|
47
|
+
tmpl = Result("10.3", "Issue and pull request templates", Status.FAIL,
|
|
48
|
+
f"no {' or '.join(absent)} template")
|
|
49
|
+
|
|
50
|
+
# Dependabot or Renovate: an ecosystem this size cannot be updated by hand.
|
|
51
|
+
has_bot = repo.exists(".github/dependabot.yml", ".github/dependabot.yaml",
|
|
52
|
+
"renovate.json", ".github/renovate.json")
|
|
53
|
+
updates = (
|
|
54
|
+
Result("10.7", "Dependency update automation", Status.PASS, "configured")
|
|
55
|
+
if has_bot
|
|
56
|
+
else Result("10.7", "Dependency update automation", Status.FAIL,
|
|
57
|
+
"no dependabot or renovate config")
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
support = repo.read("SUPPORT.md") or ""
|
|
61
|
+
window = re.search(r"latest\s+`?\d|supported versions|support window", support, re.IGNORECASE)
|
|
62
|
+
policy = (
|
|
63
|
+
Result("10.8", "Stated support window", Status.PASS, "SUPPORT.md states what is supported")
|
|
64
|
+
if window
|
|
65
|
+
else Result("10.8", "Stated support window", Status.FAIL,
|
|
66
|
+
"SUPPORT.md does not say which versions get fixes")
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
roadmap = repo.exists("ROADMAP.md", "docs/ROADMAP.md", "docs/ECOSYSTEM.md")
|
|
70
|
+
plan = (
|
|
71
|
+
Result("10.10", "Public roadmap", Status.PASS, "present")
|
|
72
|
+
if roadmap
|
|
73
|
+
else Result("10.10", "Public roadmap", Status.FAIL, "none found")
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return [
|
|
77
|
+
files, cff, tmpl,
|
|
78
|
+
Result("10.4", "Branch protection: signed commits, required checks", Status.UNVERIFIED,
|
|
79
|
+
"needs the GitHub API and repository admin scope"),
|
|
80
|
+
Result("10.5", "OpenSSF Scorecard >= 9.0", Status.UNVERIFIED,
|
|
81
|
+
"needs the Scorecard API; a badge in a README is a claim, not a reading"),
|
|
82
|
+
Result("10.6", "OpenSSF Baseline level met", Status.UNVERIFIED,
|
|
83
|
+
"no machine-readable Baseline assertion exists in this repository"),
|
|
84
|
+
updates, policy,
|
|
85
|
+
Result("10.9", "Shared reusable CI across the ecosystem", Status.UNVERIFIED,
|
|
86
|
+
"requires comparing against sibling repositories, which this tool scores one at a time"),
|
|
87
|
+
plan,
|
|
88
|
+
]
|