quality-harness 0.5.0__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.
- e2e_harness/AGENTS.md +57 -0
- e2e_harness/__init__.py +80 -0
- e2e_harness/__main__.py +10 -0
- e2e_harness/assertion.py +144 -0
- e2e_harness/casegen/__init__.py +41 -0
- e2e_harness/casegen/compiler.py +288 -0
- e2e_harness/casegen/contract.py +344 -0
- e2e_harness/casegen/discover.py +390 -0
- e2e_harness/caserun.py +241 -0
- e2e_harness/cli.py +167 -0
- e2e_harness/command.py +71 -0
- e2e_harness/core/__init__.py +0 -0
- e2e_harness/core/config.py +274 -0
- e2e_harness/core/profile.py +31 -0
- e2e_harness/engine.py +227 -0
- e2e_harness/environment.py +99 -0
- e2e_harness/examples/chat_cases.yaml +17 -0
- e2e_harness/examples/note_cases.yaml +27 -0
- e2e_harness/judge/__init__.py +0 -0
- e2e_harness/judge/metric/__init__.py +18 -0
- e2e_harness/judge/metric/base.py +89 -0
- e2e_harness/judge/metric/outcome.py +151 -0
- e2e_harness/judge/metric/registry.py +71 -0
- e2e_harness/matrix.py +32 -0
- e2e_harness/reducer.py +41 -0
- e2e_harness/runner/__init__.py +0 -0
- e2e_harness/runner/async_base.py +24 -0
- e2e_harness/runner/async_json_runner.py +74 -0
- e2e_harness/runner/async_sse_runner.py +122 -0
- e2e_harness/runner/base.py +102 -0
- e2e_harness/runner/events.py +103 -0
- e2e_harness/runner/headers.py +30 -0
- e2e_harness/runner/json_runner.py +71 -0
- e2e_harness/runner/sse_runner.py +141 -0
- e2e_harness/temporal.py +71 -0
- eval_harness/AGENTS.md +50 -0
- eval_harness/README.md +96 -0
- eval_harness/__init__.py +53 -0
- eval_harness/cli.py +69 -0
- eval_harness/config.py +128 -0
- eval_harness/engine.py +174 -0
- eval_harness/ingest.py +107 -0
- eval_harness/materials/cases/smoke.yaml +21 -0
- eval_harness/materials/experiments/smoke.yaml +17 -0
- eval_harness/metric/__init__.py +9 -0
- eval_harness/metric/aggregate.py +67 -0
- eval_harness/metric/base.py +71 -0
- eval_harness/metric/builtins.py +98 -0
- eval_harness/metric/llm_judge.py +81 -0
- eval_harness/metric/registry.py +40 -0
- eval_harness/model/__init__.py +8 -0
- eval_harness/model/evalset.py +145 -0
- eval_harness/model/experiment.py +213 -0
- eval_harness/model/sample.py +81 -0
- eval_harness/produce/__init__.py +7 -0
- eval_harness/produce/mock.py +37 -0
- eval_harness/report/__init__.py +11 -0
- eval_harness/report/html.py +240 -0
- eval_harness/report/pivot.py +211 -0
- eval_harness/report/render.py +290 -0
- eval_harness/ruff.toml +11 -0
- eval_harness/schedule/__init__.py +7 -0
- eval_harness/schedule/ratelimit.py +84 -0
- eval_harness/schedule/reconcile.py +242 -0
- eval_harness/verdict.py +84 -0
- eval_harness/worksheet/__init__.py +8 -0
- eval_harness/worksheet/checkpoint.py +192 -0
- eval_harness/worksheet/worksheet.py +230 -0
- perf_harness/AGENTS.md +98 -0
- perf_harness/README.md +62 -0
- perf_harness/__init__.py +88 -0
- perf_harness/analysis/__init__.py +61 -0
- perf_harness/analysis/base.py +48 -0
- perf_harness/analysis/capacity.py +137 -0
- perf_harness/analysis/latency.py +153 -0
- perf_harness/analysis/resource.py +180 -0
- perf_harness/analysis/validity.py +131 -0
- perf_harness/cli.py +123 -0
- perf_harness/comparison.py +83 -0
- perf_harness/config.py +753 -0
- perf_harness/deploy.py +116 -0
- perf_harness/docs/extensions.md +209 -0
- perf_harness/docs/load-model-redesign.md +70 -0
- perf_harness/docs/metric-model.md +334 -0
- perf_harness/docs/result-semantics.md +42 -0
- perf_harness/docs/service-metrics.md +88 -0
- perf_harness/drive/__init__.py +1 -0
- perf_harness/drive/controller.py +117 -0
- perf_harness/drive/load.py +153 -0
- perf_harness/drive/runner.py +219 -0
- perf_harness/drive/scheduler.py +243 -0
- perf_harness/engine.py +605 -0
- perf_harness/examples/example.yaml +92 -0
- perf_harness/examples/mock.yaml +37 -0
- perf_harness/examples/mock_cases.yaml +7 -0
- perf_harness/judge.py +33 -0
- perf_harness/metric/__init__.py +79 -0
- perf_harness/metric/family.py +270 -0
- perf_harness/metric/reduce.py +187 -0
- perf_harness/metric/store.py +227 -0
- perf_harness/model.py +488 -0
- perf_harness/observe/__init__.py +29 -0
- perf_harness/observe/base.py +257 -0
- perf_harness/observe/configuration.py +170 -0
- perf_harness/observe/k8s.py +478 -0
- perf_harness/observe/metric.py +172 -0
- perf_harness/observe/prometheus.py +99 -0
- perf_harness/observe/prometheus_query.py +59 -0
- perf_harness/observe/registry.py +42 -0
- perf_harness/records.py +26 -0
- perf_harness/report/__init__.py +13 -0
- perf_harness/report/columns.py +25 -0
- perf_harness/report/lifecycle.py +82 -0
- perf_harness/report/palette.py +20 -0
- perf_harness/report/render.py +1268 -0
- perf_harness/ruff.toml +11 -0
- perf_harness/runio.py +584 -0
- perf_harness/sh.py +16 -0
- perf_harness/slo.py +90 -0
- perf_harness/verdict.py +119 -0
- quality_harness-0.5.0.dist-info/METADATA +21 -0
- quality_harness-0.5.0.dist-info/RECORD +255 -0
- quality_harness-0.5.0.dist-info/WHEEL +4 -0
- quality_harness-0.5.0.dist-info/entry_points.txt +4 -0
- trace_harness/AGENTS.md +130 -0
- trace_harness/__init__.py +68 -0
- trace_harness/analyze/__init__.py +14 -0
- trace_harness/analyze/budget.py +190 -0
- trace_harness/analyze/call_breakdown.py +164 -0
- trace_harness/analyze/context.py +49 -0
- trace_harness/analyze/diagnose/__init__.py +177 -0
- trace_harness/analyze/diagnose/detectors.py +100 -0
- trace_harness/analyze/diagnose/http.py +132 -0
- trace_harness/analyze/diagnose/outliers.py +91 -0
- trace_harness/analyze/diagnose/patterns.py +65 -0
- trace_harness/analyze/diagnose/probes.py +41 -0
- trace_harness/analyze/diagnose/registry.py +43 -0
- trace_harness/analyze/diagnose/series.py +55 -0
- trace_harness/analyze/measure.py +200 -0
- trace_harness/analyze/verdict.py +130 -0
- trace_harness/batch.py +423 -0
- trace_harness/cli.py +262 -0
- trace_harness/corpus/__init__.py +23 -0
- trace_harness/corpus/experiment.py +82 -0
- trace_harness/corpus/operators.py +214 -0
- trace_harness/corpus/report.py +108 -0
- trace_harness/corpus/store.py +97 -0
- trace_harness/corpus/tables.py +80 -0
- trace_harness/dataset.py +42 -0
- trace_harness/detectors.py +121 -0
- trace_harness/docs/batch.md +158 -0
- trace_harness/docs/kernel.md +101 -0
- trace_harness/docs/load.md +84 -0
- trace_harness/docs/single.md +73 -0
- trace_harness/harness.py +212 -0
- trace_harness/ingest/__init__.py +13 -0
- trace_harness/ingest/assemble.py +166 -0
- trace_harness/ingest/load.py +33 -0
- trace_harness/ingest/sources/__init__.py +29 -0
- trace_harness/ingest/sources/base.py +54 -0
- trace_harness/ingest/sources/jaeger_file.py +292 -0
- trace_harness/ingest/sources/opensearch.py +260 -0
- trace_harness/kinds/__init__.py +6 -0
- trace_harness/kinds/base.py +58 -0
- trace_harness/kinds/genai.py +264 -0
- trace_harness/kinds/http.py +172 -0
- trace_harness/loading/__init__.py +1 -0
- trace_harness/loading/analysis.py +172 -0
- trace_harness/loading/facts.py +30 -0
- trace_harness/loading/http.py +29 -0
- trace_harness/loading/loader.py +219 -0
- trace_harness/loading/model.py +35 -0
- trace_harness/loading/store.py +77 -0
- trace_harness/model/__init__.py +34 -0
- trace_harness/model/agent.py +339 -0
- trace_harness/model/analysis.py +142 -0
- trace_harness/model/context.py +63 -0
- trace_harness/model/intervals.py +26 -0
- trace_harness/model/ir.py +125 -0
- trace_harness/model/measurement.py +54 -0
- trace_harness/model/node.py +88 -0
- trace_harness/model/node_tree.py +72 -0
- trace_harness/model/span.py +80 -0
- trace_harness/model/spec.py +89 -0
- trace_harness/ruff.toml +12 -0
- trace_harness/runtime.py +271 -0
- trace_harness/transform.py +103 -0
- trace_harness/view/__init__.py +6 -0
- trace_harness/view/agent_run.py +242 -0
- trace_harness/view/build.py +156 -0
- trace_harness/view/callstack.py +57 -0
- trace_harness/view/display.py +81 -0
- trace_harness/view/engine.py +346 -0
- trace_harness/view/explore.py +128 -0
- trace_harness/view/facet.py +151 -0
- trace_harness/view/facets/__init__.py +15 -0
- trace_harness/view/facets/agent.py +22 -0
- trace_harness/view/facets/model_call.py +28 -0
- trace_harness/view/facets/service.py +17 -0
- trace_harness/view/http.py +102 -0
- trace_harness/view/interactive.py +422 -0
- trace_harness/view/measurements.py +93 -0
- trace_harness/view/payload.py +116 -0
- trace_harness/view/registry.py +34 -0
- trace_harness/view/render.py +79 -0
- trace_harness/view/series.py +46 -0
- trace_harness/view/state.py +102 -0
- trace_harness/view/text.py +47 -0
- trace_harness/view/tool_name.py +73 -0
- trajectory_harness/AGENTS.md +96 -0
- trajectory_harness/README.md +270 -0
- trajectory_harness/__init__.py +130 -0
- trajectory_harness/_measurements.py +32 -0
- trajectory_harness/_steps.py +14 -0
- trajectory_harness/_tool_calls.py +137 -0
- trajectory_harness/build.py +167 -0
- trajectory_harness/dataset.py +127 -0
- trajectory_harness/detect.py +192 -0
- trajectory_harness/detectors/__init__.py +26 -0
- trajectory_harness/detectors/cache_retention_bloat.py +96 -0
- trajectory_harness/detectors/context_bloat.py +80 -0
- trajectory_harness/detectors/oversized_tool_observation.py +83 -0
- trajectory_harness/detectors/post_compact_refetch.py +110 -0
- trajectory_harness/detectors/repeated_tool_call.py +73 -0
- trajectory_harness/detectors/retry_loop.py +100 -0
- trajectory_harness/detectors/short_decision_churn.py +87 -0
- trajectory_harness/detectors/unchanged_tool_retry.py +82 -0
- trajectory_harness/failures.py +74 -0
- trajectory_harness/loaders/__init__.py +4 -0
- trajectory_harness/loaders/atif_json.py +37 -0
- trajectory_harness/loaders/base.py +17 -0
- trajectory_harness/loaders/otel_json.py +580 -0
- trajectory_harness/measure.py +194 -0
- trajectory_harness/measurers/__init__.py +14 -0
- trajectory_harness/measurers/_tokens.py +74 -0
- trajectory_harness/measurers/context_usage.py +207 -0
- trajectory_harness/measurers/model_usage.py +248 -0
- trajectory_harness/measurers/retry_usage.py +122 -0
- trajectory_harness/measurers/tool_usage.py +172 -0
- trajectory_harness/metrics.py +667 -0
- trajectory_harness/model.py +342 -0
- trajectory_harness/pipeline.py +82 -0
- trajectory_harness/report.py +744 -0
- trajectory_harness/report_comparison.py +187 -0
- trajectory_harness/report_detection.py +101 -0
- trajectory_harness/report_provenance.py +89 -0
- trajectory_harness/runio.py +210 -0
- trajectory_harness/runner.py +194 -0
- trajectory_harness/source.py +85 -0
- trajectory_harness/verdict.py +125 -0
- trajectory_harness/verifiers/__init__.py +11 -0
- trajectory_harness/verifiers/execution_success.py +48 -0
- trajectory_harness/verifiers/measurement_threshold.py +133 -0
- trajectory_harness/verifiers/tool_success.py +59 -0
- trajectory_harness/verify.py +183 -0
e2e_harness/AGENTS.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# e2e_harness(确定性契约测试:判定即数据)
|
|
2
|
+
|
|
3
|
+
## 项目定位与边界
|
|
4
|
+
|
|
5
|
+
接口结果是确定的:合理输入 → 固定响应,错误输入 → 固定错误码。不关心"回答质量"——那是 `eval_harness` 的事;也不关心压力下的表现——那是 `perf_harness` 的事。
|
|
6
|
+
|
|
7
|
+
**命脉(判定即数据)**:case 是 spec-case 持有的结构化数据——`input` + `judge.e2e.assert`(`{path,op,value}`)。`engine` 吃 canonical Case → 协议 runner 发请求 → 跑 assert → `verdict`。两个编写前端归一到同一 Case:手写 CaseSet,或 `@case/@spec` 的 NL marker 经 `casegen` 编译。
|
|
8
|
+
|
|
9
|
+
## 代码地图与核心模块
|
|
10
|
+
|
|
11
|
+
目录按 pipeline 阶段切,链路 `NL marker →(casegen) canonical Case →(engine) verdict` 在结构上可读:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
e2e_harness/
|
|
15
|
+
# ── ① NL authoring 前端:NL marker → canonical Case ──
|
|
16
|
+
├── casegen/
|
|
17
|
+
│ ├── contract.py # @case/@spec marker + CaseSpec/Case + case_hash(marker 形状)
|
|
18
|
+
│ ├── discover.py # AST 扫 @case/@spec(+ *_cases.yaml) → DiscoveredCase(不 import 被扫源码)
|
|
19
|
+
│ └── compiler.py # Compiler seam(Stub/Draft/未来 LLMCompiler) + compile/check/list + casegen CLI
|
|
20
|
+
# ── ② 执行 + 判定即数据:canonical Case → verdict ──
|
|
21
|
+
├── engine.py # run_case/run_cases:Case → runner → assert 求值 → Verdict
|
|
22
|
+
├── caserun.py # prepare/execute/judge/cleanup 生命周期、独立阶段 budget 与结果证据
|
|
23
|
+
├── matrix.py # variant Cartesian product;variant 进入 arm_id + facets
|
|
24
|
+
├── temporal.py # phase deadline 感知的 poll/retry/consistently
|
|
25
|
+
├── command.py # 项目原生命令的 transport/process 作用域;保留退出码,不替代 Case Verdict
|
|
26
|
+
├── assertion.py # judge.e2e.assert 求值器:{path,op,value} over response view(结构化、纯函数)
|
|
27
|
+
├── cli.py # `e2e run case.yaml --base-url … → verdict.json`(+ __main__;JSON/SSE)
|
|
28
|
+
# ── ③ 复用层 ──
|
|
29
|
+
├── runner/ # 协议适配器(sync+async × JSON/SSE)→ 统一 Outcome(engine 复用)
|
|
30
|
+
├── judge/metric/# 软评分 BaseMetric[Outcome]
|
|
31
|
+
└── core/ # common.Experiment 的 E2E 子类 + E2EConfig / load_config + profile/capability gating
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
> 输入 Case/CaseSet 归 spec-case;输出 verdict 与 report wire 归 `harness_common`。这些 harness-neutral seam 不收进 e2e 包。
|
|
35
|
+
|
|
36
|
+
## 关键约定
|
|
37
|
+
|
|
38
|
+
- **主链路**:`case.yaml → engine.run_cases → CasePlan(execute/judge) → BaseRunner → Outcome → CaseRun → verdict.json`;CaseRun 是 common Execution 的 E2E 子类并保留 OperationRun。复杂测试可直接使用完整 `prepare → execute → judge → cleanup`,cleanup 总执行且失败进入 Verdict error。
|
|
39
|
+
- **Experiment**:公共身份是具名验证意图;E2E 子类增加 `service + caseset`。单一配置或单一 Arm 仍然是 Experiment,不以是否存在对照组为前提。
|
|
40
|
+
- **Kernel 对齐**:Runner `Outcome` 是 Observation,一次 CaseRun(`case_id + variant`)是 Unit;一组可复判 CaseRun Unit 构成 Dataset,本次选择的 assertion / deterministic judge / soft metric 直接定义评估侧重点。立即执行并直接写 Verdict 可以融合这些阶段,但必须保留 Outcome、来源 identity 和实际组件配置,使判断能够离线重放;详见 [`../../../docs/kernel.md`](../../../docs/kernel.md#dataset-与反复评估)。
|
|
41
|
+
- **case 两个编写前端**:手写 canonical CaseSet,或 `@case/@spec` NL marker 经 `casegen compile` 编译(`casegen check` 是无-LLM intent-hash 漂移闸门)。`binding.symbol_id + optional spec_id` 对齐 spec-case 的 plural `specs[]`;未填 assertion 的草稿是 error,不会假绿。
|
|
42
|
+
- **Outcome 是 runner↔judge 契约**:`status_code / headers / body / duration_ms / metadata / raw`;SSE events 进 `metadata['events']`,engine 的 `response_view` 暴露 `events[]` / `event_count`。
|
|
43
|
+
- **Config**(`config.yaml`,语言无关 schema):`service.{name,component,environment,base_url,headers}`,Environment 支持 generic、host、kubernetes 及可选 Host;另有 `runtime.*_timeout` / `discover.{source_root,test_root}`。环境实测值与配置分离,见 [Environment](../../../docs/environment.md)。harness 不内置服务专用 header 名。
|
|
44
|
+
- **verdict**(`spec/verdict-schema.yaml`):`e2e run` 跑完落 `runs/<scope>/<run-id>/verdict.json`(scope 默认 CaseSet 名,即 Experiment 名)。
|
|
45
|
+
|
|
46
|
+
- **当前范围**:服务 API 契约测试;Playbook / Script 与 Web、移动端、产品 API Target 的长期边界见领域设计。
|
|
47
|
+
- **跨语言对齐**:Python 与 Go 共享 CaseRun 生命周期、阶段 budget、cleanup 和 Verdict 语义,由仓库根目录 `conformance/e2e/` fixture 约束。
|
|
48
|
+
|
|
49
|
+
## References
|
|
50
|
+
|
|
51
|
+
- [E2E、Playbook 与 Target 设计](../../../docs/e2e-harness.md)
|
|
52
|
+
|
|
53
|
+
- 判定即数据架构(engine / 多编写前端 / 新老收敛):[`../../../docs/case-unification.md`](../../../docs/case-unification.md)
|
|
54
|
+
- casegen(NL marker → 结构化 case 的 build-time 编译器):[`../../../docs/casegen.md`](../../../docs/casegen.md)
|
|
55
|
+
- 跨语言约定(case/config schema):[`../../../spec/conventions.md`](../../../spec/conventions.md)
|
|
56
|
+
- 统一判定出口:[`../../../spec/verdict-schema.yaml`](../../../spec/verdict-schema.yaml) + conventions.md「Run 产物与 verdict 出口」
|
|
57
|
+
- 接入示例:[`../../../examples/api-test/`](../../../examples/api-test/) / [`../../../examples/agent-test/`](../../../examples/agent-test/)
|
e2e_harness/__init__.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Top-level public API.
|
|
2
|
+
|
|
3
|
+
The canonical path is **judgment-as-data**: a structured ``common.Case`` (``input`` +
|
|
4
|
+
``judge.e2e.assert``) is run by ``engine`` over a protocol ``runner`` → ``verdict`` (CLI
|
|
5
|
+
``e2e run``). Cases are authored as ``case.yaml`` or as ``@case``/``@spec`` markers compiled by
|
|
6
|
+
``casegen``. Submodules:
|
|
7
|
+
|
|
8
|
+
- ``engine`` / ``assertion``: run a case + evaluate ``judge.e2e.assert`` → verdict
|
|
9
|
+
- ``cli`` / ``casegen``: ``e2e run`` (run) and ``casegen`` (marker → case.yaml)
|
|
10
|
+
- ``runner``: protocol adapters (JSON / SSE) producing ``Outcome``
|
|
11
|
+
- ``core``: config loading + profile gating
|
|
12
|
+
- ``judge.metric``: soft outcome metrics
|
|
13
|
+
- ``casegen``: NL authoring front-end — ``@case``/``@spec`` markers + AST discovery + the
|
|
14
|
+
compiler that turns them into ``common.Case`` (``contract`` / ``discover`` / ``compiler``)
|
|
15
|
+
|
|
16
|
+
Dataset-driven evaluation lives in the sibling ``eval_harness`` package; see its README.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from e2e_harness.casegen.contract import CASE_ID_PATTERN as CASE_ID_PATTERN
|
|
20
|
+
from e2e_harness.casegen.contract import Case as Case
|
|
21
|
+
from e2e_harness.casegen.contract import CaseSpec as CaseSpec
|
|
22
|
+
from e2e_harness.casegen.contract import case as case
|
|
23
|
+
from e2e_harness.casegen.contract import case_hash as case_hash
|
|
24
|
+
from e2e_harness.casegen.contract import get_cases as get_cases
|
|
25
|
+
from e2e_harness.casegen.contract import get_links as get_links
|
|
26
|
+
from e2e_harness.casegen.contract import get_rules as get_rules
|
|
27
|
+
from e2e_harness.casegen.contract import get_spec as get_spec
|
|
28
|
+
from e2e_harness.casegen.contract import get_spec_id as get_spec_id
|
|
29
|
+
from e2e_harness.casegen.contract import link as link
|
|
30
|
+
from e2e_harness.casegen.contract import load_cases_file as load_cases_file
|
|
31
|
+
from e2e_harness.casegen.contract import rule as rule
|
|
32
|
+
from e2e_harness.casegen.contract import spec as spec
|
|
33
|
+
from e2e_harness.casegen.discover import DiscoverConfig as DiscoverConfig
|
|
34
|
+
from e2e_harness.casegen.discover import DiscoveredCase as DiscoveredCase
|
|
35
|
+
from e2e_harness.casegen.discover import discover as discover
|
|
36
|
+
from e2e_harness.assertion import Assertion as Assertion
|
|
37
|
+
from e2e_harness.assertion import run_asserts as run_asserts
|
|
38
|
+
from e2e_harness.core.config import E2EConfig as E2EConfig
|
|
39
|
+
from e2e_harness.core.config import Experiment as Experiment
|
|
40
|
+
from e2e_harness.core.config import load_config as load_config
|
|
41
|
+
from e2e_harness.core.profile import require_capability as require_capability
|
|
42
|
+
from e2e_harness.core.profile import require_profile as require_profile
|
|
43
|
+
from e2e_harness.engine import run_case as run_case
|
|
44
|
+
from e2e_harness.engine import run_cases as run_cases
|
|
45
|
+
from e2e_harness.caserun import Budgets as Budgets
|
|
46
|
+
from e2e_harness.caserun import CaseRef as CaseRef
|
|
47
|
+
from e2e_harness.caserun import CasePlan as CasePlan
|
|
48
|
+
from e2e_harness.caserun import CaseRun as CaseRun
|
|
49
|
+
from e2e_harness.caserun import Fail as Fail
|
|
50
|
+
from e2e_harness.caserun import PhaseContext as PhaseContext
|
|
51
|
+
from e2e_harness.caserun import Skip as Skip
|
|
52
|
+
from e2e_harness.caserun import run_lifecycle as run_lifecycle
|
|
53
|
+
from e2e_harness.matrix import Variant as Variant
|
|
54
|
+
from e2e_harness.matrix import expand_matrix as expand_matrix
|
|
55
|
+
from e2e_harness.judge.metric import BaseMetric as BaseMetric
|
|
56
|
+
from e2e_harness.judge.metric import EventCountMetric as EventCountMetric
|
|
57
|
+
from e2e_harness.judge.metric import LatencyMetric as LatencyMetric
|
|
58
|
+
from e2e_harness.judge.metric import MetricKind as MetricKind
|
|
59
|
+
from e2e_harness.judge.metric import MetricRegistry as MetricRegistry
|
|
60
|
+
from e2e_harness.judge.metric import MetricResult as MetricResult
|
|
61
|
+
from e2e_harness.judge.metric import StatusMetric as StatusMetric
|
|
62
|
+
from e2e_harness.judge.metric import score_outcome as score_outcome
|
|
63
|
+
from e2e_harness.runner.async_base import AsyncBaseRunner as AsyncBaseRunner
|
|
64
|
+
from e2e_harness.runner.async_json_runner import AsyncJSONRunner as AsyncJSONRunner
|
|
65
|
+
from e2e_harness.runner.async_sse_runner import AsyncSSERunner as AsyncSSERunner
|
|
66
|
+
from e2e_harness.engine import E2ERun as E2ERun
|
|
67
|
+
from e2e_harness.engine import run_experiment as run_experiment
|
|
68
|
+
from e2e_harness.runner.base import BaseRunner as BaseRunner
|
|
69
|
+
from e2e_harness.runner.base import Outcome as Outcome
|
|
70
|
+
from e2e_harness.runner.base import Request as Request
|
|
71
|
+
from e2e_harness.reducer import E2EReducer as E2EReducer
|
|
72
|
+
from e2e_harness.runner.events import collect_text as collect_text
|
|
73
|
+
from e2e_harness.runner.events import count_events as count_events
|
|
74
|
+
from e2e_harness.runner.events import events as events
|
|
75
|
+
from e2e_harness.runner.events import find_event as find_event
|
|
76
|
+
from e2e_harness.runner.events import find_event_data as find_event_data
|
|
77
|
+
from e2e_harness.runner.json_runner import JSONRunner as JSONRunner
|
|
78
|
+
from harness_toolbox.sse_parser import SSEEvent as SSEEvent
|
|
79
|
+
from harness_toolbox.sse_parser import SSEParser as SSEParser
|
|
80
|
+
from e2e_harness.runner.sse_runner import SSERunner as SSERunner
|
e2e_harness/__main__.py
ADDED
e2e_harness/assertion.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""``judge.e2e.assert`` — the structured, executable form of e2e judgment (判定即数据).
|
|
2
|
+
|
|
3
|
+
An assertion is a ``{path, op, value}`` triple checked against the SUT response. This is the
|
|
4
|
+
deterministic core of the e2e face: no LLM, no human-written test body — **the case data IS
|
|
5
|
+
the check**. The op set mirrors ``spec/case-schema.yaml`` (judge.e2e.assert) and the legacy
|
|
6
|
+
``judge.assert_judge.AssertJudge`` methods, but driven by *data* instead of method calls, so
|
|
7
|
+
the same checks are now reviewable, hashable (intent-drift) and cross-language.
|
|
8
|
+
|
|
9
|
+
The evaluator works over a plain **response-view dict** (the Outcome→view bridge lives in
|
|
10
|
+
``e2e_harness.engine``), so it is pure and unit-testable without a live runner. Paths address
|
|
11
|
+
that view uniformly: ``status``, ``body.id``, ``headers.content-type``, ``events[].type``
|
|
12
|
+
(project a field over a list), ``data.items[0].id`` (index).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any, Literal
|
|
20
|
+
|
|
21
|
+
# op vocabulary — kept in lockstep with spec/case-schema.yaml (judge.e2e.assert.op enum).
|
|
22
|
+
Op = Literal["eq", "not_empty", "contains", "matches", "in", "gt", "gte", "within"]
|
|
23
|
+
|
|
24
|
+
_MISSING = object() # path resolved to nothing — kept distinct from a present ``None``
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Assertion:
|
|
29
|
+
"""One structured check: ``op`` applied to the value at ``path``, against ``value``.
|
|
30
|
+
|
|
31
|
+
``value`` is the comparison operand (omitted for ``not_empty``; a 2-list ``[lo, hi]`` for
|
|
32
|
+
``within``; a list for ``in``).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
path: str
|
|
36
|
+
op: str
|
|
37
|
+
value: Any = None
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_dict(cls, d: dict) -> "Assertion":
|
|
41
|
+
return cls(path=str(d["path"]), op=str(d["op"]), value=d.get("value"))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class AssertOutcome:
|
|
46
|
+
assertion: Assertion
|
|
47
|
+
ok: bool
|
|
48
|
+
actual: Any
|
|
49
|
+
detail: str # human-facing why (empty when ok) — surfaced in the verdict reason
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _tokens(path: str) -> list[str]:
|
|
53
|
+
"""``a.b[].c`` → ``["a", "b", "[]", "c"]``; ``x[0]`` → ``["x", "[0]"]``."""
|
|
54
|
+
out: list[str] = []
|
|
55
|
+
for part in path.split("."):
|
|
56
|
+
m = re.match(r"^([^\[\]]*)(.*)$", part)
|
|
57
|
+
key, brackets = (m.group(1), m.group(2)) if m else (part, "")
|
|
58
|
+
if key:
|
|
59
|
+
out.append(key)
|
|
60
|
+
out.extend(re.findall(r"\[\d*\]", brackets))
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _resolve(data: Any, tokens: list[str]) -> Any:
|
|
65
|
+
if not tokens:
|
|
66
|
+
return data
|
|
67
|
+
tok, rest = tokens[0], tokens[1:]
|
|
68
|
+
if tok == "[]": # project the remaining path over every list element
|
|
69
|
+
if not isinstance(data, list):
|
|
70
|
+
return _MISSING
|
|
71
|
+
return [_resolve(el, rest) for el in data]
|
|
72
|
+
if tok.startswith("[") and tok.endswith("]"): # list index, e.g. "[0]"
|
|
73
|
+
try:
|
|
74
|
+
return _resolve(data[int(tok[1:-1])], rest)
|
|
75
|
+
except (ValueError, IndexError, TypeError, KeyError):
|
|
76
|
+
return _MISSING
|
|
77
|
+
if isinstance(data, dict) and tok in data: # dict key
|
|
78
|
+
return _resolve(data[tok], rest)
|
|
79
|
+
return _MISSING
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def resolve_path(data: dict, path: str) -> Any:
|
|
83
|
+
"""Value at ``path`` in the response view, or ``_MISSING`` when absent."""
|
|
84
|
+
return _resolve(data, _tokens(path))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _is_empty(v: Any) -> bool:
|
|
88
|
+
# ``_MISSING`` and empty containers/strings/None are empty; 0 / False are *values*.
|
|
89
|
+
return v is _MISSING or v is None or v == "" or v == [] or v == {} or v == ()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def check(op: str, actual: Any, value: Any) -> tuple[bool, str]:
|
|
93
|
+
"""Apply one op to a resolved value; return ``(ok, short detail)``. Empty detail iff ok."""
|
|
94
|
+
if op == "not_empty":
|
|
95
|
+
return (not _is_empty(actual)), ("" if not _is_empty(actual) else "is empty")
|
|
96
|
+
if actual is _MISSING:
|
|
97
|
+
return False, "path not found"
|
|
98
|
+
if op == "eq":
|
|
99
|
+
ok = actual == value
|
|
100
|
+
return ok, "" if ok else f"expected {value!r}, got {actual!r}"
|
|
101
|
+
if op == "contains":
|
|
102
|
+
try:
|
|
103
|
+
ok = value in actual
|
|
104
|
+
except TypeError:
|
|
105
|
+
ok = False
|
|
106
|
+
return ok, "" if ok else f"{actual!r} does not contain {value!r}"
|
|
107
|
+
if op == "matches":
|
|
108
|
+
ok = re.search(str(value), str(actual)) is not None
|
|
109
|
+
return ok, "" if ok else f"{actual!r} does not match /{value}/"
|
|
110
|
+
if op == "in":
|
|
111
|
+
ok = isinstance(value, (list, tuple, set)) and actual in value
|
|
112
|
+
return ok, "" if ok else f"{actual!r} not in {value!r}"
|
|
113
|
+
if op in ("gt", "gte"):
|
|
114
|
+
try:
|
|
115
|
+
ok = actual > value if op == "gt" else actual >= value
|
|
116
|
+
except TypeError:
|
|
117
|
+
return False, f"{actual!r} not comparable to {value!r}"
|
|
118
|
+
return ok, "" if ok else f"{actual!r} not {op} {value!r}"
|
|
119
|
+
if op == "within":
|
|
120
|
+
try:
|
|
121
|
+
lo, hi = value
|
|
122
|
+
ok = lo <= actual <= hi
|
|
123
|
+
except (TypeError, ValueError):
|
|
124
|
+
return False, f"within needs [lo, hi], got {value!r}"
|
|
125
|
+
return ok, "" if ok else f"{actual!r} not within [{lo}, {hi}]"
|
|
126
|
+
return False, f"unknown op {op!r}"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def evaluate(assertion: Assertion, data: dict) -> AssertOutcome:
|
|
130
|
+
actual = resolve_path(data, assertion.path)
|
|
131
|
+
ok, why = check(assertion.op, actual, assertion.value)
|
|
132
|
+
detail = "" if ok else f"{assertion.path} {assertion.op} {assertion.value!r}: {why}"
|
|
133
|
+
return AssertOutcome(
|
|
134
|
+
assertion=assertion,
|
|
135
|
+
ok=ok,
|
|
136
|
+
actual=(None if actual is _MISSING else actual),
|
|
137
|
+
detail=detail,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def run_asserts(asserts: list[Assertion], data: dict) -> list[AssertOutcome]:
|
|
142
|
+
"""Evaluate every assertion against the response view — all, not short-circuit (so one
|
|
143
|
+
run surfaces every failing check, not just the first)."""
|
|
144
|
+
return [evaluate(a, data) for a in asserts]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""casegen — the NL-case authoring front-end, one home for ``NL marker → common.Case``.
|
|
2
|
+
|
|
3
|
+
The pushed way to write an e2e case is NOT a hand-written ``case.yaml`` but an ``@case``/``@spec``
|
|
4
|
+
NL marker in the service's own source; casegen is the compiler that turns those into the
|
|
5
|
+
structured ``common.Case`` that ``engine`` executes as data (see ``docs/casegen.md``). The three
|
|
6
|
+
files here are one pipeline stage, kept together so the front-end has a structural home:
|
|
7
|
+
|
|
8
|
+
- ``contract``: ``@case`` / ``@spec`` / ``@rule`` / ``@link`` decorators + ``CaseSpec`` / ``Case`` + ``case_hash`` (the marker shape)
|
|
9
|
+
- ``discover``: AST scanner for ``@case`` / ``@spec`` (+ companion ``*_cases.yaml``) → ``DiscoveredCase``
|
|
10
|
+
- ``compiler``: ``Compiler`` seam (``StubCompiler`` / ``DraftCompiler`` / future ``LLMCompiler``) +
|
|
11
|
+
``compile``/``check``/``list`` orchestration + the ``casegen`` CLI
|
|
12
|
+
|
|
13
|
+
The judgment then runs as data via ``engine`` — no generated pytest body.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from e2e_harness.casegen.compiler import CheckReport as CheckReport
|
|
17
|
+
from e2e_harness.casegen.compiler import Compiler as Compiler
|
|
18
|
+
from e2e_harness.casegen.compiler import CompileReport as CompileReport
|
|
19
|
+
from e2e_harness.casegen.compiler import DraftCompiler as DraftCompiler
|
|
20
|
+
from e2e_harness.casegen.compiler import StubCompiler as StubCompiler
|
|
21
|
+
from e2e_harness.casegen.compiler import check_cases as check_cases
|
|
22
|
+
from e2e_harness.casegen.compiler import compile_cases as compile_cases
|
|
23
|
+
from e2e_harness.casegen.compiler import list_cases as list_cases
|
|
24
|
+
from e2e_harness.casegen.compiler import main as main
|
|
25
|
+
from e2e_harness.casegen.contract import CASE_ID_PATTERN as CASE_ID_PATTERN
|
|
26
|
+
from e2e_harness.casegen.contract import Case as Case
|
|
27
|
+
from e2e_harness.casegen.contract import CaseSpec as CaseSpec
|
|
28
|
+
from e2e_harness.casegen.contract import case as case
|
|
29
|
+
from e2e_harness.casegen.contract import case_hash as case_hash
|
|
30
|
+
from e2e_harness.casegen.contract import get_cases as get_cases
|
|
31
|
+
from e2e_harness.casegen.contract import get_links as get_links
|
|
32
|
+
from e2e_harness.casegen.contract import get_rules as get_rules
|
|
33
|
+
from e2e_harness.casegen.contract import get_spec as get_spec
|
|
34
|
+
from e2e_harness.casegen.contract import get_spec_id as get_spec_id
|
|
35
|
+
from e2e_harness.casegen.contract import link as link
|
|
36
|
+
from e2e_harness.casegen.contract import load_cases_file as load_cases_file
|
|
37
|
+
from e2e_harness.casegen.contract import rule as rule
|
|
38
|
+
from e2e_harness.casegen.contract import spec as spec
|
|
39
|
+
from e2e_harness.casegen.discover import DiscoverConfig as DiscoverConfig
|
|
40
|
+
from e2e_harness.casegen.discover import DiscoveredCase as DiscoveredCase
|
|
41
|
+
from e2e_harness.casegen.discover import discover as discover
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""casegen — build-time compiler: NL markers (``@case``/``@spec``) → structured ``case.yaml``.
|
|
2
|
+
|
|
3
|
+
The build-time half of judgment-as-data (see ``docs/casegen.md``): ``discover`` extracts the NL
|
|
4
|
+
markers, a ``Compiler`` turns each into a structured ``common.Case`` (with ``judge.e2e.assert``),
|
|
5
|
+
and casegen writes a committed ``case.yaml`` that ``e2e run`` executes. An ``intent hash`` (the
|
|
6
|
+
marker's ``case_hash`` over NL + ``@spec``) is recorded per case in a top-level ``compiled_from``
|
|
7
|
+
map, so ``check`` is a deterministic, no-LLM drift gate and ``compile`` only re-compiles markers
|
|
8
|
+
whose intent actually drifted (preserving human/LLM-filled asserts on the rest).
|
|
9
|
+
|
|
10
|
+
This module is step ① — the deterministic skeleton (Compiler protocol + ``StubCompiler`` +
|
|
11
|
+
orchestration + ``check``). The real fill (``DraftCompiler`` → claude, then ``LLMCompiler`` →
|
|
12
|
+
``harness_toolbox.llm``) is a drop-in for ``Compiler``; nothing else changes.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import sys
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Protocol
|
|
22
|
+
|
|
23
|
+
import yaml
|
|
24
|
+
|
|
25
|
+
from spec_case.model import Binding, Case, case_to_raw, load_caseset, validate
|
|
26
|
+
from e2e_harness.casegen.discover import DiscoverConfig, DiscoveredCase, discover
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Compiler(Protocol):
|
|
30
|
+
"""NL marker → structured ``common.Case``. The one seam the LLM lives behind."""
|
|
31
|
+
|
|
32
|
+
def compile(self, nl: DiscoveredCase) -> Case: ...
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class StubCompiler:
|
|
36
|
+
"""Deterministic, no-LLM — for testing orchestration/drift and as the abstraction's anchor.
|
|
37
|
+
|
|
38
|
+
Projects only the identity (``id``) and leaves ``judge.e2e.assert`` empty; a real compiler
|
|
39
|
+
(``DraftCompiler`` / ``LLMCompiler``) fills the asserts from the NL intent.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def compile(self, nl: DiscoveredCase) -> Case:
|
|
43
|
+
return Case(
|
|
44
|
+
id=nl.case.id,
|
|
45
|
+
input={},
|
|
46
|
+
judge={"e2e": {"assert": []}},
|
|
47
|
+
binding=_binding(nl),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DraftCompiler:
|
|
52
|
+
"""v1, no LLM — a self-contained, fillable draft (mirrors eval-api's "hand to claude" loop).
|
|
53
|
+
|
|
54
|
+
Projects the identity and **inlines the NL intent into ``desc`` as fill-guidance**, leaving
|
|
55
|
+
``input`` and ``judge.e2e.assert`` empty for a human / claude to complete. The guidance lives
|
|
56
|
+
in ``desc`` (round-trips through yaml, excluded from ``case_hash``, human + LLM readable) so the
|
|
57
|
+
structured ``input`` / ``judge`` namespaces stay clean. An unfilled draft (e2e face declared, no
|
|
58
|
+
asserts) → ``error`` in ``e2e run`` — it can't be hidden green by a sibling pass; fill the
|
|
59
|
+
asserts to make it judgeable. (The future ``LLMCompiler`` fills ``input`` + ``assert`` directly.)
|
|
60
|
+
|
|
61
|
+
Why not auto-fill asserts by rule: ``input`` is NL (e.g. "req body"), can't be parsed to a real
|
|
62
|
+
request deterministically — and asserts without a real ``input`` would false-fail at run. So a
|
|
63
|
+
draft fills *neither*; both are completed together.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def compile(self, nl: DiscoveredCase) -> Case:
|
|
67
|
+
c = nl.case
|
|
68
|
+
guide = [c.desc] if c.desc else []
|
|
69
|
+
guide.append(
|
|
70
|
+
"—— casegen draft: fill input + judge.e2e.assert from the intent below ——"
|
|
71
|
+
)
|
|
72
|
+
for label, val in (
|
|
73
|
+
("input(NL)", c.input),
|
|
74
|
+
("expect", c.expect),
|
|
75
|
+
("forbid", c.forbid),
|
|
76
|
+
("spec", nl.spec_text),
|
|
77
|
+
):
|
|
78
|
+
if val:
|
|
79
|
+
guide.append(f"{label}: {val}")
|
|
80
|
+
return Case(
|
|
81
|
+
id=c.id,
|
|
82
|
+
input={},
|
|
83
|
+
desc="\n".join(guide),
|
|
84
|
+
judge={"e2e": {"assert": []}},
|
|
85
|
+
binding=_binding(nl),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --------------------------------------------------------------------------- serialization
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _case_to_dict(case: Case) -> dict:
|
|
93
|
+
"""Delegate canonical Case serialization to spec-case."""
|
|
94
|
+
return case_to_raw(case)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _binding(nl: DiscoveredCase) -> Binding:
|
|
98
|
+
return Binding(
|
|
99
|
+
symbol_id=nl.symbol_id,
|
|
100
|
+
spec=nl.spec_text or "",
|
|
101
|
+
spec_id=nl.spec_id,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _write_caseset(
|
|
106
|
+
out: Path, caseset: str, cases: list[Case], compiled_from: dict[str, str]
|
|
107
|
+
) -> None:
|
|
108
|
+
doc = {
|
|
109
|
+
"caseset": caseset,
|
|
110
|
+
# casegen-owned metadata: case_id → intent hash it was compiled from. `load_caseset`
|
|
111
|
+
# ignores this top-level key; `check` reads it to detect marker drift.
|
|
112
|
+
"compiled_from": dict(sorted(compiled_from.items())),
|
|
113
|
+
"cases": [_case_to_dict(c) for c in cases],
|
|
114
|
+
}
|
|
115
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
out.write_text(
|
|
117
|
+
yaml.safe_dump(doc, sort_keys=False, allow_unicode=True), encoding="utf-8"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _read_compiled(out: Path) -> tuple[dict[str, Case], dict[str, str]]:
|
|
122
|
+
"""Existing compiled file → (cases by id, ``compiled_from`` map). Empty if absent."""
|
|
123
|
+
if not out.exists():
|
|
124
|
+
return {}, {}
|
|
125
|
+
cases = {c.id: c for c in load_caseset(out).cases}
|
|
126
|
+
raw = yaml.safe_load(out.read_text()) or {}
|
|
127
|
+
return cases, dict(raw.get("compiled_from") or {})
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --------------------------------------------------------------------------- orchestration
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class CompileReport:
|
|
135
|
+
compiled: list[str] = field(default_factory=list) # new or drifted → (re)compiled
|
|
136
|
+
reused: list[str] = field(
|
|
137
|
+
default_factory=list
|
|
138
|
+
) # intent unchanged → existing kept (edits preserved)
|
|
139
|
+
dropped: list[str] = field(
|
|
140
|
+
default_factory=list
|
|
141
|
+
) # in old file but no longer a marker (orphaned)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def compile_cases(
|
|
145
|
+
config: DiscoverConfig, compiler: Compiler, out: Path, *, caseset: str
|
|
146
|
+
) -> CompileReport:
|
|
147
|
+
"""(Re)compile drifted/new markers → write ``out``. Markers whose intent is unchanged keep
|
|
148
|
+
their existing compiled case verbatim — so a human/LLM's filled asserts survive re-runs."""
|
|
149
|
+
discovered = discover(config)
|
|
150
|
+
prev_cases, prev_hashes = _read_compiled(out)
|
|
151
|
+
rep = CompileReport()
|
|
152
|
+
cases: list[Case] = []
|
|
153
|
+
compiled_from: dict[str, str] = {}
|
|
154
|
+
for dc in discovered:
|
|
155
|
+
cid = dc.case.id
|
|
156
|
+
compiled_from[cid] = dc.case_hash
|
|
157
|
+
if cid in prev_cases and prev_hashes.get(cid) == dc.case_hash:
|
|
158
|
+
cases.append(
|
|
159
|
+
prev_cases[cid]
|
|
160
|
+
) # intent unchanged → keep (don't clobber filled asserts)
|
|
161
|
+
rep.reused.append(cid)
|
|
162
|
+
else:
|
|
163
|
+
cases.append(compiler.compile(dc)) # new or drifted → recompile
|
|
164
|
+
rep.compiled.append(cid)
|
|
165
|
+
rep.dropped = sorted(set(prev_cases) - {dc.case.id for dc in discovered})
|
|
166
|
+
_write_caseset(out, caseset, cases, compiled_from)
|
|
167
|
+
return rep
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass
|
|
171
|
+
class CheckReport:
|
|
172
|
+
drifted: list[str] = field(
|
|
173
|
+
default_factory=list
|
|
174
|
+
) # marker intent ≠ compiled (or never compiled)
|
|
175
|
+
orphaned: list[str] = field(default_factory=list) # compiled but marker gone
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def ok(self) -> bool:
|
|
179
|
+
return not self.drifted and not self.orphaned
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def check_cases(config: DiscoverConfig, out: Path) -> CheckReport:
|
|
183
|
+
"""Deterministic, no-LLM drift gate: compiled ``case.yaml`` must be in sync with the markers."""
|
|
184
|
+
discovered = discover(config)
|
|
185
|
+
_, prev_hashes = _read_compiled(out)
|
|
186
|
+
marker_ids = {dc.case.id for dc in discovered}
|
|
187
|
+
rep = CheckReport()
|
|
188
|
+
rep.drifted = sorted(
|
|
189
|
+
dc.case.id for dc in discovered if prev_hashes.get(dc.case.id) != dc.case_hash
|
|
190
|
+
)
|
|
191
|
+
rep.orphaned = sorted(set(prev_hashes) - marker_ids)
|
|
192
|
+
return rep
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def list_cases(config: DiscoverConfig, out: Path) -> list[tuple[str, str]]:
|
|
196
|
+
"""(case_id, status) where status ∈ in-sync / drifted / new / orphaned."""
|
|
197
|
+
discovered = discover(config)
|
|
198
|
+
prev_cases, prev_hashes = _read_compiled(out)
|
|
199
|
+
rows: list[tuple[str, str]] = []
|
|
200
|
+
for dc in discovered:
|
|
201
|
+
cid = dc.case.id
|
|
202
|
+
if cid not in prev_hashes:
|
|
203
|
+
rows.append((cid, "new"))
|
|
204
|
+
elif prev_hashes[cid] != dc.case_hash:
|
|
205
|
+
rows.append((cid, "drifted"))
|
|
206
|
+
else:
|
|
207
|
+
rows.append((cid, "in-sync"))
|
|
208
|
+
rows += [
|
|
209
|
+
(cid, "orphaned")
|
|
210
|
+
for cid in sorted(set(prev_cases) - {dc.case.id for dc in discovered})
|
|
211
|
+
]
|
|
212
|
+
return rows
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# --------------------------------------------------------------------------- CLI
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _config(args: argparse.Namespace) -> DiscoverConfig:
|
|
219
|
+
src = Path(args.source)
|
|
220
|
+
if not src.is_dir():
|
|
221
|
+
raise SystemExit(f"casegen: --source {src} is not a directory")
|
|
222
|
+
return DiscoverConfig(source_root=src, test_root=src) # test_root unused by casegen
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _out(args: argparse.Namespace) -> Path:
|
|
226
|
+
return Path(args.out)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def cmd_list(args: argparse.Namespace) -> int:
|
|
230
|
+
for cid, status in list_cases(_config(args), _out(args)):
|
|
231
|
+
print(f" {status:<9} {cid}")
|
|
232
|
+
return 0
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def cmd_compile(args: argparse.Namespace) -> int:
|
|
236
|
+
rep = compile_cases(
|
|
237
|
+
_config(args), DraftCompiler(), _out(args), caseset=args.caseset
|
|
238
|
+
)
|
|
239
|
+
print(
|
|
240
|
+
f"casegen: compiled {len(rep.compiled)}, reused {len(rep.reused)}, dropped {len(rep.dropped)} → {args.out}"
|
|
241
|
+
)
|
|
242
|
+
if rep.compiled:
|
|
243
|
+
validate(load_caseset(_out(args))) # the written file is a valid case set
|
|
244
|
+
return 0
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def cmd_check(args: argparse.Namespace) -> int:
|
|
248
|
+
rep = check_cases(_config(args), _out(args))
|
|
249
|
+
if rep.ok:
|
|
250
|
+
print("casegen: in sync ✓")
|
|
251
|
+
return 0
|
|
252
|
+
for cid in rep.drifted:
|
|
253
|
+
print(f" drifted {cid} (marker changed — re-run `casegen compile`)")
|
|
254
|
+
for cid in rep.orphaned:
|
|
255
|
+
print(f" orphaned {cid} (compiled case has no marker)")
|
|
256
|
+
return 1
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
260
|
+
parser = argparse.ArgumentParser(
|
|
261
|
+
prog="casegen", description="Compile NL @case markers → structured case.yaml."
|
|
262
|
+
)
|
|
263
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
264
|
+
for name, fn, help_ in [
|
|
265
|
+
("list", cmd_list, "list markers + drift status"),
|
|
266
|
+
("compile", cmd_compile, "(re)compile drifted/new markers → case.yaml"),
|
|
267
|
+
("check", cmd_check, "drift gate (no LLM): markers vs compiled case.yaml"),
|
|
268
|
+
]:
|
|
269
|
+
p = sub.add_parser(name, help=help_)
|
|
270
|
+
p.add_argument(
|
|
271
|
+
"--source", required=True, help="dir to scan for @case/@spec markers"
|
|
272
|
+
)
|
|
273
|
+
p.add_argument("--out", required=True, help="compiled case.yaml path")
|
|
274
|
+
if name == "compile":
|
|
275
|
+
p.add_argument(
|
|
276
|
+
"--caseset", default="cases", help="case set name written into the file"
|
|
277
|
+
)
|
|
278
|
+
p.set_defaults(func=fn)
|
|
279
|
+
return parser
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def main(argv: list[str] | None = None) -> int:
|
|
283
|
+
args = build_parser().parse_args(argv)
|
|
284
|
+
return args.func(args)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
if __name__ == "__main__":
|
|
288
|
+
sys.exit(main())
|