lint_runner.py 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. #!/usr/bin/env python3
  2. """
  3. GitHub Action runner for lambdagent agent config lint.
  4. Scans directories for agent YAML/JSON configs, runs lint + type check + cost
  5. estimation, and outputs results as GitHub Action annotations + summary.
  6. """
  7. import glob
  8. import json
  9. import os
  10. import sys
  11. import yaml
  12. def find_configs(paths: str) -> list:
  13. """Find all agent YAML configs in given paths."""
  14. configs = []
  15. for path in paths.strip().split("\n"):
  16. path = path.strip()
  17. if not path:
  18. continue
  19. if os.path.isfile(path):
  20. configs.append(path)
  21. elif os.path.isdir(path):
  22. for pattern in ["**/*.yml", "**/*.yaml", "**/*.json"]:
  23. for f in glob.glob(os.path.join(path, pattern), recursive=True):
  24. if "node_modules" in f or ".git" in f:
  25. continue
  26. # Check if it's an agent config (has 'type' field)
  27. try:
  28. with open(f) as fh:
  29. cfg = yaml.safe_load(fh)
  30. if cfg and isinstance(cfg, dict) and "type" in cfg:
  31. configs.append(f)
  32. except Exception:
  33. pass
  34. return configs
  35. def run_lint(config_path: str) -> dict:
  36. """Run lint on a single config file."""
  37. try:
  38. from lambdagent.fromconfig.lint import lint_config, detect_framework
  39. from lambdagent.fromconfig.schema import validate_schema
  40. with open(config_path) as f:
  41. cfg = yaml.safe_load(f)
  42. framework = detect_framework(cfg)
  43. lint_results = lint_config(cfg)
  44. schema_errors = validate_schema(cfg)
  45. return {
  46. "path": config_path,
  47. "framework": framework,
  48. "lint": [
  49. {"rule": r.rule, "level": r.level, "message": r.message}
  50. for r in lint_results
  51. ],
  52. "schema": [
  53. {"rule": e[1], "level": e[0], "message": e[2]}
  54. for e in schema_errors
  55. ],
  56. }
  57. except ImportError:
  58. # lambdagent not installed — run basic YAML validation only
  59. return {
  60. "path": config_path,
  61. "framework": "unknown",
  62. "lint": [],
  63. "schema": [],
  64. "warning": "lambdagent not installed; only basic YAML validation available",
  65. }
  66. except Exception as e:
  67. return {
  68. "path": config_path,
  69. "framework": "unknown",
  70. "lint": [],
  71. "schema": [],
  72. "error": str(e),
  73. }
  74. def run_cost_estimate(config_path: str) -> dict:
  75. """Estimate cost for a config."""
  76. try:
  77. from lambdagent.fromconfig import from_config
  78. from lambdagent.cost_grade import estimate_cost
  79. term = from_config(config_path)
  80. grade = estimate_cost(term)
  81. return {
  82. "tokens": grade.tokens,
  83. "cost_usd": round(grade.money, 4),
  84. "probability": round(grade.probability, 4),
  85. }
  86. except Exception as e:
  87. return {"error": str(e)}
  88. def main():
  89. paths = os.environ.get("INPUT_PATHS", ".")
  90. fail_on = os.environ.get("INPUT_FAIL_ON", "error").upper()
  91. cost_threshold = os.environ.get("INPUT_COST_THRESHOLD", "")
  92. configs = find_configs(paths)
  93. if not configs:
  94. print("No agent config files found.")
  95. _set_output("total-errors", "0")
  96. _set_output("total-warnings", "0")
  97. _set_output("total-files", "0")
  98. return
  99. total_errors = 0
  100. total_warnings = 0
  101. all_results = []
  102. for config_path in configs:
  103. result = run_lint(config_path)
  104. cost = run_cost_estimate(config_path)
  105. result["cost"] = cost
  106. all_results.append(result)
  107. for finding in result["lint"] + result["schema"]:
  108. level = finding["level"]
  109. if level == "ERROR":
  110. total_errors += 1
  111. _annotation("error", config_path, finding["message"], finding.get("rule", ""))
  112. elif level == "WARN":
  113. total_warnings += 1
  114. _annotation("warning", config_path, finding["message"], finding.get("rule", ""))
  115. # Cost threshold check
  116. if cost_threshold and cost.get("cost_usd", 0) > float(cost_threshold):
  117. total_errors += 1
  118. _annotation(
  119. "error", config_path,
  120. f"Estimated cost ${cost['cost_usd']:.2f} exceeds threshold ${float(cost_threshold):.2f}",
  121. "COST"
  122. )
  123. # Summary
  124. print(f"\n{'='*60}")
  125. print(f"Agent Config Lint Results")
  126. print(f"{'='*60}")
  127. print(f"Files scanned: {len(configs)}")
  128. print(f"Errors: {total_errors}")
  129. print(f"Warnings: {total_warnings}")
  130. for r in all_results:
  131. errors = [f for f in r["lint"] + r["schema"] if f["level"] == "ERROR"]
  132. warns = [f for f in r["lint"] + r["schema"] if f["level"] == "WARN"]
  133. status = "PASS" if not errors else "FAIL"
  134. cost_str = ""
  135. if r.get("cost", {}).get("cost_usd"):
  136. cost_str = f" | Cost: ${r['cost']['cost_usd']:.2f}"
  137. print(f" {'FAIL' if errors else 'PASS'} {r['path']} ({r['framework']}) "
  138. f"— {len(errors)} errors, {len(warns)} warnings{cost_str}")
  139. _set_output("total-errors", str(total_errors))
  140. _set_output("total-warnings", str(total_warnings))
  141. _set_output("total-files", str(len(configs)))
  142. # Fail if needed
  143. level_order = {"ERROR": 0, "WARN": 1, "INFO": 2}
  144. fail_order = level_order.get(fail_on, 0)
  145. should_fail = False
  146. if fail_order <= 0 and total_errors > 0:
  147. should_fail = True
  148. elif fail_order <= 1 and (total_errors + total_warnings) > 0:
  149. should_fail = True
  150. if should_fail:
  151. print(f"\nFailing because {total_errors} errors and {total_warnings} warnings found "
  152. f"(fail-on: {fail_on})")
  153. sys.exit(1)
  154. def _annotation(level: str, file: str, message: str, rule: str):
  155. """Emit GitHub Actions annotation."""
  156. print(f"::{level} file={file}::[{rule}] {message}")
  157. def _set_output(name: str, value: str):
  158. """Set GitHub Actions output."""
  159. output_file = os.environ.get("GITHUB_OUTPUT")
  160. if output_file:
  161. with open(output_file, "a") as f:
  162. f.write(f"{name}={value}\n")
  163. if __name__ == "__main__":
  164. main()