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.
- aitest_kit/__init__.py +1 -0
- aitest_kit/cli.py +23 -0
- aitest_kit/codegen/__init__.py +1 -0
- aitest_kit/codegen/cli.py +615 -0
- aitest_kit/codegen/emitter.py +251 -0
- aitest_kit/codegen/health.py +238 -0
- aitest_kit/codegen/ir.py +127 -0
- aitest_kit/codegen/ir_renderer.py +405 -0
- aitest_kit/codegen/parser.py +381 -0
- aitest_kit/codegen/planner.py +465 -0
- aitest_kit/codegen/profile.py +292 -0
- aitest_kit/codegen/profile_validator.py +423 -0
- aitest_kit/codegen/project_config.py +202 -0
- aitest_kit/codegen/promotion.py +357 -0
- aitest_kit/codegen/render_utils.py +225 -0
- aitest_kit/init_workspace.py +32 -0
- aitest_kit/report/__init__.py +2 -0
- aitest_kit/report/classifier.py +40 -0
- aitest_kit/report/cli.py +226 -0
- aitest_kit/report/collector.py +351 -0
- aitest_kit/report/renderer.py +182 -0
- aitest_kit/report/sanitizer.py +40 -0
- aitest_kit/templates/__init__.py +2 -0
- aitest_kit/templates/project_workspace/.agents/skills/doc-gen/SKILL.md +269 -0
- aitest_kit/templates/project_workspace/.agents/skills/doc-review/SKILL.md +132 -0
- aitest_kit/templates/project_workspace/.agents/skills/emitter-build/SKILL.md +281 -0
- aitest_kit/templates/project_workspace/.agents/skills/knowledge-build/SKILL.md +171 -0
- aitest_kit/templates/project_workspace/.agents/skills/test-codegen/SKILL.md +310 -0
- aitest_kit/templates/project_workspace/.agents/skills/test-design/SKILL.md +207 -0
- aitest_kit/templates/project_workspace/.agents/skills/test-fix/SKILL.md +104 -0
- aitest_kit/templates/project_workspace/.claude/skills/doc-gen/SKILL.md +269 -0
- aitest_kit/templates/project_workspace/.claude/skills/doc-review/SKILL.md +132 -0
- aitest_kit/templates/project_workspace/.claude/skills/emitter-build/SKILL.md +281 -0
- aitest_kit/templates/project_workspace/.claude/skills/knowledge-build/SKILL.md +171 -0
- aitest_kit/templates/project_workspace/.claude/skills/test-codegen/SKILL.md +310 -0
- aitest_kit/templates/project_workspace/.claude/skills/test-design/SKILL.md +207 -0
- aitest_kit/templates/project_workspace/.claude/skills/test-fix/SKILL.md +104 -0
- aitest_kit/templates/project_workspace/.codex/skills/doc-gen/SKILL.md +269 -0
- aitest_kit/templates/project_workspace/.codex/skills/doc-review/SKILL.md +132 -0
- aitest_kit/templates/project_workspace/.codex/skills/emitter-build/SKILL.md +281 -0
- aitest_kit/templates/project_workspace/.codex/skills/knowledge-build/SKILL.md +171 -0
- aitest_kit/templates/project_workspace/.codex/skills/test-codegen/SKILL.md +310 -0
- aitest_kit/templates/project_workspace/.codex/skills/test-design/SKILL.md +207 -0
- aitest_kit/templates/project_workspace/.codex/skills/test-fix/SKILL.md +104 -0
- aitest_kit/templates/project_workspace/.gitignore +14 -0
- aitest_kit/templates/project_workspace/AGENTS.md +122 -0
- aitest_kit/templates/project_workspace/CLAUDE.md +103 -0
- aitest_kit/templates/project_workspace/README.md +51 -0
- aitest_kit/templates/project_workspace/__init__.py +2 -0
- aitest_kit/templates/project_workspace/aitest_config/config.yaml +29 -0
- aitest_kit/templates/project_workspace/aitest_config/project_config.yaml +46 -0
- aitest_kit/templates/project_workspace/aitest_config/refs/assertion-strategy.md +92 -0
- aitest_kit/templates/project_workspace/aitest_config/refs/case-format.md +150 -0
- aitest_kit/templates/project_workspace/aitest_config/refs/l1-template.md +49 -0
- aitest_kit/templates/project_workspace/aitest_config/refs/l2-template.md +30 -0
- aitest_kit/templates/project_workspace/aitest_config/refs/mismatch-format.md +32 -0
- aitest_kit/templates/project_workspace/aitest_config/schemas/codegen_profile.schema.json +187 -0
- aitest_kit/templates/project_workspace/docs/.gitkeep +1 -0
- aitest_kit/templates/project_workspace/test_workspace/cases/.gitkeep +1 -0
- aitest_kit/templates/project_workspace/test_workspace/knowledge/L0_system_architecture.md +8 -0
- aitest_kit/templates/project_workspace/test_workspace/knowledge/L1/.gitkeep +1 -0
- aitest_kit/templates/project_workspace/test_workspace/knowledge/L2/.gitkeep +1 -0
- aitest_kit/templates/project_workspace/test_workspace/knowledge/TEST_SPEC.md +52 -0
- aitest_kit/templates/project_workspace/test_workspace/plans/.gitkeep +1 -0
- aitest_kit/templates/project_workspace/test_workspace/reports/.gitkeep +1 -0
- aitest_kit/templates/project_workspace/test_workspace/results/.gitkeep +1 -0
- aitest_kit/templates/project_workspace/test_workspace/tests/__init__.py +2 -0
- aitest_kit/templates/project_workspace/test_workspace/tests/conftest.py +23 -0
- aitest_kit/templates/project_workspace/test_workspace/tests/fixtures/__init__.py +2 -0
- aitest_kit/templates/project_workspace/test_workspace/tests/generated/__init__.py +2 -0
- aitest_kit/templates/project_workspace/test_workspace/tests/helpers/__init__.py +2 -0
- aitest_kit/templates/project_workspace/test_workspace/tests/helpers/grpc_ops.py +12 -0
- aitest_kit/templates/project_workspace/test_workspace/tests/helpers/http.py +40 -0
- aitest_kit/templates/project_workspace/test_workspace/tests/helpers/redis_ops.py +36 -0
- aitest_kit/workspace.py +99 -0
- aitest_kit-0.1.1.dist-info/METADATA +394 -0
- aitest_kit-0.1.1.dist-info/RECORD +81 -0
- aitest_kit-0.1.1.dist-info/WHEEL +5 -0
- aitest_kit-0.1.1.dist-info/entry_points.txt +2 -0
- aitest_kit-0.1.1.dist-info/licenses/LICENSE +21 -0
- 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)
|