mirror of
https://github.com/github/awesome-copilot.git
synced 2026-09-09 09:15:17 +00:00
* feat(skills): add test-gap-audit and docs-sync-audit Two repo-agnostic review skills that answer questions the existing testing and documentation skills do not. test-gap-audit asks which behaviour is not covered, rather than how to write a test in a given framework. Given no scope it audits the whole repository, inventories the testable surfaces, and reports which routes, services, jobs and contracts have no tests, too few assertions, or only indirect coverage. It bundles coverage_map.py, which detects the test framework and naming convention, then matches every source file to tests by name, by mirrored path, and by what the test files actually import, and ranks the unmatched by risk keyword and size. docs-sync-audit compares what the docs claim against what the code does. It bundles docs_drift.py, which checks documented npm scripts and make targets against the ones that exist, relative Markdown links against the filesystem, and environment variable names in both directions. It also reports a documented setting that is read only inside a module nothing imports, which is configuration that reads as working but cannot take effect. Both are read-only: they report and do not edit unless asked. Both emit the same contract, so a finding always carries a P0-P3 severity and a path:line you can open. Both scripts are Python standard library only, install nothing, and are accelerators rather than requirements, so each skill still works when the script cannot run. The existing testing and docs skills here are framework-specific, which is where most of the value is. These are the repo-agnostic complement: pytest-coverage raises coverage inside a pytest project, and this decides where coverage is missing across a repository regardless of language. * fix: satisfy codespell and regenerate all generated docs Two CI failures on the first push. codespell flagged `testng` and `shouldBe` in coverage_map.py. Both are legitimate identifiers rather than typos: TestNG is the Java test framework the script detects by name, and shouldBe is the Kotlin and Scala assertion method matched by its assertion-detection regex. Added both to ignore-words-list with a comment each, following the convention already used for the other entries. validate-readme failed because I had reverted docs/README.agents.md. `npm start` rewrites a Dynatrace MCP URL there from re-fetched external plugin data, which is unrelated to these skills, so I had excluded it to keep the diff scoped. That was wrong: the check regenerates every generated file and compares, so the commit has to carry whatever the build produces. Restored. --------- Co-authored-by: Aaron Powell <me@aaron-powell.com>
475 lines
21 KiB
Python
475 lines
21 KiB
Python
#!/usr/bin/env python3
|
|
"""Check machine-verifiable documentation claims against the repository.
|
|
|
|
Read-only. Standard library only. Writes nothing.
|
|
|
|
python docs_drift.py # text report
|
|
python docs_drift.py --repo ../other # a different repo
|
|
python docs_drift.py --format json
|
|
python docs_drift.py --no-git-root # scope to one package of a monorepo
|
|
|
|
Only checks claims that have a definite answer:
|
|
|
|
commands `npm run x`, `make x`, `./scripts/x` in a fenced block, against the
|
|
scripts, targets and files that actually exist
|
|
links relative Markdown links and images, against the filesystem
|
|
paths backticked paths, against the filesystem (opt-in, --check-paths)
|
|
env vars names documented in docs or .env.example, against names actually
|
|
read by the code, in both directions
|
|
staleness a doc untouched for far longer than the code it describes
|
|
|
|
It does not judge prose. Wording, tone, completeness and accuracy of explanation
|
|
are the reviewing agent's job; this exists so the agent does not spend forty tool
|
|
calls confirming whether a path exists.
|
|
|
|
Backticked-path checking is opt-in. On real repositories most such references are
|
|
ambiguous -- a path the doc is telling you to create, or one an archived report
|
|
described accurately at the time -- and reporting them buries the findings that
|
|
are unambiguous. Markdown links are always checked, because a link is a promise
|
|
to resolve.
|
|
|
|
Values are never read out of environment files. Only the names to the left of `=`
|
|
are used, because the right-hand side is a credential by design.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
if hasattr(sys.stdout, "reconfigure"):
|
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
|
|
GIT_TIMEOUT = 30
|
|
MAX_READ = 2_000_000
|
|
STALE_DAYS = 120
|
|
|
|
SKIP_DIRS = {
|
|
".git", "node_modules", "vendor", "venv", ".venv", "dist", "build", "target",
|
|
"__pycache__", ".next", "coverage", ".terraform", "site-packages",
|
|
}
|
|
DOC_EXTS = {".md", ".mdx", ".rst", ".txt"}
|
|
CODE_EXTS = {
|
|
".py", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".vue", ".svelte",
|
|
".go", ".rs", ".rb", ".php", ".java", ".kt", ".swift", ".cs", ".ex", ".exs", ".sh",
|
|
}
|
|
|
|
FENCE = re.compile(r"^```")
|
|
# Commands worth checking. Anything else in a fenced block is left alone.
|
|
CMD_NPM = re.compile(r"\b(?:npm|pnpm|yarn|bun)\s+run\s+([A-Za-z0-9:_.-]+)")
|
|
CMD_MAKE = re.compile(r"\bmake\s+([A-Za-z0-9_.-]+)")
|
|
CMD_SCRIPT = re.compile(r"(?:^|\s)(\./[A-Za-z0-9_./-]+|(?:python3?|node|bash|sh|ruby)\s+([A-Za-z0-9_./-]+\.[A-Za-z0-9]+))")
|
|
|
|
MD_LINK = re.compile(r"!?\[[^\]]*\]\(([^)\s]+)")
|
|
BACKTICK = re.compile(r"`([^`\n]+)`")
|
|
|
|
# Placeholder shapes that are not meant to resolve.
|
|
PLACEHOLDER = re.compile(
|
|
r"[<>{}$*]|^\.{3}|\.{3}$|(^|/)(path/to|your[-_]|my[-_]|example|foo|bar|baz|placeholder)",
|
|
re.I)
|
|
|
|
ENV_IN_CODE = [
|
|
re.compile(r"process\.env\.([A-Z][A-Z0-9_]*)"),
|
|
re.compile(r"""process\.env\[\s*['"]([A-Z][A-Z0-9_]*)['"]"""),
|
|
re.compile(r"""os\.environ(?:\.get)?\[?\(?\s*['"]([A-Z][A-Z0-9_]*)['"]"""),
|
|
re.compile(r"""os\.getenv\(\s*['"]([A-Z][A-Z0-9_]*)['"]"""),
|
|
re.compile(r"""getenv\(\s*['"]([A-Z][A-Z0-9_]*)['"]"""),
|
|
re.compile(r"""ENV\[\s*['"]([A-Z][A-Z0-9_]*)['"]"""),
|
|
re.compile(r"""Deno\.env\.get\(\s*['"]([A-Z][A-Z0-9_]*)['"]"""),
|
|
]
|
|
ENV_NAME = re.compile(r"\b([A-Z][A-Z0-9_]{2,})\b")
|
|
|
|
# Import forms across the languages handled above. Four alternatives, so findall
|
|
# returns tuples and the caller takes the first non-empty group.
|
|
IMPORT_SPEC = re.compile(
|
|
r"""(?:from|import)\s+['"]([^'"]+)['"]"""
|
|
r"""|require\(\s*['"]([^'"]+)['"]\s*\)"""
|
|
r"""|^\s*from\s+([A-Za-z0-9_.]+)\s+import"""
|
|
r"""|^\s*import\s+([A-Za-z0-9_.]+)""",
|
|
re.M)
|
|
|
|
warnings: list[str] = []
|
|
|
|
|
|
def run_git(args: list[str], cwd: Path) -> str | None:
|
|
git = shutil.which("git")
|
|
if git is None:
|
|
return None
|
|
try:
|
|
p = subprocess.run([git, *args], cwd=str(cwd), text=True, timeout=GIT_TIMEOUT,
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
encoding="utf-8", errors="replace")
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
warnings.append(f"git {' '.join(args[:2])} failed: {exc}")
|
|
return None
|
|
return p.stdout if p.returncode == 0 else None
|
|
|
|
|
|
def list_files(repo: Path) -> list[str]:
|
|
out = run_git(["ls-files", "--cached", "--other", "--exclude-standard"], repo)
|
|
if out is not None and out.strip():
|
|
return sorted(x.strip() for x in out.splitlines() if x.strip())
|
|
warnings.append("git unavailable or empty index; walking the filesystem instead")
|
|
files = []
|
|
for p in repo.rglob("*"):
|
|
if p.is_file() and not any(part in SKIP_DIRS for part in p.parts):
|
|
files.append(p.relative_to(repo).as_posix())
|
|
return sorted(files)
|
|
|
|
|
|
def read(path: Path) -> str:
|
|
try:
|
|
if path.stat().st_size > MAX_READ:
|
|
return ""
|
|
return path.read_text(encoding="utf-8", errors="replace")
|
|
except OSError:
|
|
return ""
|
|
|
|
|
|
def available_commands(repo: Path, files: list[str]) -> tuple[dict[str, set[str]], set[str]]:
|
|
"""Real npm scripts and make targets, keyed by the directory that declares them."""
|
|
npm: dict[str, set[str]] = {}
|
|
make: set[str] = set()
|
|
for rel in files:
|
|
base = Path(rel).name
|
|
prefix = str(Path(rel).parent).replace("\\", "/")
|
|
if base == "package.json":
|
|
try:
|
|
data = json.loads(read(repo / rel))
|
|
except ValueError:
|
|
continue
|
|
if isinstance(data.get("scripts"), dict):
|
|
npm.setdefault(prefix, set()).update(data["scripts"].keys())
|
|
elif base == "Makefile":
|
|
targets = re.findall(r"^([A-Za-z0-9][A-Za-z0-9_.-]*):(?!=)", read(repo / rel), re.M)
|
|
make.update(targets)
|
|
return npm, make
|
|
|
|
|
|
def fenced_blocks(text: str) -> list[tuple[int, str]]:
|
|
"""Yield (line_number, line) for lines inside fenced code blocks."""
|
|
out: list[tuple[int, str]] = []
|
|
inside = False
|
|
for i, line in enumerate(text.splitlines(), start=1):
|
|
if FENCE.match(line.strip()):
|
|
inside = not inside
|
|
continue
|
|
if inside:
|
|
out.append((i, line))
|
|
return out
|
|
|
|
|
|
def env_names_from_code(repo: Path, files: list[str]) -> dict[str, list[str]]:
|
|
"""Env var names the code reads, mapped to every location that reads them."""
|
|
found: dict[str, list[str]] = {}
|
|
for rel in files:
|
|
if Path(rel).suffix not in CODE_EXTS or any(p in SKIP_DIRS for p in Path(rel).parts):
|
|
continue
|
|
text = read(repo / rel)
|
|
if not text or "env" not in text.lower():
|
|
continue
|
|
for i, line in enumerate(text.splitlines(), start=1):
|
|
for pattern in ENV_IN_CODE:
|
|
for name in pattern.findall(line):
|
|
found.setdefault(name, []).append(f"{rel}:{i}")
|
|
return found
|
|
|
|
|
|
def unreferenced_modules(repo: Path, files: list[str]) -> set[str]:
|
|
"""Code files that nothing imports, and that are not plausible entrypoints.
|
|
|
|
A documented setting read only inside such a file is configuration that cannot
|
|
take effect, which reads as working config in the docs. Deliberately
|
|
conservative: basename matching, and anything entrypoint-shaped is excluded, so
|
|
it under-reports rather than accusing live code of being dead.
|
|
"""
|
|
code = [f for f in files
|
|
if Path(f).suffix in CODE_EXTS and not any(p in SKIP_DIRS for p in Path(f).parts)]
|
|
entrypoint = re.compile(
|
|
r"(^|/)(server|main|index|app|cli|__init__|__main__|conftest|setup|wsgi|asgi)\.[A-Za-z]+$",
|
|
re.I)
|
|
imported: set[str] = set()
|
|
for rel in code:
|
|
text = read(repo / rel)
|
|
if not text:
|
|
continue
|
|
for groups in IMPORT_SPEC.findall(text):
|
|
ref = next((g for g in groups if g), "").strip()
|
|
if not ref:
|
|
continue
|
|
imported.add(Path(ref).name.lower())
|
|
imported.add(Path(ref).stem.lower())
|
|
for part in re.split(r"[./\:]", ref):
|
|
if part:
|
|
imported.add(part.lower())
|
|
out = set()
|
|
for rel in code:
|
|
if entrypoint.search(rel):
|
|
continue
|
|
stem = Path(rel).stem.lower()
|
|
if stem not in imported and Path(rel).name.lower() not in imported:
|
|
out.add(rel)
|
|
return out
|
|
|
|
|
|
def env_names_documented(repo: Path, files: list[str]) -> dict[str, str]:
|
|
"""Env var names named in docs or declared in an env sample file.
|
|
|
|
Only the key to the left of `=` is ever read from an env file. The value is a
|
|
credential by design and is never touched.
|
|
"""
|
|
documented: dict[str, str] = {}
|
|
for rel in files:
|
|
base = Path(rel).name
|
|
is_env_sample = base.startswith(".env")
|
|
if not is_env_sample and Path(rel).suffix not in DOC_EXTS:
|
|
continue
|
|
text = read(repo / rel)
|
|
if not text:
|
|
continue
|
|
for i, line in enumerate(text.splitlines(), start=1):
|
|
if is_env_sample:
|
|
stripped = line.strip()
|
|
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
|
continue
|
|
key = stripped.split("=", 1)[0].strip().lstrip("export ").strip()
|
|
if ENV_NAME.fullmatch(key or ""):
|
|
documented.setdefault(key, f"{rel}:{i}")
|
|
else:
|
|
# In prose, a backticked all-caps token is weak evidence: `SKILL.md`
|
|
# and `README` are not configuration. Require an underscore, which is
|
|
# what actually distinguishes API_TOKEN from a shouted word, and skip
|
|
# anything that looks like a filename.
|
|
for chunk in BACKTICK.findall(line):
|
|
if "." in chunk or "/" in chunk:
|
|
continue
|
|
for name in ENV_NAME.findall(chunk):
|
|
if "_" not in name:
|
|
continue
|
|
documented.setdefault(name, f"{rel}:{i}")
|
|
return documented
|
|
|
|
|
|
def newest_commit_epoch(repo: Path, pathspec: str) -> int | None:
|
|
out = run_git(["log", "-1", "--format=%at", "--", pathspec], repo)
|
|
if not out or not out.strip().isdigit():
|
|
return None
|
|
return int(out.strip())
|
|
|
|
|
|
def build(repo: Path, files: list[str], check_paths: bool = False) -> dict:
|
|
findings: list[dict] = []
|
|
|
|
def add(kind: str, severity: str, doc: str, line: int | None, detail: str,
|
|
source: str | None = None) -> None:
|
|
findings.append({"kind": kind, "severity": severity, "doc": doc, "line": line,
|
|
"detail": detail, "source": source})
|
|
|
|
file_set = set(files)
|
|
npm_scripts, make_targets = available_commands(repo, files)
|
|
all_npm = set().union(*npm_scripts.values()) if npm_scripts else set()
|
|
docs = [f for f in files
|
|
if Path(f).suffix in DOC_EXTS and not any(p in SKIP_DIRS for p in Path(f).parts)]
|
|
|
|
for doc in docs:
|
|
text = read(repo / doc)
|
|
if not text:
|
|
continue
|
|
|
|
# 1. commands
|
|
for lineno, line in fenced_blocks(text):
|
|
for script in set(CMD_NPM.findall(line)):
|
|
if not all_npm:
|
|
continue
|
|
if script not in all_npm:
|
|
near = ", ".join(sorted(s for s in all_npm if s.startswith(script.split(":")[0]))[:4])
|
|
hint = f" Closest existing: {near}." if near else ""
|
|
add("missing-script", "high", doc, lineno,
|
|
f"documents `{script}`, which is not a script in any package.json.{hint}",
|
|
source="package.json")
|
|
for target in set(CMD_MAKE.findall(line)):
|
|
if make_targets and target not in make_targets and target not in ("-j", "all"):
|
|
add("missing-make-target", "high", doc, lineno,
|
|
f"documents `make {target}`, which is not a target in the Makefile.",
|
|
source="Makefile")
|
|
for whole, inner in CMD_SCRIPT.findall(line):
|
|
candidate = (inner or whole).lstrip("./")
|
|
if not candidate or PLACEHOLDER.search(candidate):
|
|
continue
|
|
if candidate.endswith("/") or any(part in SKIP_DIRS
|
|
for part in Path(candidate).parts):
|
|
continue
|
|
if candidate not in file_set and not (repo / candidate).exists():
|
|
add("missing-script-file", "high", doc, lineno,
|
|
f"documents running `{candidate}`, which does not exist.")
|
|
|
|
# 2 and 3. links and backticked paths
|
|
for i, line in enumerate(text.splitlines(), start=1):
|
|
for target in MD_LINK.findall(line):
|
|
t = target.split("#")[0].strip()
|
|
if not t or t.startswith(("http://", "https://", "mailto:", "#", "tel:", "data:")):
|
|
continue
|
|
if PLACEHOLDER.search(t):
|
|
continue
|
|
if not (repo / Path(doc).parent / t).exists():
|
|
add("broken-link", "high", doc, i,
|
|
f"relative link `{t}` does not resolve.")
|
|
for chunk in (BACKTICK.findall(line) if check_paths else []):
|
|
c = chunk.strip()
|
|
# Only treat it as a path claim when it looks like one.
|
|
if "/" not in c or " " in c or PLACEHOLDER.search(c):
|
|
continue
|
|
# A leading slash means a slash-command or an absolute path, e.g.
|
|
# `/security-review`. Neither is a claim about this repository.
|
|
if c.startswith("/"):
|
|
continue
|
|
if not re.match(r"^[A-Za-z0-9._/-]+$", c) or c.endswith("/"):
|
|
continue
|
|
# Require a real file extension. Extensionless slashed tokens are
|
|
# ambiguous by nature -- `origin/staging` is a git ref, `src/utils/billing`
|
|
# is an illustrative example, `@scope/pkg` is a package. Accusing those
|
|
# of being broken paths produced far more noise than signal.
|
|
if not re.match(r"^\.[A-Za-z0-9]{1,5}$", Path(c).suffix):
|
|
continue
|
|
if c in file_set or (repo / c).exists():
|
|
continue
|
|
# Docs routinely write paths relative to their own directory.
|
|
if (repo / Path(doc).parent / c).exists():
|
|
continue
|
|
# A directory prefix that exists is close enough not to report.
|
|
if any(f.startswith(c.rstrip("/") + "/") for f in file_set):
|
|
continue
|
|
# Docs also reference a shape that repeats, e.g. `agents/openai.yaml`
|
|
# when the real files are skills/<name>/agents/openai.yaml. If it is
|
|
# the tail of a real path, the claim is true enough.
|
|
tail = "/" + c
|
|
if any(f.endswith(tail) for f in file_set):
|
|
continue
|
|
add("missing-path", "medium", doc, i,
|
|
f"references `{c}`, which does not exist in the repository.")
|
|
|
|
# 4. env vars, both directions
|
|
in_code = env_names_from_code(repo, files)
|
|
in_docs = env_names_documented(repo, files)
|
|
dead = unreferenced_modules(repo, files)
|
|
for name, where in sorted(in_docs.items()):
|
|
readers = in_code.get(name, [])
|
|
doc_path, _, doc_line = where.rpartition(":")
|
|
if not readers:
|
|
add("documented-unused-env", "medium", doc_path, int(doc_line),
|
|
f"`{name}` is documented but nothing in the code reads it. "
|
|
"Either it is dead configuration or the docs promise a knob that does not exist.",
|
|
source="no reader found")
|
|
elif all(r.rsplit(":", 1)[0] in dead for r in readers):
|
|
where_read = ", ".join(readers[:3])
|
|
add("documented-env-in-unreferenced-module", "medium", doc_path, int(doc_line),
|
|
f"`{name}` is read only in a module nothing imports, so the documented setting "
|
|
"cannot take effect. The docs describe a working knob that does nothing.",
|
|
source=where_read)
|
|
for name, readers in sorted(in_code.items()):
|
|
if name not in in_docs:
|
|
add("undocumented-env", "medium", "(docs)", None,
|
|
f"`{name}` is read by the code but is not documented anywhere, "
|
|
"and is not in an env sample file.",
|
|
source=readers[0])
|
|
|
|
# 5. staleness
|
|
code_dirs = {str(Path(f).parent).replace("\\", "/") for f in files
|
|
if Path(f).suffix in CODE_EXTS}
|
|
newest_code = max((e for e in (newest_commit_epoch(repo, d) for d in list(code_dirs)[:40])
|
|
if e), default=None)
|
|
if newest_code:
|
|
for doc in docs:
|
|
doc_epoch = newest_commit_epoch(repo, doc)
|
|
if not doc_epoch:
|
|
continue
|
|
days = (newest_code - doc_epoch) / 86400
|
|
if days > STALE_DAYS:
|
|
add("stale-doc", "low", doc, None,
|
|
f"last changed {int(days)} days before the most recent code change. "
|
|
"Not wrong by itself, but worth reading against current behavior.")
|
|
|
|
order = {"high": 0, "medium": 1, "low": 2}
|
|
findings.sort(key=lambda f: (order.get(f["severity"], 3), f["doc"], f["line"] or 0))
|
|
return {
|
|
"repo": str(repo),
|
|
"totals": {"docs_checked": len(docs), "findings": len(findings),
|
|
"npm_scripts_found": len(all_npm), "make_targets_found": len(make_targets),
|
|
"env_names_in_code": len(in_code), "env_names_documented": len(in_docs)},
|
|
"findings": findings,
|
|
"warnings": warnings,
|
|
}
|
|
|
|
|
|
def render(d: dict, top: int) -> str:
|
|
t = d["totals"]
|
|
L = ["# Documentation Drift Check", "", f"Repo: {d['repo']}",
|
|
f"Docs checked: {t['docs_checked']} Findings: {t['findings']}",
|
|
f"Known npm scripts: {t['npm_scripts_found']} make targets: {t['make_targets_found']}",
|
|
f"Env names in code: {t['env_names_in_code']} documented: {t['env_names_documented']}", ""]
|
|
|
|
if not d["findings"]:
|
|
L.append("No machine-verifiable drift found. Prose accuracy is still unchecked.")
|
|
else:
|
|
shown = d["findings"][:top]
|
|
if len(d["findings"]) > top:
|
|
L.append(f"TRUNCATED: showing {top} of {len(d['findings'])} findings")
|
|
L.append("")
|
|
for f in shown:
|
|
loc = f"{f['doc']}:{f['line']}" if f.get("line") else f["doc"]
|
|
L.append(f"- [{f['severity']}] {f['kind']} -- {loc}")
|
|
L.append(f" {f['detail']}")
|
|
if f.get("source"):
|
|
L.append(f" source of truth: {f['source']}")
|
|
L.append("")
|
|
if d["warnings"]:
|
|
L.append("## Warnings")
|
|
L.extend(f"- {w}" for w in d["warnings"])
|
|
L.append("")
|
|
L.append("Checks only claims with a definite answer. Wording, completeness and whether an")
|
|
L.append("explanation is actually correct are not checked here. Confirm each finding by")
|
|
L.append("opening both the doc and the source before reporting it.")
|
|
return "\n".join(L)
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description="Check documentation claims against the repo. Read-only.")
|
|
ap.add_argument("--repo", default=".", help="Path inside the repository.")
|
|
ap.add_argument("--format", choices=["text", "json"], default="text", help="Output format.")
|
|
ap.add_argument("--top", type=int, default=30, help="Findings to show. Default 30.")
|
|
ap.add_argument("--check-paths", action="store_true",
|
|
help=("Also check backticked paths against the filesystem. Off by default: on "
|
|
"real repos most such references are ambiguous -- a path a doc tells you "
|
|
"to create, or one an archived report described at the time -- and the "
|
|
"noise buries the unambiguous findings. Markdown links are always checked."))
|
|
ap.add_argument("--no-git-root", action="store_true",
|
|
help="Treat --repo literally instead of expanding to the enclosing git repository root.")
|
|
args = ap.parse_args()
|
|
|
|
repo = Path(args.repo).resolve()
|
|
if not repo.is_dir():
|
|
print(f"error: not a directory: {repo}", file=sys.stderr)
|
|
return 2
|
|
if not args.no_git_root:
|
|
root = run_git(["rev-parse", "--show-toplevel"], repo)
|
|
if root and root.strip():
|
|
repo = Path(root.strip()).resolve()
|
|
|
|
files = list_files(repo)
|
|
if not files:
|
|
print(f"error: no files found under {repo}", file=sys.stderr)
|
|
return 2
|
|
|
|
data = build(repo, files, check_paths=args.check_paths)
|
|
print(json.dumps(data, indent=2) if args.format == "json" else render(data, args.top))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|