Files
Edward Cheng-I Wu edb0265301 refactor: consolidate markdown-stripping helpers into scripts/_markdown_lint_util.py (#771) (#791)
* refactor: consolidate the duplicated markdown-stripping helpers into scripts/_markdown_lint_util.py (#771)

The #757/#758 defrift lints shipped two diverging copies of the markdown
non-rendering semantics; check_risk_register.py was already importing the
siblings' private helpers as a stopgap. One shared module now carries the
grammar; per #771 the #770 superset rules (inline code-span stripping +
image exclusion in the link grammar) win, so CA-1..CA-3 inherit them too.

Pure refactor, no invariant change. All three mutation suites (73 tests)
pass unchanged against the shared module; the three lints pass on the
real tree.

Closes #771

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NZwcSFBwiJBZEtsSTcCxq

* review: address codex findings on #771 — honest CHANGELOG framing, #759 attribution, three CA grammar tests

- CHANGELOG no longer calls the change a pure refactor: the CA-side
  code-span/image grammar alignment is behavior-visible (strengthening
  direction) and is stated as such.
- _markdown_lint_util docstring attributes check_risk_register to #759
  (it shipped there; #760 is the governance change).
- Three new CA mutation tests pin the inherited rules: broken image
  target / backticked pseudo-link do not fire CA-1; an image form of the
  README inbound link does not satisfy CA-3 (73 -> 76 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NZwcSFBwiJBZEtsSTcCxq

* simplify: complete the #771 consolidation per four-angle cleanup review

- github_slug / heading_slugs move into _markdown_lint_util.py — the last
  cross-lint private import (check_risk_register -> check_control_
  availability._heading_slugs) is gone; each lint now imports only the
  shared module plus genuinely data-owning dependencies.
- New links_to() predicate absorbs the three copy-pasted CA-3/DF-3/RR-3
  inbound-link loops; link_targets()/code_spans() named wrappers replace
  raw regex exports (regexes back to module-private); NON_RELATIVE_LINK_
  PREFIXES replaces repeated literal tuples.
- Fence state machine collapses to a single `fence` variable; helper
  strip passes back to module-private; extract_link_targets uses findall.
- The grammar gains a direct, manifest-registered test suite
  (test__markdown_lint_util.py); the two CA tests that re-pinned grammar
  already pinned in the DF/RR suites are dropped, keeping the CA-3
  image-exclusion mutation test. Consolidation history now lives in the
  CHANGELOG once instead of four prose sites.

87 tests green across the four suites; all three lints PASS on the tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NZwcSFBwiJBZEtsSTcCxq

* docs: qualify the cross-lint import claim (codex P3) — markdown-helper imports only

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015NZwcSFBwiJBZEtsSTcCxq

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 12:53:31 +08:00

253 lines
9.3 KiB
Python

#!/usr/bin/env python3
"""Defrift lint for docs/DATA_FLOWS.md (#758).
The data-flow map claims to be the exhaustive user-facing inventory of the
network touchpoints and local stores the suite's own scripts create. A claim
of exhaustiveness is exactly the kind that silently rots, so the lint pins
the coverage direction mechanically and deliberately nothing else — row
semantics (payload class, TTL, off switch) stay owned by code review:
DF-1 Every non-test Python file under scripts/ that IMPORTS a network
module (AST import scan, so a no-call guard merely naming
"urllib.request" in a forbidden-list string does not count) must be
named in docs/DATA_FLOWS.md. A new resolver cannot land without a
row on the map.
DF-2 Every shell script under scripts/ or hooks/ that invokes `curl`
(outside comments) must be named in docs/DATA_FLOWS.md.
DF-3 README.md, SECURITY.md, and THIRD_PARTY.md each carry a RENDERED
markdown link whose destination resolves to docs/DATA_FLOWS.md
(the #758 acceptance criterion, pinned so a refactor cannot orphan
the map). Non-rendering markdown counts for nothing: fenced code
(CommonMark fence-length closing rule) and HTML comments (inline
spans + line/blockquote-level type-2 blocks) are stripped first.
Rendered-link / non-rendering grammar: scripts/_markdown_lint_util.py
(#771 consolidation).
Exit 0 when all invariants hold; exit 1 with one line per violation.
"""
from __future__ import annotations
import ast
import re
import sys
from pathlib import Path
from _markdown_lint_util import links_to
DOC_RELPATH = Path("docs/DATA_FLOWS.md")
INBOUND_LINK_SURFACES = (
Path("README.md"),
Path("SECURITY.md"),
Path("THIRD_PARTY.md"),
Path("docs/SETUP.md"),
)
# Direct network capability at the Python level. The vocabulary is the
# network subset of check_indirect_prompt_injection_no_call.py's
# FORBIDDEN_IMPORTS (that lint's full set also bans process/loader modules
# like subprocess/asyncio/importlib, which many scripts here use
# legitimately — importing it wholesale would false-fire). Deliberate
# deviations from the sibling set: bare `urllib` / `http` are replaced by
# the dotted forms below (urllib.parse and http.HTTPStatus are not network
# calls), and `ssl` is excluded (building a TLS context is not a data
# flow).
NETWORK_TOP_LEVEL = {
"aiohttp",
"anthropic",
"boto3",
"ftplib",
"grpc",
"httpx",
"openai",
"paramiko",
"requests",
"smtplib",
"socket",
"telnetlib",
"urllib3",
"websocket",
"websockets",
}
NETWORK_DOTTED = {
"urllib.request",
"http.client",
}
_SHELL_QUOTED_RE = re.compile(r"'[^']*'|\"[^\"]*\"")
_ENV_ASSIGN_RE = re.compile(r"^[A-Za-z_][A-Za-z_0-9]*=")
# Shell control words that precede a command without consuming its command
# position: `if curl …; then`, `while ! curl …; do`, `exec curl …`.
_SHELL_CONTROL_TOKENS = {
"if",
"elif",
"while",
"until",
"then",
"else",
"do",
"!",
"time",
"exec",
}
def _invokes_curl(code: str) -> bool:
"""True when curl appears in COMMAND POSITION: the first token of a
segment (split on pipes/separators/substitution openers) after skipping
VAR=value assignment prefixes and shell control words, with any path
prefix allowed (`/usr/bin/curl`). `command -v curl`, `echo curl`, and
other argument-position mentions do not count. Accepted edges:
wrapper-prefixed invocations (`sudo curl`, `timeout 3 curl`,
`xargs curl`) are missed — the surfaces don't write them."""
for segment in re.split(r"[|;&(`]|\$\(", code):
head = None
for token in segment.split():
if _ENV_ASSIGN_RE.match(token):
continue # VAR=value prefix before the command word
if token in _SHELL_CONTROL_TOKENS:
continue # control word — the command follows
if token.startswith("-"):
continue # an option to a control word (`time -p curl …`)
head = token
break
if head is not None and head.rsplit("/", 1)[-1] == "curl":
return True
return False
def _imported_modules(py_source: str) -> set[str]:
"""Top-level dotted module names imported anywhere in the file."""
try:
tree = ast.parse(py_source)
except SyntaxError:
return set()
modules: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
modules.add(alias.name)
elif isinstance(node, ast.ImportFrom):
if node.module is None or node.level:
continue # relative import — repo-internal, not a network module
modules.add(node.module)
# `from urllib import request` imports urllib.request without the
# dotted name appearing as node.module.
for alias in node.names:
modules.add(f"{node.module}.{alias.name}")
return modules
def _is_network_module(module: str) -> bool:
if module.split(".", 1)[0] in NETWORK_TOP_LEVEL:
return True
return any(
module == net or module.startswith(net + ".")
for net in NETWORK_DOTTED
)
def _python_network_scripts(root: Path) -> list[Path]:
hits: list[Path] = []
# Recursive on purpose: scripts/verification_gate/,
# scripts/cross_model_verification/, scripts/adapters/ are exactly where
# a future resolver call would plausibly land.
for py in sorted((root / "scripts").rglob("*.py")):
if py.name.startswith("test_") or "tests" in py.parts:
continue
modules = _imported_modules(py.read_text(encoding="utf-8"))
if any(_is_network_module(m) for m in modules):
hits.append(py)
return hits
def _curl_shell_scripts(root: Path) -> list[Path]:
hits: list[Path] = []
for base in ("scripts", "hooks"):
for sh in sorted((root / base).rglob("*.sh")):
for line in sh.read_text(encoding="utf-8").split("\n"):
if line.lstrip().startswith("#"):
continue # a full comment line executes nothing
# Command substitutions execute even inside double quotes:
# `resp="$(curl ...)"` is a real network call, so scan their
# bodies FIRST, before quote masking can hide them. (Known
# accepted edge: a $(curl) inside a trailing inline comment
# would false-fire; the surfaces don't write that.)
subs = re.findall(r"\$\(([^)]*)\)", line)
if any(_invokes_curl(sub) for sub in subs):
hits.append(sh)
break
# Then mask quoted spans so `echo "run curl ..."` (quoted
# instructional text, no curl process) cannot fire, and drop
# the comment tail.
code = _SHELL_QUOTED_RE.sub("", line).split("#", 1)[0]
if _invokes_curl(code):
hits.append(sh)
break
return hits
def check_network_coverage(root: Path) -> list[str]:
"""DF-1 + DF-2: every network-capable script is named on the map.
Naming means the FULL repo-relative path appears in the doc — basename
matching would let a new `scripts/client.py` ride on the substring
inside `scripts/semantic_scholar_client.py`.
"""
errors: list[str] = []
doc_text = (root / DOC_RELPATH).read_text(encoding="utf-8")
for path in _python_network_scripts(root):
if path.relative_to(root).as_posix() not in doc_text:
errors.append(
f"DF-1: {path.relative_to(root)} imports a network module "
f"but is not named in {DOC_RELPATH} — new or renamed "
f"network touchpoint missing from the map"
)
for path in _curl_shell_scripts(root):
if path.relative_to(root).as_posix() not in doc_text:
errors.append(
f"DF-2: {path.relative_to(root)} invokes curl but is not "
f"named in {DOC_RELPATH}"
)
return errors
def check_inbound_links(root: Path) -> list[str]:
"""DF-3: the three surfaces carry a rendered link resolving to the map."""
errors: list[str] = []
doc_abs = (root / DOC_RELPATH).resolve()
for rel in INBOUND_LINK_SURFACES:
surface = root / rel
text = surface.read_text(encoding="utf-8")
if not links_to(text, surface.parent, doc_abs):
errors.append(
f"DF-3: {rel} no longer links to {DOC_RELPATH.name} "
f"(#758 acceptance criterion)"
)
return errors
def run_all_checks(root: Path) -> list[str]:
if not (root / DOC_RELPATH).exists():
return [f"DF-1: {DOC_RELPATH} is missing"]
errors: list[str] = []
errors.extend(check_network_coverage(root))
errors.extend(check_inbound_links(root))
return errors
def main() -> int:
root = Path(__file__).resolve().parent.parent
errors = run_all_checks(root)
if errors:
for line in errors:
print(f"ERROR: {line}", file=sys.stderr)
print(f"check_data_flows: {len(errors)} violation(s)", file=sys.stderr)
return 1
print("check_data_flows: OK (DF-1..DF-3)")
return 0
if __name__ == "__main__":
sys.exit(main())