aitest-kit 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. aitest_kit/__init__.py +1 -0
  2. aitest_kit/cli.py +23 -0
  3. aitest_kit/codegen/__init__.py +1 -0
  4. aitest_kit/codegen/cli.py +615 -0
  5. aitest_kit/codegen/emitter.py +251 -0
  6. aitest_kit/codegen/health.py +238 -0
  7. aitest_kit/codegen/ir.py +127 -0
  8. aitest_kit/codegen/ir_renderer.py +405 -0
  9. aitest_kit/codegen/parser.py +381 -0
  10. aitest_kit/codegen/planner.py +465 -0
  11. aitest_kit/codegen/profile.py +292 -0
  12. aitest_kit/codegen/profile_validator.py +423 -0
  13. aitest_kit/codegen/project_config.py +202 -0
  14. aitest_kit/codegen/promotion.py +357 -0
  15. aitest_kit/codegen/render_utils.py +225 -0
  16. aitest_kit/init_workspace.py +32 -0
  17. aitest_kit/report/__init__.py +2 -0
  18. aitest_kit/report/classifier.py +40 -0
  19. aitest_kit/report/cli.py +226 -0
  20. aitest_kit/report/collector.py +351 -0
  21. aitest_kit/report/renderer.py +182 -0
  22. aitest_kit/report/sanitizer.py +40 -0
  23. aitest_kit/templates/__init__.py +2 -0
  24. aitest_kit/templates/project_workspace/.agents/skills/doc-gen/SKILL.md +269 -0
  25. aitest_kit/templates/project_workspace/.agents/skills/doc-review/SKILL.md +132 -0
  26. aitest_kit/templates/project_workspace/.agents/skills/emitter-build/SKILL.md +281 -0
  27. aitest_kit/templates/project_workspace/.agents/skills/knowledge-build/SKILL.md +171 -0
  28. aitest_kit/templates/project_workspace/.agents/skills/test-codegen/SKILL.md +310 -0
  29. aitest_kit/templates/project_workspace/.agents/skills/test-design/SKILL.md +207 -0
  30. aitest_kit/templates/project_workspace/.agents/skills/test-fix/SKILL.md +104 -0
  31. aitest_kit/templates/project_workspace/.claude/skills/doc-gen/SKILL.md +269 -0
  32. aitest_kit/templates/project_workspace/.claude/skills/doc-review/SKILL.md +132 -0
  33. aitest_kit/templates/project_workspace/.claude/skills/emitter-build/SKILL.md +281 -0
  34. aitest_kit/templates/project_workspace/.claude/skills/knowledge-build/SKILL.md +171 -0
  35. aitest_kit/templates/project_workspace/.claude/skills/test-codegen/SKILL.md +310 -0
  36. aitest_kit/templates/project_workspace/.claude/skills/test-design/SKILL.md +207 -0
  37. aitest_kit/templates/project_workspace/.claude/skills/test-fix/SKILL.md +104 -0
  38. aitest_kit/templates/project_workspace/.codex/skills/doc-gen/SKILL.md +269 -0
  39. aitest_kit/templates/project_workspace/.codex/skills/doc-review/SKILL.md +132 -0
  40. aitest_kit/templates/project_workspace/.codex/skills/emitter-build/SKILL.md +281 -0
  41. aitest_kit/templates/project_workspace/.codex/skills/knowledge-build/SKILL.md +171 -0
  42. aitest_kit/templates/project_workspace/.codex/skills/test-codegen/SKILL.md +310 -0
  43. aitest_kit/templates/project_workspace/.codex/skills/test-design/SKILL.md +207 -0
  44. aitest_kit/templates/project_workspace/.codex/skills/test-fix/SKILL.md +104 -0
  45. aitest_kit/templates/project_workspace/.gitignore +14 -0
  46. aitest_kit/templates/project_workspace/AGENTS.md +122 -0
  47. aitest_kit/templates/project_workspace/CLAUDE.md +103 -0
  48. aitest_kit/templates/project_workspace/README.md +51 -0
  49. aitest_kit/templates/project_workspace/__init__.py +2 -0
  50. aitest_kit/templates/project_workspace/aitest_config/config.yaml +29 -0
  51. aitest_kit/templates/project_workspace/aitest_config/project_config.yaml +46 -0
  52. aitest_kit/templates/project_workspace/aitest_config/refs/assertion-strategy.md +92 -0
  53. aitest_kit/templates/project_workspace/aitest_config/refs/case-format.md +150 -0
  54. aitest_kit/templates/project_workspace/aitest_config/refs/l1-template.md +49 -0
  55. aitest_kit/templates/project_workspace/aitest_config/refs/l2-template.md +30 -0
  56. aitest_kit/templates/project_workspace/aitest_config/refs/mismatch-format.md +32 -0
  57. aitest_kit/templates/project_workspace/aitest_config/schemas/codegen_profile.schema.json +187 -0
  58. aitest_kit/templates/project_workspace/docs/.gitkeep +1 -0
  59. aitest_kit/templates/project_workspace/test_workspace/cases/.gitkeep +1 -0
  60. aitest_kit/templates/project_workspace/test_workspace/knowledge/L0_system_architecture.md +8 -0
  61. aitest_kit/templates/project_workspace/test_workspace/knowledge/L1/.gitkeep +1 -0
  62. aitest_kit/templates/project_workspace/test_workspace/knowledge/L2/.gitkeep +1 -0
  63. aitest_kit/templates/project_workspace/test_workspace/knowledge/TEST_SPEC.md +52 -0
  64. aitest_kit/templates/project_workspace/test_workspace/plans/.gitkeep +1 -0
  65. aitest_kit/templates/project_workspace/test_workspace/reports/.gitkeep +1 -0
  66. aitest_kit/templates/project_workspace/test_workspace/results/.gitkeep +1 -0
  67. aitest_kit/templates/project_workspace/test_workspace/tests/__init__.py +2 -0
  68. aitest_kit/templates/project_workspace/test_workspace/tests/conftest.py +23 -0
  69. aitest_kit/templates/project_workspace/test_workspace/tests/fixtures/__init__.py +2 -0
  70. aitest_kit/templates/project_workspace/test_workspace/tests/generated/__init__.py +2 -0
  71. aitest_kit/templates/project_workspace/test_workspace/tests/helpers/__init__.py +2 -0
  72. aitest_kit/templates/project_workspace/test_workspace/tests/helpers/grpc_ops.py +12 -0
  73. aitest_kit/templates/project_workspace/test_workspace/tests/helpers/http.py +40 -0
  74. aitest_kit/templates/project_workspace/test_workspace/tests/helpers/redis_ops.py +36 -0
  75. aitest_kit/workspace.py +99 -0
  76. aitest_kit-0.1.1.dist-info/METADATA +394 -0
  77. aitest_kit-0.1.1.dist-info/RECORD +81 -0
  78. aitest_kit-0.1.1.dist-info/WHEEL +5 -0
  79. aitest_kit-0.1.1.dist-info/entry_points.txt +2 -0
  80. aitest_kit-0.1.1.dist-info/licenses/LICENSE +21 -0
  81. aitest_kit-0.1.1.dist-info/top_level.txt +1 -0
