1"""ll-learning-tests: CLI for querying and managing the learning test registry."""

2

3 from __future__ import annotations

4

5 import argparse

6 import sys

7

8 from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context

9

10__all__ = ["main_learning_tests"]

11

12

13 def cmd_check(args: argparse.Namespace) -> int:

14 from little_loops.cli.output import print_json

15 from little_loops.learning_tests import check_learning_test

16

17 record = check_learning_test(args.target)

18 if record is None:

19 print(f"Error: no record found for {args.target!r}", file=sys.stderr)

20 return 1

21

22 print_json(record.to_dict())

23

24 if getattr(args, "stale_aware", False):

25 import json as _json

26 from pathlib import Path

27

28 from little_loops.config.core import resolve_config_path

29 from little_loops.config.features import LearningTestsConfig

30 from little_loops.learning_tests.gate import is_record_stale

31

32 config_path = resolve_config_path(Path.cwd())

33 lt_config = LearningTestsConfig()

34 if config_path is not None:

35 try:

36 data = _json.loads(config_path.read_text(encoding="utf-8"))

37 if isinstance(data, dict):

38 lt_config = LearningTestsConfig.from_dict(data.get("learning_tests", {}))

39 except (OSError, _json.JSONDecodeError):

40 pass

41

42 if record.status != "proven" or is_record_stale(record, lt_config.stale_after_days):

43 return 1

44

45 return 0

46

47

48 def cmd_list(_args: argparse.Namespace) -> int:

49 from little_loops.cli.output import print_json

50 from little_loops.learning_tests import list_records

51

52 records = list_records()

53 print_json([r.to_dict() for r in records])

54 return 0

55

56

57 def cmd_mark_stale(args: argparse.Namespace) -> int:

58 from little_loops.issue_parser import slugify

59 from little_loops.learning_tests import check_learning_test, mark_stale

60

61 record = check_learning_test(args.target)

62 if record is None:

63 print(f"Error: no record found for {args.target!r}", file=sys.stderr)

64 return 1

65 mark_stale(slugify(args.target))

66 return 0

67

68

69 def cmd_orphans(args: argparse.Namespace) -> int:

70 import json as _json

71 from pathlib import Path

72

73 from little_loops.config.core import resolve_config_path

74 from little_loops.issue_parser import slugify

75 from little_loops.learning_tests import list_records, mark_stale

76 from little_loops.learning_tests.import_scan import get_imported_packages

77

78 source_dirs: list[Path]

79 if args.scope:

80 source_dirs = [Path(d.strip()) for d in args.scope.split(",")]

81 else:

82 resolved: list[Path] | None = None

83 config_path = resolve_config_path(Path.cwd())

84 if config_path is not None:

85 try:

86 data = _json.loads(config_path.read_text(encoding="utf-8"))

87 if isinstance(data, dict):

88 raw = data.get("learning_tests", {}).get("scan_dirs")

89 if raw:

90 resolved = [Path(d) for d in raw]

91 except (OSError, _json.JSONDecodeError):

92 pass

93 source_dirs = resolved if resolved is not None else [Path("scripts/")]

94

95 imported = get_imported_packages(source_dirs)

96

97 records = list_records()

98 orphans = [r for r in records if r.target.split()[0].lower() not in imported]

99

100 if not orphans:

101 print("No orphaned records found.")

102 return 0

103

104 for record in orphans:

105 print(f"{record.target} (status: {record.status}, date: {record.date})")

106

107 if args.mark_stale:

108 for record in orphans:

109 mark_stale(slugify(record.target))

110 print(f"\nMarked {len(orphans)} record(s) stale.")

111 return 0

112

113 return 1

114

115

116 def main_learning_tests() -> int:

117 """CLI handler for ll-learning-tests subcommands."""

118 with cli_event_context(DEFAULT_DB_PATH, "ll-learning-tests", sys.argv[1:]):

119 parser = argparse.ArgumentParser(

120 prog="ll-learning-tests",

121 description="Query and manage the little-loops learning test registry",

122 formatter_class=argparse.RawDescriptionHelpFormatter,

123 epilog="""

124 Examples:

125 ll-learning-tests check "Anthropic SDK streaming"

126 ll-learning-tests list

127 ll-learning-tests mark-stale "Anthropic SDK streaming"

128""",

129 )

130

131 subparsers = parser.add_subparsers(dest="command", metavar="COMMAND")

132 subparsers.required = True

133

134 check_parser = subparsers.add_parser(

135 "check",

136 help="Print a record as JSON; exit 1 if not found",

137 description="Look up a learning test record by target name and print as JSON",

138 )

139 check_parser.add_argument("target", help="Target name (e.g. 'Anthropic SDK streaming')")

140 check_parser.add_argument(

141 "--stale-aware",

142 action="store_true",

143 default=False,

144 dest="stale_aware",

145 help=(

146 "Exit 1 if the record is absent or date-stale "

147 "(even if status=proven); exit 0 only if proven and within stale_after_days threshold"

148 ),

149 )

150

151 subparsers.add_parser(

152 "list",

153 help="Print all records as a JSON array",

154 description="List all learning test records in the registry",

155 )

156

157 stale_parser = subparsers.add_parser(

158 "mark-stale",

159 help="Mark a record as stale; exit 1 if not found",

160 description="Set status=stale on a learning test record",

161 )

162 stale_parser.add_argument("target", help="Target name (e.g. 'Anthropic SDK streaming')")

163

164 orphans_parser = subparsers.add_parser(

165 "orphans",

166 help="List records for packages no longer imported; exit 1 if any found",

167 description=(

168 "Detect learning test records whose target package is no longer imported "

169 "anywhere in the configured source directories."

170 ),

171 )

172 orphans_parser.add_argument(

173 "--mark-stale",

174 action="store_true",

175 default=False,

176 dest="mark_stale",

177 help="Atomically mark all orphaned records stale and exit 0",

178 )

179 orphans_parser.add_argument(

180 "--scope",

181 default=None,

182 metavar="DIRS",

183 help=(

184 "Comma-separated list of directories to scan for imports "

185 "(default: learning_tests.scan_dirs config key, fallback 'scripts/')"

186 ),

187 )

188

189 parsed = parser.parse_args()

190

191 if parsed.command == "check":

192 return cmd_check(parsed)

193 elif parsed.command == "list":

194 return cmd_list(parsed)

195 elif parsed.command == "mark-stale":

196 return cmd_mark_stale(parsed)

197 elif parsed.command == "orphans":

198 return cmd_orphans(parsed)

199 else:

200 parser.print_help()

201 return 1