Files
magnus919_agent-skills/spring-ai/scripts/spring_ai_check.py
T
Magnus HedemarkandGitHub 3c0a21493a feat(spring-ai): add offline integration diagnostics (#508)
* feat(skill): add spring ai diagnostics

* fix(spring-ai): harden project compatibility diagnostics

* Register Spring AI skill and normalize metadata

* fix(spring-ai): scope Maven versions to dependency elements

* fix(spring-ai): validate the shipped diagnostic fixture

* docs(spring-ai): state the limits of static diagnostics

* docs(spring-ai): clarify warning exit semantics

* test(spring-ai): load the sibling diagnostic module explicitly

* fix(spring-ai): ignore Java block comments in diagnostics

* fix(spring-ai): preserve Java text block contents
2026-09-14 17:34:07 -04:00

391 lines
14 KiB
Python
Executable File

#!/usr/bin/env python3
"""Read-only, offline diagnostics for Maven and Gradle Spring AI projects."""
import argparse
import json
import re
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
VERSION = r"[0-9]+\.[0-9]+(?:\.[0-9]+)?(?:-[A-Za-z0-9.-]+)?"
def finding(code, severity, message, paths, evidence):
return {
"code": code,
"severity": severity,
"message": message,
"paths": paths,
"evidence": evidence,
}
def read(path):
try:
return path.read_text(encoding="utf-8")
except UnicodeDecodeError as exc:
raise RuntimeError(f"cannot decode {path} as UTF-8") from exc
except OSError as exc:
raise RuntimeError(f"cannot read {path}: {exc}") from exc
def strip_comments(text):
text = re.sub(r"<!--.*?-->", "", text, flags=re.S)
text = re.sub(r"(?m)^\s*//.*$", "", text)
text = re.sub(r"(?m)^\s*#.*$", "", text)
return text
def strip_java_comments(text):
"""Remove Java comments while preserving Java literals, including text blocks."""
result = []
index = 0
state = "normal"
while index < len(text):
char = text[index]
next_char = text[index + 1] if index + 1 < len(text) else ""
if state == "normal":
if text.startswith('"""', index):
state = "text_block"
result.append('"""')
index += 2
elif char == '"':
state = "string"
result.append(char)
elif char == "'":
state = "char"
result.append(char)
elif char == "/" and next_char == "/":
state = "line"
result.append(" ")
index += 1
elif char == "/" and next_char == "*":
state = "block"
result.append(" ")
index += 1
else:
result.append(char)
elif state == "line":
if char == "\n":
state = "normal"
result.append(char)
elif state == "block":
if char == "*" and next_char == "/":
state = "normal"
result.append(" ")
index += 1
elif char == "\n":
result.append("\n")
elif state == "text_block":
if text.startswith('"""', index):
backslashes = 0
cursor = index - 1
while cursor >= 0 and text[cursor] == "\\":
backslashes += 1
cursor -= 1
if backslashes % 2 == 0:
state = "normal"
result.append('"""')
index += 2
else:
result.append(char)
else:
result.append(char)
else:
result.append(char)
if char == "\\" and next_char:
result.append(next_char)
index += 1
elif (state == "string" and char == '"') or (state == "char" and char == "'"):
state = "normal"
index += 1
return "".join(result)
def rel(root, paths):
return [str(path.relative_to(root)) for path in paths]
def maven_versions(text):
try:
root = ET.fromstring(text)
except ET.ParseError as exc:
raise ValueError("pom.xml is not well-formed XML") from exc
# Namespace-independent element access, scoped to the dependency or parent.
def child(node, name):
return next((x for x in node if x.tag.split("}")[-1] == name), None)
def value(node, name):
item = child(node, name)
return (item.text or "").strip() if item is not None else ""
props_node = child(root, "properties")
props = {
x.tag.split("}")[-1]: (x.text or "").strip()
for x in (props_node if props_node is not None else [])
}
ai, boot = set(), set()
for node in root.iter():
if node.tag.split("}")[-1] not in {"dependency", "parent"}:
continue
coordinate = (value(node, "groupId"), value(node, "artifactId"))
target = None
if coordinate == ("org.springframework.ai", "spring-ai-bom"):
target = ai
elif coordinate[0] == "org.springframework.boot" and coordinate[1] in {
"spring-boot-starter-parent", "spring-boot-dependencies"
}:
target = boot
if target is None:
continue
version = value(node, "version")
seen = set()
while version.startswith("${") and version.endswith("}") and version not in seen:
seen.add(version)
version = props.get(version[2:-1], version)
if re.fullmatch(VERSION, version):
target.add(version)
# A conventional property is a declared clue, not proof of dependency resolution.
for key in ("spring-ai.version", "spring-ai-version"):
if re.fullmatch(VERSION, props.get(key, "")):
ai.add(props[key])
return ai, boot, props
def gradle_versions(text):
clean = strip_comments(text)
variables = dict(
re.findall(r"(?:def\s+)?([A-Za-z][A-Za-z0-9_]*)\s*=\s*[\"']([^\"']+)[\"']", clean)
)
ai, boot = set(), set()
for match in re.finditer(r"spring-ai-bom:([^\"')\s]+)", clean):
value = match.group(1)
if value.startswith("${"):
value = variables.get(value[2:-1], value)
elif value.startswith("$"):
value = variables.get(value[1:], value)
if re.fullmatch(VERSION, value):
ai.add(value)
for key, value in variables.items():
if key.lower() in {"springaiversion", "spring_ai_version"} and re.fullmatch(VERSION, value):
ai.add(value)
if key.lower() in {"springbootversion", "spring_boot_version"} and re.fullmatch(
VERSION, value
):
boot.add(value)
return ai, boot, variables
def check(root):
build_files = [
path
for path in (
root / "pom.xml",
root / "build.gradle",
root / "build.gradle.kts",
root / "gradle.properties",
)
if path.exists()
]
if not build_files:
raise ValueError("no pom.xml, build.gradle, build.gradle.kts, or gradle.properties found")
config = []
for base in (root / "src/main/resources", root / "src/test/resources"):
for name in ("application.properties", "application.yml", "application.yaml"):
path = base / name
if path.exists():
config.append((path, read(path)))
java_files = [
path
for base in (root / "src/main/java", root / "src/test/java")
if base.exists()
for path in base.rglob("*.java")
]
build_text = "\n".join(read(path) for path in build_files)
clean_build = strip_comments(build_text)
if any(path.name == "pom.xml" for path in build_files):
ai_versions, boot_versions, variables = maven_versions(read(root / "pom.xml"))
else:
ai_versions, boot_versions, variables = gradle_versions(build_text)
source = [(path, strip_java_comments(read(path))) for path in java_files]
findings = []
if not ai_versions:
unresolved = bool(
re.search(r"spring-ai-bom|spring-ai\.version\s*[<>=:]", clean_build, re.I)
)
code = "SPRING_AI_VERSION_UNKNOWN" if unresolved else "SPRING_AI_VERSION_MISSING"
message = (
"Spring AI dependency management was found but its version is unresolved."
if unresolved
else "No Spring AI BOM or version property was detected."
)
findings.append(
finding(
code,
"warning",
message,
rel(root, build_files),
"static dependency scan; compatibility is not established",
)
)
memory_files = [path for path, text in source if "ChatMemory" in text]
if memory_files:
if not any("CONVERSATION_ID" in text for _, text in source):
findings.append(
finding(
"MEMORY_CONVERSATION_ID_MISSING",
"error",
"Chat memory symbols are present but no explicit conversation ID symbol was found.",
rel(root, memory_files),
"source scan",
)
)
findings.append(
finding(
"MEMORY_ISOLATION_UNPROVEN",
"warning",
"A conversation ID symbol does not prove user/tenant isolation; test the authenticated scope and persistence boundary.",
rel(root, memory_files),
"static scan cannot prove isolation",
)
)
stream_files = [path for path, text in source if re.search(r"\.stream\s*\(", text)]
if stream_files and "spring-boot-starter-webflux" not in clean_build:
findings.append(
finding(
"STREAMING_STACK_UNCLEAR",
"warning",
"Streaming code was found without a detected WebFlux starter; verify the selected Spring AI runtime stack.",
rel(root, build_files),
"source and dependency scan",
)
)
logging_files = [
path
for path, text in config
if re.search(r"log-(?:prompt|completion)\s*[:=]\s*true", strip_comments(text), re.I)
]
if logging_files:
findings.append(
finding(
"PROMPT_LOGGING_ENABLED",
"error",
"Prompt or completion logging is enabled; review redaction, access, retention, and rollback.",
rel(root, logging_files),
"configuration value is true",
)
)
literal_files = [
path
for path, text in config
if re.search(
r"spring\.ai\.[^\n]*(?:api-key|apiKey)\s*[:=]\s*(?!\$\{|\$)[^\s#]+",
strip_comments(text),
re.I,
)
]
if literal_files:
findings.append(
finding(
"PROVIDER_SECRET_LITERAL",
"error",
"A provider credential appears literal; use an environment or secret reference. The value is omitted.",
rel(root, literal_files),
"credential key detected; value redacted",
)
)
retrieval_files = [
path
for path, text in source
if re.search(r"QuestionAnswerAdvisor|RetrievalAugmentationAdvisor|VectorStore", text)
]
if retrieval_files:
findings.append(
finding(
"RETRIEVAL_AUTH_UNVERIFIED",
"warning",
"Retrieval integration was found; static scanning cannot establish authorization or tenant filtering before prompt construction.",
rel(root, retrieval_files),
"retrieval symbols detected",
)
)
tool_files = [
path for path, text in source if re.search(r"\.tools\s*\(|@Tool\b|ToolCallback|Mcp", text)
]
if tool_files:
findings.append(
finding(
"TOOL_AUTH_UNVERIFIED",
"warning",
"Tool or MCP integration was found; static scanning cannot establish authorization, argument validation, timeout, or side-effect controls.",
rel(root, tool_files),
"tool symbols detected",
)
)
facts = {
"build_files": rel(root, build_files),
"spring_ai_versions": sorted(ai_versions),
"spring_boot_versions": sorted(boot_versions),
"version_variables": sorted(variables),
"config_files": rel(root, [path for path, _ in config]),
"java_files": len(java_files),
"compatibility_assessment": "static-facts-only; consult official release and upgrade notes",
}
status = (
"error"
if any(item["severity"] == "error" for item in findings)
else ("warning" if findings else "ok")
)
return {
"status": status,
"project": str(root),
"facts": facts,
"findings": findings,
"errors": [],
}
def human(result):
lines = [f"status: {result['status']}", f"project: {result['project']}"]
lines.extend(
f"{item['severity'].upper()} {item['code']}: {item['message']}"
for item in result["findings"]
)
return "\n".join(lines)
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", default=".")
parser.add_argument("--json", action="store_true")
parser.add_argument("--strict", action="store_true")
try:
args = parser.parse_args(argv)
root = Path(args.root).expanduser().resolve()
if not root.is_dir():
raise ValueError(f"project root is not a directory: {root}")
result = check(root)
if args.strict and result["status"] == "warning":
result["status"] = "error"
print(json.dumps(result, indent=2, sort_keys=True) if args.json else human(result))
return 1 if result["status"] == "error" else 0
except (OSError, RuntimeError, ValueError) as exc:
project = str(Path(args.root).expanduser()) if "args" in locals() else None
result = {
"status": "invalid",
"project": project,
"facts": {},
"findings": [],
"errors": [str(exc)],
}
if "args" in locals() and args.json:
print(json.dumps(result, indent=2, sort_keys=True))
else:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main())