aitest_kit/__init__.py ADDED
@@ -0,0 +1 @@
1
+ from __future__ import annotations
aitest_kit/cli.py ADDED
@@ -0,0 +1,23 @@
1
+ """CLI entry point for aitest-kit."""
2
+ from __future__ import annotations
3
+
4
+ import click
5
+
6
+ from aitest_kit.codegen.cli import codegen
7
+ from aitest_kit.init_workspace import init_command
8
+ from aitest_kit.report.cli import report_command, run_command
9
+
10
+
11
+ @click.group()
12
+ def main():
13
+ """AI-driven testing toolkit for Markdown cases, codegen, and pytest reports."""
14
+
15
+
16
+ main.add_command(codegen)
17
+ main.add_command(init_command)
18
+ main.add_command(run_command)
19
+ main.add_command(report_command)
20
+
21
+
22
+ if __name__ == "__main__":
23
+ main()
@@ -0,0 +1 @@
1
+ from __future__ import annotations
@@ -0,0 +1,615 @@
1
+ """codegen CLI — parse and emit pytest from Markdown test cases."""
2
+ from __future__ import annotations
3
+
4
+ import ast
5
+ import difflib
6
+ import json
7
+ import sys
8
+ import tempfile
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ import click
13
+ import yaml
14
+
15
+ from aitest_kit.codegen.emitter import emit_module
16
+ from aitest_kit.codegen.health import (
17
+ build_codegen_health_report,
18
+ codegen_health_to_dict,
19
+ write_codegen_health_report,
20
+ )
21
+ from aitest_kit.codegen.ir import FileIR, ir_to_dict
22
+ from aitest_kit.codegen.parser import parse_case_file
23
+ from aitest_kit.codegen.planner import build_file_ir
24
+ from aitest_kit.codegen.project_config import load_project_config
25
+ from aitest_kit.codegen.promotion import (
26
+ PromotionReport,
27
+ analyze_case_body_promotion,
28
+ promotion_to_dict,
29
+ write_promotion_patch,
30
+ write_promotion_report,
31
+ )
32
+ from aitest_kit.codegen.profile_validator import (
33
+ validate_profile_module,
34
+ write_profile_validation_report,
35
+ )
36
+ from aitest_kit.workspace import push_workspace
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class CodegenPaths:
41
+ cases_dir: Path
42
+ generated_dir: Path
43
+ profile_dir: Path
44
+ reports_dir: Path
45
+ project_config: Path
46
+
47
+
48
+ def _load_codegen_paths() -> CodegenPaths:
49
+ defaults = {
50
+ "cases_dir": "test_workspace/cases",
51
+ "generated_dir": "test_workspace/tests/generated",
52
+ "fixtures_dir": "test_workspace/tests/fixtures",
53
+ "reports_dir": "test_workspace/reports",
54
+ "project_config": "aitest_config/project_config.yaml",
55
+ }
56
+ config_path = Path("aitest_config/config.yaml")
57
+ if config_path.exists():
58
+ cfg = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
59
+ configured = cfg.get("paths", {}) if isinstance(cfg, dict) else {}
60
+ else:
61
+ configured = {}
62
+ paths = {**defaults, **configured}
63
+ return CodegenPaths(
64
+ cases_dir=Path(paths["cases_dir"]),
65
+ generated_dir=Path(paths["generated_dir"]),
66
+ profile_dir=Path(paths["fixtures_dir"]),
67
+ reports_dir=Path(paths["reports_dir"]),
68
+ project_config=Path(paths["project_config"]),
69
+ )
70
+
71
+
72
+ def _list_modules(cases_dir: Path) -> list[str]:
73
+ return sorted(
74
+ d.name for d in cases_dir.iterdir()
75
+ if d.is_dir()
76
+ and not d.name.startswith(".")
77
+ and ((d / "business.md").exists() or (d / "boundary.md").exists())
78
+ )
79
+
80
+
81
+ def _ast_error(path: Path) -> str | None:
82
+ try:
83
+ ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
84
+ except SyntaxError as exc:
85
+ return f"{path}: {exc.msg} at line {exc.lineno}, column {exc.offset}"
86
+ return None
87
+
88
+
89
+ def _profile_path(module: str, paths: CodegenPaths) -> Path | None:
90
+ path = paths.profile_dir / f"codegen_profile_{module}.md"
91
+ return path if path.exists() else None
92
+
93
+
94
+ def _build_module_ir(module: str, paths: CodegenPaths) -> list[FileIR]:
95
+ project = load_project_config(paths.project_config)
96
+ module_dir = paths.cases_dir / module
97
+ profile_path = _profile_path(module, paths)
98
+ files: list[FileIR] = []
99
+ for file_type in ("business", "boundary"):
100
+ md_path = module_dir / f"{file_type}.md"
101
+ if not md_path.exists():
102
+ continue
103
+ parse_result = parse_case_file(md_path)
104
+ files.append(build_file_ir(
105
+ parse_result,
106
+ file_type,
107
+ profile_path=profile_path,
108
+ project=project,
109
+ ))
110
+ return files
111
+
112
+
113
+ def _dump_ir(modules: list[str], paths: CodegenPaths) -> int:
114
+ payload = {
115
+ "modules": [
116
+ {
117
+ "module": module,
118
+ "files": [ir_to_dict(file_ir) for file_ir in _build_module_ir(module, paths)],
119
+ }
120
+ for module in modules
121
+ ]
122
+ }
123
+ click.echo(json.dumps(payload, ensure_ascii=False, indent=2))
124
+ return 0
125
+
126
+
127
+ def _explain_case(module: str, case_id: str, paths: CodegenPaths) -> int:
128
+ for file_ir in _build_module_ir(module, paths):
129
+ for case_ir in file_ir.cases:
130
+ if case_ir.case_id == case_id:
131
+ click.echo(yaml.safe_dump(
132
+ ir_to_dict(case_ir),
133
+ allow_unicode=True,
134
+ sort_keys=False,
135
+ ).rstrip())
136
+ return 0
137
+ click.echo(f"Case {case_id} not found in module {module}")
138
+ return 1
139
+
140
+
141
+ def _default_codegen_report_dir(paths: CodegenPaths) -> Path:
142
+ return paths.reports_dir / "codegen" / "latest"
143
+
144
+
145
+ def _analyze_promotion(
146
+ modules: list[str],
147
+ paths: CodegenPaths,
148
+ *,
149
+ output_dir: str | None = None,
150
+ write_report: bool = False,
151
+ write_patch: bool = False,
152
+ echo_yaml: bool = True,
153
+ ) -> int:
154
+ report_dir = Path(output_dir) if output_dir else _default_codegen_report_dir(paths)
155
+ reports = []
156
+ written: list[Path] = []
157
+ for module in modules:
158
+ profile_path = _profile_path(module, paths)
159
+ if profile_path is None:
160
+ report = PromotionReport(module=module, total_case_bodies=0)
161
+ item = promotion_to_dict(report)
162
+ item["note"] = "codegen profile not found"
163
+ reports.append(item)
164
+ if write_report:
165
+ written.extend(write_promotion_report(report, report_dir).values())
166
+ if write_patch:
167
+ written.extend(write_promotion_patch(report, report_dir).values())
168
+ continue
169
+ report = analyze_case_body_promotion(module, profile_path)
170
+ reports.append(promotion_to_dict(report))
171
+ if write_report:
172
+ written.extend(write_promotion_report(report, report_dir).values())
173
+ if write_patch:
174
+ written.extend(write_promotion_patch(
175
+ report,
176
+ report_dir,
177
+ profile_path=profile_path,
178
+ ).values())
179
+
180
+ if echo_yaml:
181
+ click.echo(yaml.safe_dump(
182
+ {"promotion_reports": reports},
183
+ allow_unicode=True,
184
+ sort_keys=False,
185
+ ).rstrip())
186
+ if written:
187
+ click.echo("Promotion artifacts written:")
188
+ for path in written:
189
+ click.echo(f"- {path}")
190
+ return 0
191
+
192
+
193
+ def _validate_profiles(
194
+ modules: list[str],
195
+ paths: CodegenPaths,
196
+ *,
197
+ output_dir: str | None = None,
198
+ write_report: bool = False,
199
+ ) -> int:
200
+ report_dir = Path(output_dir) if output_dir else _default_codegen_report_dir(paths)
201
+ project = load_project_config(paths.project_config)
202
+ error_count = 0
203
+ warning_count = 0
204
+ written: list[Path] = []
205
+ if not modules:
206
+ click.echo("No modules found under the configured cases directory.")
207
+ click.echo(
208
+ "Next step: create "
209
+ f"{paths.cases_dir}/<module>/business.md and a matching codegen profile "
210
+ f"under {paths.profile_dir}."
211
+ )
212
+ for module in modules:
213
+ report = validate_profile_module(
214
+ module,
215
+ cases_dir=paths.cases_dir,
216
+ profile_dir=paths.profile_dir,
217
+ project=project,
218
+ )
219
+ error_count += len(report.errors)
220
+ warning_count += len(report.warnings)
221
+ if write_report:
222
+ written.extend(write_profile_validation_report(report, report_dir).values())
223
+ click.echo(f"\nModule: {module}")
224
+ click.echo(f" Profile: {report.profile_path}")
225
+ click.echo(f" Case files: {len(report.case_files)}")
226
+ click.echo(f" Cases: {len(report.case_ids)}")
227
+ if report.diagnostics:
228
+ click.echo(" Diagnostics:")
229
+ for diag in report.diagnostics:
230
+ click.echo(f" {diag.format()}")
231
+ else:
232
+ click.echo(" Status: OK")
233
+
234
+ click.echo(
235
+ f"\nProfile validation summary: modules={len(modules)}, "
236
+ f"errors={error_count}, warnings={warning_count}"
237
+ )
238
+ if written:
239
+ click.echo("Profile validation artifacts written:")
240
+ for path in written:
241
+ click.echo(f"- {path}")
242
+ return 1 if error_count else 0
243
+
244
+
245
+ def _profile_gate(modules: list[str], paths: CodegenPaths) -> int:
246
+ project = load_project_config(paths.project_config)
247
+ reports = [
248
+ validate_profile_module(
249
+ module,
250
+ cases_dir=paths.cases_dir,
251
+ profile_dir=paths.profile_dir,
252
+ project=project,
253
+ )
254
+ for module in modules
255
+ ]
256
+ error_count = sum(len(report.errors) for report in reports)
257
+ if not error_count:
258
+ return 0
259
+
260
+ warning_count = sum(len(report.warnings) for report in reports)
261
+ click.echo(
262
+ f"Profile gate: modules={len(modules)}, "
263
+ f"errors={error_count}, warnings={warning_count}"
264
+ )
265
+ click.echo("Profile gate blocked codegen:")
266
+ for report in reports:
267
+ if not report.errors:
268
+ continue
269
+ click.echo(f"\nModule: {report.module}")
270
+ for diag in report.errors:
271
+ click.echo(f" {diag.format()}")
272
+ click.echo("\nRun `aitest codegen --all --validate-profile --write-report` for artifacts.")
273
+ return 1
274
+
275
+
276
+ def _health_report(
277
+ modules: list[str],
278
+ paths: CodegenPaths,
279
+ *,
280
+ output_dir: str | None = None,
281
+ write_report: bool = False,
282
+ ) -> int:
283
+ project = load_project_config(paths.project_config)
284
+ report = build_codegen_health_report(
285
+ modules,
286
+ paths.cases_dir,
287
+ profile_dir=paths.profile_dir,
288
+ project=project,
289
+ )
290
+ click.echo(yaml.safe_dump(
291
+ codegen_health_to_dict(report),
292
+ allow_unicode=True,
293
+ sort_keys=False,
294
+ ).rstrip())
295
+ if write_report:
296
+ report_dir = Path(output_dir) if output_dir else _default_codegen_report_dir(paths)
297
+ written = write_codegen_health_report(report, report_dir)
298
+ click.echo("Codegen health artifacts written:")
299
+ for path in written.values():
300
+ click.echo(f"- {path}")
301
+ return 1 if report.error_count else 0
302
+
303
+
304
+ def _check_consistency(
305
+ modules: list[str],
306
+ paths: CodegenPaths,
307
+ include_all_generated: bool = False,
308
+ *,
309
+ project=None,
310
+ ) -> int:
311
+ generated_dir = paths.generated_dir
312
+ stale_count = 0
313
+ blocked_count = 0
314
+ target_files: set[str] = set()
315
+ for mod in modules:
316
+ mod_dir = paths.cases_dir / mod
317
+ for file_type in ("business", "boundary"):
318
+ if (mod_dir / f"{file_type}.md").exists():
319
+ target_files.add(f"test_{mod}_{file_type}.py")
320
+
321
+ with tempfile.TemporaryDirectory() as tmpdir:
322
+ for mod in modules:
323
+ mod_dir = paths.cases_dir / mod
324
+ if not mod_dir.exists():
325
+ continue
326
+ results = emit_module(
327
+ mod,
328
+ cases_dir=paths.cases_dir,
329
+ output_dir=tmpdir,
330
+ profile_dir=paths.profile_dir,
331
+ project=project,
332
+ )
333
+ for r in results:
334
+ if r.diagnostics:
335
+ click.echo(f"[BLOCKED] {Path(r.output_path).name}")
336
+ for diag in r.diagnostics:
337
+ click.echo(f" {diag}")
338
+ blocked_count += 1
339
+ stale_count += 1
340
+
341
+ tmp_path = Path(tmpdir)
342
+ all_files = set()
343
+ for f in tmp_path.glob("test_*.py"):
344
+ all_files.add(f.name)
345
+ syntax_error = _ast_error(f)
346
+ if syntax_error:
347
+ click.echo(f"[SYNTAX] {f.name}")
348
+ click.echo(f" {syntax_error}")
349
+ stale_count += 1
350
+ for f in generated_dir.glob("test_*.py"):
351
+ if include_all_generated or f.name in target_files:
352
+ all_files.add(f.name)
353
+
354
+ for fname in sorted(all_files):
355
+ new_file = tmp_path / fname
356
+ old_file = generated_dir / fname
357
+
358
+ if not old_file.exists():
359
+ click.echo(f"[NEW] {fname} — not yet in generated/")
360
+ stale_count += 1
361
+ continue
362
+ if not new_file.exists():
363
+ click.echo(f"[EXTRA] {fname} — in generated/ but no source")
364
+ stale_count += 1
365
+ continue
366
+
367
+ old_lines = old_file.read_text(encoding="utf-8").splitlines(keepends=True)
368
+ new_lines = new_file.read_text(encoding="utf-8").splitlines(keepends=True)
369
+ diff = list(difflib.unified_diff(
370
+ old_lines, new_lines,
371
+ fromfile=f"generated/{fname}",
372
+ tofile=f"(regenerated) {fname}",
373
+ ))
374
+ if diff:
375
+ click.echo(f"[STALE] {fname}")
376
+ click.echo("".join(diff[:40]))
377
+ stale_count += 1
378
+
379
+ if stale_count:
380
+ if blocked_count:
381
+ click.echo(f"\n{blocked_count} file(s) blocked by diagnostics.")
382
+ click.echo(f"\n{stale_count} file(s) stale. Run `aitest codegen --all` to update.")
383
+ return 1
384
+
385
+ click.echo("All generated files are up to date.")
386
+ return 0
387
+
388
+
389
+ @click.command()
390
+ @click.argument("module", required=False)
391
+ @click.option("--all", "all_modules", is_flag=True, help="Operate on all modules under test_workspace/cases")
392
+ @click.option("--dry-run", is_flag=True, help="Parse Markdown only; do not write generated files")
393
+ @click.option("--check", is_flag=True, help="Verify generated pytest matches Markdown/profile/config")
394
+ @click.option("--dump-ir", is_flag=True, help="Print Case IR as JSON without generating files")
395
+ @click.option("--explain", metavar="TC_ID", help="Print Case IR explanation for one case")
396
+ @click.option("--analyze-promotion", is_flag=True, help="Analyze profile case_bodies promotion candidates")
397
+ @click.option("--write-report", is_flag=True, help="Write profile/health/promotion artifacts under reports/codegen")
398
+ @click.option("--suggest-promotion-patch", is_flag=True, help="Write review-only promotion patch artifacts")
399
+ @click.option("--report-dir", type=click.Path(file_okay=False, dir_okay=True), help="Codegen report output directory")
400
+ @click.option("--validate-profile", is_flag=True, help="Validate codegen_profile JSON Schema and semantics")
401
+ @click.option("--health-report", is_flag=True, help="Report codegen module health and maturity")
402
+ @click.option("--workspace", type=click.Path(file_okay=False, dir_okay=True), help="Run from another AITest workspace root")
403
+ def codegen(
404
+ module: str | None,
405
+ all_modules: bool,
406
+ dry_run: bool,
407
+ check: bool,
408
+ dump_ir: bool,
409
+ explain: str | None,
410
+ analyze_promotion: bool,
411
+ write_report: bool,
412
+ suggest_promotion_patch: bool,
413
+ report_dir: str | None,
414
+ validate_profile: bool,
415
+ health_report: bool,
416
+ workspace: str | None,
417
+ ):
418
+ """Compile Markdown test cases into generated pytest files."""
419
+ try:
420
+ with push_workspace(workspace):
421
+ _codegen_impl(
422
+ module,
423
+ all_modules,
424
+ dry_run,
425
+ check,
426
+ dump_ir,
427
+ explain,
428
+ analyze_promotion,
429
+ write_report,
430
+ suggest_promotion_patch,
431
+ report_dir,
432
+ validate_profile,
433
+ health_report,
434
+ )
435
+ except (FileNotFoundError, NotADirectoryError) as exc:
436
+ raise click.ClickException(str(exc)) from exc
437
+
438
+
439
+ def _codegen_impl(
440
+ module: str | None,
441
+ all_modules: bool,
442
+ dry_run: bool,
443
+ check: bool,
444
+ dump_ir: bool,
445
+ explain: str | None,
446
+ analyze_promotion: bool,
447
+ write_report: bool,
448
+ suggest_promotion_patch: bool,
449
+ report_dir: str | None,
450
+ validate_profile: bool,
451
+ health_report: bool,
452
+ ) -> None:
453
+ if check and dry_run:
454
+ click.echo("Error: --check and --dry-run are mutually exclusive")
455
+ sys.exit(2)
456
+ promotion_mode = analyze_promotion or suggest_promotion_patch
457
+ if (dump_ir or explain or promotion_mode or validate_profile or health_report) and (check or dry_run):
458
+ click.echo("Error: report/IR/profile modes cannot be combined with --check or --dry-run")
459
+ sys.exit(2)
460
+ exclusive_modes = sum(bool(item) for item in [dump_ir, explain, promotion_mode, validate_profile, health_report])
461
+ if exclusive_modes > 1:
462
+ click.echo("Error: report/IR/profile modes are mutually exclusive")
463
+ sys.exit(2)
464
+ if explain and all_modules:
465
+ click.echo("Error: --explain requires a single module, not --all")
466
+ sys.exit(2)
467
+ if write_report and not (promotion_mode or validate_profile or health_report):
468
+ click.echo("Error: --write-report requires promotion analysis, --validate-profile, or --health-report")
469
+ sys.exit(2)
470
+ if report_dir and not (write_report or suggest_promotion_patch):
471
+ click.echo("Error: --report-dir requires --write-report or --suggest-promotion-patch")
472
+ sys.exit(2)
473
+
474
+ paths = _load_codegen_paths()
475
+ project = load_project_config(paths.project_config)
476
+
477
+ if all_modules:
478
+ modules = _list_modules(paths.cases_dir)
479
+ elif module:
480
+ modules = [module]
481
+ else:
482
+ click.echo("Usage: aitest codegen <module> or aitest codegen --all")
483
+ sys.exit(1)
484
+
485
+ if check:
486
+ gate_result = _profile_gate(modules, paths)
487
+ if gate_result:
488
+ sys.exit(gate_result)
489
+ sys.exit(_check_consistency(
490
+ modules,
491
+ paths,
492
+ include_all_generated=all_modules,
493
+ project=project,
494
+ ))
495
+ if validate_profile:
496
+ sys.exit(_validate_profiles(
497
+ modules,
498
+ paths,
499
+ output_dir=report_dir,
500
+ write_report=write_report,
501
+ ))
502
+ if health_report:
503
+ sys.exit(_health_report(
504
+ modules,
505
+ paths,
506
+ output_dir=report_dir,
507
+ write_report=write_report,
508
+ ))
509
+ if not dry_run:
510
+ gate_result = _profile_gate(modules, paths)
511
+ if gate_result:
512
+ sys.exit(gate_result)
513
+ if dump_ir:
514
+ sys.exit(_dump_ir(modules, paths))
515
+ if explain:
516
+ if not module:
517
+ click.echo("Error: --explain requires a module")
518
+ sys.exit(2)
519
+ sys.exit(_explain_case(module, explain, paths))
520
+ if promotion_mode:
521
+ sys.exit(_analyze_promotion(
522
+ modules,
523
+ paths,
524
+ output_dir=report_dir,
525
+ write_report=write_report or suggest_promotion_patch,
526
+ write_patch=suggest_promotion_patch,
527
+ echo_yaml=analyze_promotion,
528
+ ))
529
+
530
+ total_generated = 0
531
+ total_blocked = 0
532
+ total_syntax_errors = 0
533
+
534
+ for mod in modules:
535
+ mod_dir = paths.cases_dir / mod
536
+ if not mod_dir.exists():
537
+ click.echo(f"[SKIP] {mod}: directory not found at {mod_dir}")
538
+ continue
539
+
540
+ click.echo(f"\n{'='*60}")
541
+ click.echo(f"Module: {mod}")
542
+ click.echo(f"{'='*60}")
543
+
544
+ if dry_run:
545
+ for md_file in ["business.md", "boundary.md"]:
546
+ path = mod_dir / md_file
547
+ if not path.exists():
548
+ continue
549
+ result = parse_case_file(path)
550
+ skipped = [tc for tc in result.cases if any("可行性存疑" in m for m in tc.markers)]
551
+ manual = [tc for tc in result.cases if any("manual" in m.lower() for m in tc.markers)]
552
+ auto = [tc for tc in result.cases if tc not in skipped and tc not in manual]
553
+ click.echo(f"\n {md_file}: {len(result.cases)} cases")
554
+ click.echo(f" Auto: {len(auto)}")
555
+ click.echo(f" Manual: {len(manual)}")
556
+ click.echo(f" Skipped: {len(skipped)}")
557
+ if result.errors:
558
+ click.echo(" Errors:")
559
+ for err in result.errors:
560
+ click.echo(f" {err}")
561
+ if skipped:
562
+ for tc in skipped:
563
+ click.echo(f" SKIP {tc.id}: {tc.markers}")
564
+ continue
565
+
566
+ results = emit_module(
567
+ mod,
568
+ cases_dir=paths.cases_dir,
569
+ output_dir=paths.generated_dir,
570
+ profile_dir=paths.profile_dir,
571
+ project=project,
572
+ )
573
+ blocked = 0
574
+ generated = 0
575
+ syntax_errors = 0
576
+ for r in results:
577
+ click.echo(f"\n {r.output_path}")
578
+ if r.diagnostics:
579
+ blocked += 1
580
+ click.echo(" Status: BLOCKED")
581
+ click.echo(f" Diagnostics: {len(r.diagnostics)}")
582
+ for diag in r.diagnostics:
583
+ click.echo(f" {diag}")
584
+ continue
585
+
586
+ generated += 1
587
+ click.echo(f" Cases: {r.case_count}")
588
+ click.echo(f" Manual: {r.manual_count}")
589
+ click.echo(f" Skipped: {len(r.skipped)}")
590
+ click.echo(f" Unparsed: {len(r.unparsed)}")
591
+ if r.unparsed:
592
+ for tc_id, text in r.unparsed:
593
+ click.echo(f" {tc_id}: {text[:80]}")
594
+ syntax_error = _ast_error(Path(r.output_path))
595
+ if syntax_error:
596
+ syntax_errors += 1
597
+ click.echo(" Syntax: ERROR")
598
+ click.echo(f" {syntax_error}")
599
+ else:
600
+ click.echo(" Syntax: OK")
601
+
602
+ total_generated += generated
603
+ total_blocked += blocked
604
+ total_syntax_errors += syntax_errors
605
+ click.echo(f"\n Summary: generated={generated}, blocked={blocked}")
606
+
607
+ if dry_run:
608
+ click.echo("\n[dry-run] No files generated.")
609
+ else:
610
+ click.echo(
611
+ f"\nFinal summary: generated={total_generated}, "
612
+ f"blocked={total_blocked}, syntax_errors={total_syntax_errors}"
613
+ )
614
+ if total_blocked or total_syntax_errors:
615
+ sys.exit(1)