@michaelxwb/code-flow 0.6.4 → 0.8.1
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.
- package/README.md +47 -3
- package/docs/USAGE.md +1529 -0
- package/docs/acceptance-contract.md +217 -0
- package/docs/ci/acceptance-evidence.md +77 -0
- package/docs/migrations/runtime-0.7.0.md +41 -0
- package/docs/migrations/runtime-0.8.0.md +154 -0
- package/docs/migrations/runtime-0.9.0.md +40 -0
- package/docs/spec-injection-hardening.md +54 -0
- package/docs/spec-injection-performance.json +73 -0
- package/docs/spec-optimization.md +12 -0
- package/package.json +18 -3
- package/src/adapters/claude/CLAUDE.md +1 -1
- package/src/adapters/claude/commands/cf-init.md +1 -3
- package/src/adapters/claude/commands/cf-spec.md +23 -3
- package/src/adapters/claude/commands/cf-stats.md +11 -1
- package/src/adapters/claude/commands/cf-sync.md +20 -6
- package/src/adapters/claude/commands/cf-task/align.md +25 -3
- package/src/adapters/claude/commands/cf-task/archive.md +36 -40
- package/src/adapters/claude/commands/cf-task/block.md +15 -2
- package/src/adapters/claude/commands/cf-task/graph.md +13 -1
- package/src/adapters/claude/commands/cf-task/note.md +13 -1
- package/src/adapters/claude/commands/cf-task/plan.md +137 -23
- package/src/adapters/claude/commands/cf-task/prd.md +13 -2
- package/src/adapters/claude/commands/cf-task/start.md +67 -35
- package/src/adapters/claude/commands/cf-task/status.md +12 -0
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +72 -34
- package/src/adapters/claude/commands/cf-validate.md +30 -5
- package/src/adapters/codex/AGENTS.md +1 -1
- package/src/adapters/codex/hooks.json +14 -10
- package/src/adapters/codex/skills/cf-init/SKILL.md +4 -4
- package/src/adapters/codex/skills/cf-spec/SKILL.md +34 -6
- package/src/adapters/codex/skills/cf-stats/SKILL.md +11 -1
- package/src/adapters/codex/skills/cf-sync/SKILL.md +21 -6
- package/src/adapters/codex/skills/cf-task-align/SKILL.md +30 -8
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +36 -40
- package/src/adapters/codex/skills/cf-task-block/SKILL.md +15 -2
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +13 -1
- package/src/adapters/codex/skills/cf-task-note/SKILL.md +13 -1
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +175 -21
- package/src/adapters/codex/skills/cf-task-prd/SKILL.md +13 -2
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +67 -35
- package/src/adapters/codex/skills/cf-task-status/SKILL.md +12 -0
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +73 -34
- package/src/adapters/codex/skills/cf-validate/SKILL.md +31 -5
- package/src/adapters/commands.json +115 -0
- package/src/adapters/costrict/CLAUDE.md +1 -1
- package/src/adapters/costrict/commands/cf-init.md +1 -3
- package/src/adapters/costrict/commands/cf-spec.md +23 -3
- package/src/adapters/costrict/commands/cf-stats.md +11 -1
- package/src/adapters/costrict/commands/cf-sync.md +20 -6
- package/src/adapters/costrict/commands/cf-task/align.md +25 -3
- package/src/adapters/costrict/commands/cf-task/archive.md +36 -40
- package/src/adapters/costrict/commands/cf-task/block.md +15 -2
- package/src/adapters/costrict/commands/cf-task/graph.md +13 -1
- package/src/adapters/costrict/commands/cf-task/note.md +13 -1
- package/src/adapters/costrict/commands/cf-task/plan.md +137 -23
- package/src/adapters/costrict/commands/cf-task/prd.md +13 -2
- package/src/adapters/costrict/commands/cf-task/start.md +67 -35
- package/src/adapters/costrict/commands/cf-task/status.md +12 -0
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +72 -34
- package/src/adapters/costrict/commands/cf-validate.md +30 -5
- package/src/adapters/install-plan.json +100 -0
- package/src/adapters/opencode/AGENTS.md +1 -1
- package/src/adapters/opencode/commands/cf-init.md +7 -7
- package/src/adapters/opencode/commands/cf-learn.md +6 -6
- package/src/adapters/opencode/commands/cf-spec.md +34 -2
- package/src/adapters/opencode/commands/cf-stats.md +17 -7
- package/src/adapters/opencode/commands/cf-sync.md +21 -6
- package/src/adapters/opencode/commands/cf-task/align.md +25 -8
- package/src/adapters/opencode/commands/cf-task/archive.md +36 -40
- package/src/adapters/opencode/commands/cf-task/block.md +16 -2
- package/src/adapters/opencode/commands/cf-task/graph.md +14 -1
- package/src/adapters/opencode/commands/cf-task/note.md +14 -1
- package/src/adapters/opencode/commands/cf-task/plan.md +137 -23
- package/src/adapters/opencode/commands/cf-task/prd.md +13 -2
- package/src/adapters/opencode/commands/cf-task/start.md +68 -41
- package/src/adapters/opencode/commands/cf-task/status.md +13 -0
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +72 -34
- package/src/adapters/opencode/commands/cf-validate.md +35 -9
- package/src/adapters/opencode/plugins/code-flow/index.js +128 -179
- package/src/adapters/platform-bindings.json +370 -0
- package/src/cli.js +369 -316
- package/src/core/code-flow/config.yml +4 -1
- package/src/core/code-flow/runtime-commands.json +259 -0
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +5 -289
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +5 -187
- package/src/core/code-flow/scripts/cf_audit.py +10 -0
- package/src/core/code-flow/scripts/cf_codex_hook.py +10 -0
- package/src/core/code-flow/scripts/cf_design_lint.py +10 -0
- package/src/core/code-flow/scripts/cf_evidence_import.py +10 -0
- package/src/core/code-flow/scripts/cf_feedback.py +5 -51
- package/src/core/code-flow/scripts/cf_opencode_event.py +10 -0
- package/src/core/code-flow/scripts/cf_post_hook.py +6 -191
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +6 -99
- package/src/core/code-flow/scripts/cf_scenarios.py +10 -0
- package/src/core/code-flow/scripts/cf_spec_context.py +5 -1714
- package/src/core/code-flow/scripts/cf_spec_gate.py +5 -257
- package/src/core/code-flow/scripts/cf_spec_session.py +5 -114
- package/src/core/code-flow/scripts/cf_stats.py +5 -501
- package/src/core/code-flow/scripts/cf_stop_hook.py +6 -376
- package/src/core/code-flow/scripts/cf_sync.py +5 -135
- package/src/core/code-flow/scripts/cf_task_index.py +5 -143
- package/src/core/code-flow/scripts/cf_task_parallel.py +5 -380
- package/src/core/code-flow/scripts/cf_task_workflow.py +6 -103
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +6 -173
- package/src/core/code-flow/scripts/cf_validation.py +6 -77
- package/src/core/code-flow/scripts/codeflow/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_evidence.py +347 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_manifest_core.py +660 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_plan.py +202 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_registration.py +60 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_risk.py +73 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_schema.py +282 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_selection.py +266 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_inputs.py +172 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_registration.py +326 -0
- package/src/core/code-flow/scripts/{cf_checks.py → codeflow/acceptance/cf_checks.py} +8 -11
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_plan_quality.py +197 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_quality.py +174 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_source.py +300 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_traceability.py +502 -0
- package/src/core/code-flow/scripts/codeflow/cli/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_manifest.py +347 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_runner.py +1490 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_audit.py +533 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_design_lint.py +172 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_evidence_import.py +607 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_feedback.py +53 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_scenarios.py +58 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_context.py +1175 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_gate.py +60 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_session.py +46 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_stats.py +540 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_sync.py +144 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_index.py +177 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_parallel.py +1142 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_workflow.py +742 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_validation.py +31 -0
- package/src/core/code-flow/scripts/codeflow/core/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_context_cache.py +67 -0
- package/src/core/code-flow/scripts/{cf_core.py → codeflow/core/cf_core.py} +119 -297
- package/src/core/code-flow/scripts/codeflow/core/cf_exec_base.py +325 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_fs.py +57 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_git.py +92 -0
- package/src/core/code-flow/scripts/{cf_log.py → codeflow/core/cf_log.py} +13 -7
- package/src/core/code-flow/scripts/codeflow/core/cf_markdown.py +77 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_native_paths.py +26 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_patch.py +33 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_runtime_install.py +32 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_stop_guard.py +53 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_task_state.py +9 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_validators.py +408 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_worktree_env.py +253 -0
- package/src/core/code-flow/scripts/codeflow/evidence/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/evidence/cf_evidence_store.py +527 -0
- package/src/core/code-flow/scripts/codeflow/hooks/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_codex_hook.py +95 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_opencode_event.py +98 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_post_hook.py +30 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_pre_tool_hook.py +41 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_stop_hook.py +292 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_user_prompt_hook.py +161 -0
- package/src/core/code-flow/scripts/codeflow/spec/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_active_task.py +412 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_context_refresh.py +301 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_edit_service.py +149 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_injection.py +111 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_rule_readiness.py +114 -0
- package/src/core/code-flow/scripts/{cf_scan.py → codeflow/spec/cf_scan.py} +93 -21
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_budget.py +55 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_context_model.py +477 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_impact.py +119 -0
- package/src/core/code-flow/scripts/{cf_spec_metadata.py → codeflow/spec/cf_spec_metadata.py} +37 -3
- package/src/core/code-flow/scripts/{cf_spec_migrate.py → codeflow/spec/cf_spec_migrate.py} +27 -10
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize.py +193 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize_apply.py +187 -0
- package/src/core/code-flow/scripts/{cf_spec_resolver.py → codeflow/spec/cf_spec_resolver.py} +39 -17
- package/src/core/code-flow/scripts/{cf_spec_router.py → codeflow/spec/cf_spec_router.py} +96 -36
- package/src/core/code-flow/scripts/{cf_spec_verify.py → codeflow/spec/cf_spec_verify.py} +252 -26
- package/src/core/code-flow/scripts/codeflow/spec/cf_stage_gate.py +330 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_task_projection.py +83 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_service.py +755 -0
- package/src/core/code-flow/scripts/{cf_workflow_transaction.py → codeflow/spec/cf_workflow_transaction.py} +29 -15
- package/src/core/code-flow/scripts/codeflow/task/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_requirement_verification.py +867 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_doc.py +178 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_merge.py +635 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_plan.py +251 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_task_runtime.py +570 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_validation_service.py +73 -0
- package/src/core/code-flow/scripts/runtime-resources.json +29 -0
- package/src/core/code-flow/specs/shared/design/design-frontend.md +18 -15
- package/src/core/code-flow/specs/shared/design/design-full.md +33 -23
- package/src/core/code-flow/specs/shared/design/design-lite.md +23 -13
- package/src/core/code-flow/specs/shared/scenarios-template.md +55 -0
- package/src/core/code-flow/validation.yml +9 -3
- package/src/migrate/runtime.js +213 -0
- package/src/migrate/spec-workflow.js +16 -1
- package/src/runtime.js +219 -0
- package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
- package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
- package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
- package/src/core/code-flow/scripts/cf_task_runtime.py +0 -299
- package/src/core/code-flow/scripts/cf_task_state.py +0 -3
- package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
- /package/src/core/code-flow/scripts/{cf_session_state.py → codeflow/core/cf_session_state.py} +0 -0
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Import CI JUnit / Playwright reports as central acceptance evidence.
|
|
3
|
+
|
|
4
|
+
CI owns full E2E execution; the local workflow imports the report instead of
|
|
5
|
+
re-running it. Imports are fail-closed:
|
|
6
|
+
|
|
7
|
+
- ``--commit`` must resolve to the current ``HEAD``; a different commit means
|
|
8
|
+
the report does not describe this worktree and is rejected.
|
|
9
|
+
- The worktree must be clean (ignoring ``.code-flow/``): CI proves committed
|
|
10
|
+
content, not uncommitted local edits.
|
|
11
|
+
- ``run_url``, ``input_hash``, ``exit_code`` and ``source=ci`` are mandatory
|
|
12
|
+
provenance; ``input_hash`` binds evidence to the exact worktree content.
|
|
13
|
+
- Every test case must map to a manifest check/scenario. Unmapped cases are
|
|
14
|
+
rejected instead of being silently attributed or dropped.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
import hashlib
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
import re
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
from typing import IO, Mapping, Optional, Sequence
|
|
29
|
+
from urllib.parse import urlparse
|
|
30
|
+
import xml.etree.ElementTree as ET
|
|
31
|
+
|
|
32
|
+
from codeflow.acceptance.cf_acceptance_evidence import persist_results
|
|
33
|
+
from codeflow.acceptance.cf_acceptance_schema import load_manifest, scenario_check_ids
|
|
34
|
+
from codeflow.acceptance.cf_check_inputs import check_input_hashes
|
|
35
|
+
from codeflow.evidence.cf_evidence_store import EvidenceStoreError, discover_project_root
|
|
36
|
+
from codeflow.core.cf_exec_base import execution_environment_hash, worktree_fingerprint
|
|
37
|
+
from codeflow.core.cf_worktree_env import check_environment
|
|
38
|
+
|
|
39
|
+
MANIFEST_NAME = ".acceptance-manifest.json"
|
|
40
|
+
FORMATS = ("junit", "playwright")
|
|
41
|
+
_PASSED, _FAILED, _SKIPPED = "passed", "failed", "skipped"
|
|
42
|
+
_FAILED_RESULTS = ("failed", "timedOut", "interrupted")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class EvidenceImportError(ValueError):
|
|
46
|
+
"""Stable rejection for an import that cannot prove its claims."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, code: str, field: str, message: str, path: str = "") -> None:
|
|
49
|
+
super().__init__(message)
|
|
50
|
+
self.code = code
|
|
51
|
+
self.field = field
|
|
52
|
+
self.message = message
|
|
53
|
+
self.path = path
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class ReportCase:
|
|
58
|
+
"""One executable test case from a CI report."""
|
|
59
|
+
|
|
60
|
+
name: str
|
|
61
|
+
classname: str
|
|
62
|
+
file: str
|
|
63
|
+
line: Optional[int]
|
|
64
|
+
status: str
|
|
65
|
+
message: str = ""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _local(tag: str) -> str:
|
|
69
|
+
return tag.rsplit("}", 1)[-1]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _text(value: object) -> str:
|
|
73
|
+
return value.strip() if isinstance(value, str) else ""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _line(value: object) -> Optional[int]:
|
|
77
|
+
text = _text(value)
|
|
78
|
+
return int(text) if text.isdigit() else None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _junit_case(node: ET.Element) -> ReportCase:
|
|
82
|
+
tags = {_local(child.tag) for child in node}
|
|
83
|
+
if tags & {"failure", "error"}:
|
|
84
|
+
status = _FAILED
|
|
85
|
+
elif "skipped" in tags:
|
|
86
|
+
status = _SKIPPED
|
|
87
|
+
else:
|
|
88
|
+
status = _PASSED
|
|
89
|
+
detail = next((child for child in node if _local(child.tag) in {"failure", "error"}), None)
|
|
90
|
+
message = _text(detail.get("message")) if detail is not None else ""
|
|
91
|
+
return ReportCase(
|
|
92
|
+
name=_text(node.get("name")),
|
|
93
|
+
classname=_text(node.get("classname")),
|
|
94
|
+
file=_text(node.get("file")),
|
|
95
|
+
line=_line(node.get("line")),
|
|
96
|
+
status=status,
|
|
97
|
+
message=message,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _parse_junit(path: Path) -> list[ReportCase]:
|
|
102
|
+
try:
|
|
103
|
+
root = ET.parse(str(path)).getroot()
|
|
104
|
+
except (OSError, UnicodeError, ET.ParseError) as exc:
|
|
105
|
+
raise EvidenceImportError("invalid_report", "file", f"invalid JUnit XML: {exc}", str(path)) from exc
|
|
106
|
+
cases = [_junit_case(node) for node in root.iter() if _local(node.tag) == "testcase"]
|
|
107
|
+
if not cases:
|
|
108
|
+
raise EvidenceImportError("empty_report", "file", "JUnit report has no testcase", str(path))
|
|
109
|
+
return cases
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _playwright_status(test: Mapping[str, object]) -> str:
|
|
113
|
+
results = test.get("results")
|
|
114
|
+
last = ""
|
|
115
|
+
if isinstance(results, list) and results and isinstance(results[-1], Mapping):
|
|
116
|
+
last = _text(results[-1].get("status"))
|
|
117
|
+
status = _text(test.get("status"))
|
|
118
|
+
if last in _FAILED_RESULTS or status == "unexpected":
|
|
119
|
+
return _FAILED
|
|
120
|
+
if status == "skipped" or last == "skipped":
|
|
121
|
+
return _SKIPPED
|
|
122
|
+
return _PASSED if last == "passed" else _SKIPPED
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _playwright_spec_status(tests: Sequence[object]) -> str:
|
|
126
|
+
statuses = [_playwright_status(test) for test in tests if isinstance(test, Mapping)]
|
|
127
|
+
if _FAILED in statuses:
|
|
128
|
+
return _FAILED
|
|
129
|
+
if statuses and all(status == _PASSED for status in statuses):
|
|
130
|
+
return _PASSED
|
|
131
|
+
return _SKIPPED
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _walk_playwright_suite(suite: Mapping[str, object], parent_file: str, cases: list[ReportCase]) -> None:
|
|
135
|
+
file = _text(suite.get("file")) or parent_file
|
|
136
|
+
specs = suite.get("specs")
|
|
137
|
+
for spec in specs if isinstance(specs, list) else []:
|
|
138
|
+
if not isinstance(spec, Mapping):
|
|
139
|
+
continue
|
|
140
|
+
tests = spec.get("tests")
|
|
141
|
+
cases.append(
|
|
142
|
+
ReportCase(
|
|
143
|
+
name=_text(spec.get("title")),
|
|
144
|
+
classname=_text(suite.get("title")),
|
|
145
|
+
file=_text(spec.get("file")) or file,
|
|
146
|
+
line=_line(spec.get("line")),
|
|
147
|
+
status=_playwright_spec_status(tests if isinstance(tests, list) else []),
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
children = suite.get("suites")
|
|
151
|
+
for child in children if isinstance(children, list) else []:
|
|
152
|
+
if isinstance(child, Mapping):
|
|
153
|
+
_walk_playwright_suite(child, file, cases)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _parse_playwright(path: Path) -> list[ReportCase]:
|
|
157
|
+
try:
|
|
158
|
+
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
159
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
160
|
+
raise EvidenceImportError("invalid_report", "file", f"invalid Playwright JSON: {exc}", str(path)) from exc
|
|
161
|
+
suites = loaded.get("suites") if isinstance(loaded, Mapping) else None
|
|
162
|
+
if not isinstance(suites, list):
|
|
163
|
+
raise EvidenceImportError("invalid_report", "file", "Playwright report needs a suites list", str(path))
|
|
164
|
+
cases: list[ReportCase] = []
|
|
165
|
+
for suite in suites:
|
|
166
|
+
if isinstance(suite, Mapping):
|
|
167
|
+
_walk_playwright_suite(suite, "", cases)
|
|
168
|
+
if not cases:
|
|
169
|
+
raise EvidenceImportError("empty_report", "file", "Playwright report has no spec", str(path))
|
|
170
|
+
return cases
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def load_report(path: object, fmt: str) -> list[ReportCase]:
|
|
174
|
+
report = Path(path)
|
|
175
|
+
if fmt == "junit":
|
|
176
|
+
return _parse_junit(report)
|
|
177
|
+
if fmt == "playwright":
|
|
178
|
+
return _parse_playwright(report)
|
|
179
|
+
raise EvidenceImportError("unsupported_format", "format", f"unsupported report format: {fmt}")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _resolve_manifest(root: Path, manifest: str, task_dir: str) -> Path:
|
|
183
|
+
if manifest:
|
|
184
|
+
path = Path(manifest).expanduser()
|
|
185
|
+
return path if path.is_absolute() else root / path
|
|
186
|
+
if task_dir:
|
|
187
|
+
path = Path(task_dir).expanduser()
|
|
188
|
+
base = path if path.is_absolute() else root / path
|
|
189
|
+
return base / MANIFEST_NAME
|
|
190
|
+
marker = root / ".code-flow" / ".active-task.json"
|
|
191
|
+
try:
|
|
192
|
+
loaded = json.loads(marker.read_text(encoding="utf-8"))
|
|
193
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
194
|
+
raise EvidenceImportError(
|
|
195
|
+
"manifest_unresolved", "manifest", "pass --manifest/--task-dir or start a task first", str(marker)
|
|
196
|
+
) from exc
|
|
197
|
+
task = loaded.get("task_dir") if isinstance(loaded, Mapping) else None
|
|
198
|
+
if not isinstance(task, str) or not task.strip():
|
|
199
|
+
raise EvidenceImportError("manifest_unresolved", "manifest", "active task has no task_dir", str(marker))
|
|
200
|
+
base = Path(task)
|
|
201
|
+
base = base if base.is_absolute() else root / base
|
|
202
|
+
return base / MANIFEST_NAME
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _load_plan(path: Path) -> tuple[dict[str, object], dict[str, object], dict[str, str], list[tuple[str, str]]]:
|
|
206
|
+
try:
|
|
207
|
+
data = load_manifest(path)
|
|
208
|
+
except (OSError, ValueError) as exc:
|
|
209
|
+
raise EvidenceImportError("manifest_invalid", "manifest", str(exc), str(path)) from exc
|
|
210
|
+
checks = {
|
|
211
|
+
str(item["id"]): item
|
|
212
|
+
for item in data.get("checks", [])
|
|
213
|
+
if isinstance(item, Mapping) and isinstance(item.get("id"), str)
|
|
214
|
+
}
|
|
215
|
+
scenarios = {
|
|
216
|
+
str(item["id"]): item
|
|
217
|
+
for item in data.get("scenarios", [])
|
|
218
|
+
if isinstance(item, Mapping) and isinstance(item.get("id"), str)
|
|
219
|
+
}
|
|
220
|
+
linked: dict[str, str] = {}
|
|
221
|
+
for scenario_id, item in scenarios.items():
|
|
222
|
+
ids = scenario_check_ids(item)
|
|
223
|
+
if len(ids) == 1 and ids[0] in checks:
|
|
224
|
+
linked[scenario_id] = ids[0]
|
|
225
|
+
if not checks or not any(scenario_check_ids(row) for row in scenarios.values()):
|
|
226
|
+
raise EvidenceImportError(
|
|
227
|
+
"manifest_schema_unsupported", "manifest", "CI import needs manifest v3 checks linked to scenarios", str(path)
|
|
228
|
+
)
|
|
229
|
+
assertions: list[tuple[str, str]] = []
|
|
230
|
+
for scenario_id, item in scenarios.items():
|
|
231
|
+
raw = item.get("assertions")
|
|
232
|
+
for value in raw if isinstance(raw, list) else []:
|
|
233
|
+
if isinstance(value, str) and value.strip():
|
|
234
|
+
assertions.append((scenario_id, value.strip()))
|
|
235
|
+
return data, checks, linked, assertions
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _has_token(text: str, token: str) -> bool:
|
|
239
|
+
return re.search(rf"(?<![0-9A-Za-z_-]){re.escape(token)}(?![0-9A-Za-z_-])", text) is not None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _normalize_path(value: str) -> str:
|
|
243
|
+
text = value.replace("\\", "/").strip()
|
|
244
|
+
while text.startswith("./"):
|
|
245
|
+
text = text[2:]
|
|
246
|
+
return text.strip("/")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _assertion_matches(assertion: str, case: ReportCase) -> bool:
|
|
250
|
+
if not case.file:
|
|
251
|
+
return False
|
|
252
|
+
target, line = assertion, None
|
|
253
|
+
if "::" in target:
|
|
254
|
+
target = target.split("::", 1)[0]
|
|
255
|
+
head, separator, tail = target.rpartition(":")
|
|
256
|
+
if separator and tail.isdigit():
|
|
257
|
+
target, line = head, int(tail)
|
|
258
|
+
expected, actual = _normalize_path(target), _normalize_path(case.file)
|
|
259
|
+
if not expected or not actual:
|
|
260
|
+
return False
|
|
261
|
+
if not (expected == actual or actual.endswith("/" + expected) or expected.endswith("/" + actual)):
|
|
262
|
+
return False
|
|
263
|
+
return line is None or case.line is None or line == case.line
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _match_case(
|
|
267
|
+
case: ReportCase, override: str, checks: Mapping[str, object], linked: Mapping[str, str],
|
|
268
|
+
assertions: Sequence[tuple[str, str]],
|
|
269
|
+
) -> str:
|
|
270
|
+
if override:
|
|
271
|
+
return override
|
|
272
|
+
text = " ".join(part for part in (case.classname, case.name, case.file) if part)
|
|
273
|
+
candidates = [candidate for candidate in checks if _has_token(text, candidate)]
|
|
274
|
+
if len(candidates) > 1:
|
|
275
|
+
raise EvidenceImportError("ambiguous_test_case", "file",
|
|
276
|
+
f"test case must identify one check: {_case_label(case)}")
|
|
277
|
+
if candidates:
|
|
278
|
+
return candidates[0]
|
|
279
|
+
scenario_id = next((candidate for candidate in linked if _has_token(text, candidate)), "")
|
|
280
|
+
if scenario_id:
|
|
281
|
+
return linked[scenario_id]
|
|
282
|
+
for candidate, assertion in assertions:
|
|
283
|
+
if candidate in linked and _assertion_matches(assertion, case):
|
|
284
|
+
return linked[candidate]
|
|
285
|
+
return ""
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _case_label(case: ReportCase) -> str:
|
|
289
|
+
name = case.name or "<unnamed>"
|
|
290
|
+
return f"{case.classname}::{name}" if case.classname else name
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _map_cases(
|
|
294
|
+
cases: Sequence[ReportCase], override: str, checks: Mapping[str, object],
|
|
295
|
+
linked: Mapping[str, str], assertions: Sequence[tuple[str, str]],
|
|
296
|
+
) -> dict[str, list[str]]:
|
|
297
|
+
mapped: dict[str, list[str]] = {}
|
|
298
|
+
unmapped: list[str] = []
|
|
299
|
+
for case in cases:
|
|
300
|
+
check_id = _match_case(case, override, checks, linked, assertions)
|
|
301
|
+
if not check_id:
|
|
302
|
+
unmapped.append(_case_label(case))
|
|
303
|
+
continue
|
|
304
|
+
mapped.setdefault(check_id, []).append(case.status)
|
|
305
|
+
if unmapped:
|
|
306
|
+
raise EvidenceImportError(
|
|
307
|
+
"unmapped_test_cases", "file", "unmapped test cases: " + ", ".join(unmapped[:10])
|
|
308
|
+
)
|
|
309
|
+
if not mapped:
|
|
310
|
+
raise EvidenceImportError("no_matching_tests", "file", "report does not cover any manifest check")
|
|
311
|
+
return mapped
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _check_outcomes(mapped: Mapping[str, Sequence[str]]) -> dict[str, tuple[str, int]]:
|
|
315
|
+
outcomes: dict[str, tuple[str, int]] = {}
|
|
316
|
+
for check_id, statuses in mapped.items():
|
|
317
|
+
if _FAILED in statuses:
|
|
318
|
+
outcomes[check_id] = ("failed", 1)
|
|
319
|
+
elif not statuses or any(status != _PASSED for status in statuses):
|
|
320
|
+
outcomes[check_id] = ("incomplete", 1)
|
|
321
|
+
else:
|
|
322
|
+
outcomes[check_id] = ("passed", 0)
|
|
323
|
+
return outcomes
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _git(root: Path, *arguments: str) -> str:
|
|
327
|
+
try:
|
|
328
|
+
completed = subprocess.run(
|
|
329
|
+
("git", *arguments), cwd=str(root), text=True, encoding="utf-8", capture_output=True, check=False
|
|
330
|
+
)
|
|
331
|
+
except (OSError, UnicodeError):
|
|
332
|
+
return ""
|
|
333
|
+
return completed.stdout.strip() if completed.returncode == 0 else ""
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _resolve_commit(root: Path, value: str) -> str:
|
|
337
|
+
text = _text(value)
|
|
338
|
+
if not text:
|
|
339
|
+
raise EvidenceImportError("missing_commit", "commit", "CI evidence requires --commit")
|
|
340
|
+
resolved = _git(root, "rev-parse", "--verify", "--quiet", f"{text}^{{commit}}")
|
|
341
|
+
if not resolved:
|
|
342
|
+
raise EvidenceImportError("commit_invalid", "commit", f"unknown commit: {text}")
|
|
343
|
+
head = _git(root, "rev-parse", "HEAD")
|
|
344
|
+
if not head:
|
|
345
|
+
raise EvidenceImportError("git_unavailable", "commit", "cannot read HEAD; CI import needs a git worktree")
|
|
346
|
+
if resolved != head:
|
|
347
|
+
raise EvidenceImportError("commit_mismatch", "commit", f"CI commit {resolved} != HEAD {head}")
|
|
348
|
+
return head
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _changed_paths(root: Path) -> list[str]:
|
|
352
|
+
try:
|
|
353
|
+
completed = subprocess.run(
|
|
354
|
+
("git", "status", "--porcelain", "--untracked-files=all"),
|
|
355
|
+
cwd=str(root), text=True, encoding="utf-8", capture_output=True, check=False,
|
|
356
|
+
)
|
|
357
|
+
except (OSError, UnicodeError) as exc:
|
|
358
|
+
raise EvidenceImportError("git_unavailable", "worktree", str(exc)) from exc
|
|
359
|
+
if completed.returncode != 0:
|
|
360
|
+
raise EvidenceImportError("git_unavailable", "worktree", completed.stderr.strip())
|
|
361
|
+
paths: list[str] = []
|
|
362
|
+
for line in completed.stdout.splitlines():
|
|
363
|
+
path = line[3:].strip()
|
|
364
|
+
if " -> " in path:
|
|
365
|
+
path = path.split(" -> ", 1)[1].strip()
|
|
366
|
+
if not path or path.startswith(".code-flow/"):
|
|
367
|
+
continue
|
|
368
|
+
paths.append(path)
|
|
369
|
+
return paths
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _require_clean(root: Path) -> None:
|
|
373
|
+
paths = _changed_paths(root)
|
|
374
|
+
if paths:
|
|
375
|
+
raise EvidenceImportError(
|
|
376
|
+
"worktree_dirty", "worktree", "CI evidence requires a clean worktree: " + ", ".join(paths[:5])
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _require_run_url(value: str) -> str:
|
|
381
|
+
text = _text(value)
|
|
382
|
+
if not text:
|
|
383
|
+
raise EvidenceImportError("missing_run_url", "run_url", "CI evidence requires --run-url")
|
|
384
|
+
parsed = urlparse(text)
|
|
385
|
+
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
|
386
|
+
raise EvidenceImportError("invalid_run_url", "run_url", f"not an http(s) run URL: {text}")
|
|
387
|
+
return text
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _report_digest(path: Path) -> str:
|
|
391
|
+
try:
|
|
392
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
393
|
+
except (OSError, UnicodeError) as exc:
|
|
394
|
+
raise EvidenceImportError("invalid_report", "file", str(exc), str(path)) from exc
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _scenario_results(
|
|
398
|
+
data: Mapping[str, object], outcomes: Mapping[str, tuple[str, int]], *,
|
|
399
|
+
commit: str, run_url: str, fmt: str, report_sha256: str,
|
|
400
|
+
) -> list[dict[str, object]]:
|
|
401
|
+
results: list[dict[str, object]] = []
|
|
402
|
+
for row in data.get("scenarios", []):
|
|
403
|
+
if not isinstance(row, Mapping) or str(row.get("kind", "functional")) == "manual":
|
|
404
|
+
continue
|
|
405
|
+
check_ids = scenario_check_ids(row)
|
|
406
|
+
if not check_ids or any(check_id not in outcomes for check_id in check_ids):
|
|
407
|
+
continue
|
|
408
|
+
statuses = [outcomes[check_id][0] for check_id in check_ids]
|
|
409
|
+
if any(status == "failed" for status in statuses):
|
|
410
|
+
status = "failed"
|
|
411
|
+
elif any(status != "passed" for status in statuses):
|
|
412
|
+
status = "incomplete"
|
|
413
|
+
else:
|
|
414
|
+
status = "passed"
|
|
415
|
+
exit_code = max(outcomes[check_id][1] for check_id in check_ids)
|
|
416
|
+
results.append({
|
|
417
|
+
"id": str(row["id"]),
|
|
418
|
+
"kind": str(row.get("kind", "functional")),
|
|
419
|
+
"status": status,
|
|
420
|
+
"exit_code": exit_code,
|
|
421
|
+
"check_ids": check_ids,
|
|
422
|
+
"check_results": {check_id: {"status": outcomes[check_id][0],
|
|
423
|
+
"exit_code": outcomes[check_id][1]}
|
|
424
|
+
for check_id in check_ids},
|
|
425
|
+
"source": "ci",
|
|
426
|
+
"commit": commit,
|
|
427
|
+
"run_url": run_url,
|
|
428
|
+
"ci_format": fmt,
|
|
429
|
+
"ci_report_sha256": report_sha256,
|
|
430
|
+
})
|
|
431
|
+
return results
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _already_imported(data: Mapping[str, object], results: Sequence[Mapping[str, object]], run_id: str) -> bool:
|
|
435
|
+
rows = {
|
|
436
|
+
str(row["id"]): row
|
|
437
|
+
for row in data.get("scenarios", [])
|
|
438
|
+
if isinstance(row, Mapping) and isinstance(row.get("id"), str)
|
|
439
|
+
}
|
|
440
|
+
for result in results:
|
|
441
|
+
evidence = rows.get(str(result["id"]), {}).get("evidence")
|
|
442
|
+
if not isinstance(evidence, Mapping):
|
|
443
|
+
return False
|
|
444
|
+
if evidence.get("run_id") != run_id or evidence.get("source") != "ci":
|
|
445
|
+
return False
|
|
446
|
+
return True
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _checks_payload(mapped: Mapping[str, Sequence[str]], outcomes: Mapping[str, tuple[str, int]]) -> list[dict[str, object]]:
|
|
450
|
+
return [
|
|
451
|
+
{"check_id": check_id, "status": outcomes[check_id][0],
|
|
452
|
+
"exit_code": outcomes[check_id][1], "cases": len(mapped[check_id])}
|
|
453
|
+
for check_id in sorted(outcomes)
|
|
454
|
+
]
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _payload(
|
|
458
|
+
status: str, decision: str, results: Sequence[Mapping[str, object]], mapped: Mapping[str, Sequence[str]],
|
|
459
|
+
outcomes: Mapping[str, tuple[str, int]], *, fmt: str, head: str, run_url: str,
|
|
460
|
+
fingerprint: str, run_id: str, report_sha256: str,
|
|
461
|
+
) -> dict[str, object]:
|
|
462
|
+
return {
|
|
463
|
+
"status": status,
|
|
464
|
+
"decision": decision,
|
|
465
|
+
"source": "ci",
|
|
466
|
+
"format": fmt,
|
|
467
|
+
"commit": head,
|
|
468
|
+
"run_url": run_url,
|
|
469
|
+
"input_hash": fingerprint,
|
|
470
|
+
"run_id": run_id,
|
|
471
|
+
"imported": len(results),
|
|
472
|
+
"checks": _checks_payload(mapped, outcomes),
|
|
473
|
+
"scenarios": sorted(str(result["id"]) for result in results),
|
|
474
|
+
"report_sha256": report_sha256,
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _attach_check_hashes(
|
|
479
|
+
data: Mapping[str, object], results: Sequence[dict[str, object]],
|
|
480
|
+
per_check: Mapping[str, Mapping[str, object]], fallback: str,
|
|
481
|
+
) -> None:
|
|
482
|
+
rows = {
|
|
483
|
+
str(item.get("id")): item for item in data.get("scenarios", [])
|
|
484
|
+
if isinstance(item, Mapping) and item.get("id")
|
|
485
|
+
}
|
|
486
|
+
for result in results:
|
|
487
|
+
row = rows.get(str(result.get("id")))
|
|
488
|
+
if not row:
|
|
489
|
+
continue
|
|
490
|
+
raw = result.get("check_ids")
|
|
491
|
+
check_ids = [str(value) for value in raw] if isinstance(raw, list) else []
|
|
492
|
+
if not check_ids:
|
|
493
|
+
raw = row.get("checks")
|
|
494
|
+
check_ids = [str(value) for value in raw] if isinstance(raw, list) else []
|
|
495
|
+
if not check_ids and isinstance(row.get("check"), str):
|
|
496
|
+
check_ids = [str(row.get("check"))]
|
|
497
|
+
if not check_ids:
|
|
498
|
+
result["input_hash"] = fallback
|
|
499
|
+
continue
|
|
500
|
+
hashes = {
|
|
501
|
+
check_id: str(per_check.get(check_id, {}).get("input_hash") or fallback)
|
|
502
|
+
for check_id in check_ids
|
|
503
|
+
}
|
|
504
|
+
result["check_ids"] = check_ids
|
|
505
|
+
result["check_hashes"] = hashes
|
|
506
|
+
if len(hashes) == 1:
|
|
507
|
+
result["input_hash"] = next(iter(hashes.values()))
|
|
508
|
+
else:
|
|
509
|
+
payload = "|".join(f"{key}={hashes[key]}" for key in sorted(hashes))
|
|
510
|
+
result["input_hash"] = hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def import_report(
|
|
514
|
+
root: object, *, report: object, fmt: str, commit: str, run_url: str,
|
|
515
|
+
manifest: str = "", task_dir: str = "", check: str = "", exit_code: Optional[int] = None,
|
|
516
|
+
) -> dict[str, object]:
|
|
517
|
+
"""Import one CI report; returns the imported evidence summary."""
|
|
518
|
+
project = discover_project_root(root)
|
|
519
|
+
report_path = Path(report)
|
|
520
|
+
plan_path = _resolve_manifest(project, manifest, task_dir)
|
|
521
|
+
if not plan_path.is_file():
|
|
522
|
+
raise EvidenceImportError("manifest_missing", "manifest", "acceptance manifest not found", str(plan_path))
|
|
523
|
+
data, checks, linked, assertions = _load_plan(plan_path)
|
|
524
|
+
url = _require_run_url(run_url)
|
|
525
|
+
if check and check not in checks:
|
|
526
|
+
raise EvidenceImportError("unknown_check", "check", f"check not in manifest: {check}")
|
|
527
|
+
if check and not any(check in scenario_check_ids(row) for row in data["scenarios"]):
|
|
528
|
+
raise EvidenceImportError("check_without_scenarios", "check", f"check has no scenario: {check}")
|
|
529
|
+
cases = load_report(report_path, fmt)
|
|
530
|
+
mapped = _map_cases(cases, check, checks, linked, assertions)
|
|
531
|
+
head = _resolve_commit(project, commit)
|
|
532
|
+
outcomes = _check_outcomes(mapped)
|
|
533
|
+
derived_exit = 0 if all(status == "passed" for status, _ in outcomes.values()) else 1
|
|
534
|
+
if exit_code is not None and exit_code != derived_exit:
|
|
535
|
+
raise EvidenceImportError(
|
|
536
|
+
"exit_code_mismatch", "exit_code", f"CI exit code {exit_code} != report exit code {derived_exit}"
|
|
537
|
+
)
|
|
538
|
+
_require_clean(project)
|
|
539
|
+
fingerprint = worktree_fingerprint(str(project))
|
|
540
|
+
if not fingerprint:
|
|
541
|
+
raise EvidenceImportError("input_hash_unavailable", "input_hash", "cannot fingerprint the worktree")
|
|
542
|
+
check_hashes = check_input_hashes(str(project), checks, fingerprint)
|
|
543
|
+
digest = _report_digest(report_path)
|
|
544
|
+
run_id = hashlib.sha256(
|
|
545
|
+
json.dumps([head, fmt, digest, url], ensure_ascii=False, sort_keys=True).encode("utf-8")
|
|
546
|
+
).hexdigest()[:32]
|
|
547
|
+
results = _scenario_results(
|
|
548
|
+
data, outcomes, commit=head, run_url=url, fmt=fmt, report_sha256=digest
|
|
549
|
+
)
|
|
550
|
+
for result in results:
|
|
551
|
+
for check_id, outcome in result.get("check_results", {}).items():
|
|
552
|
+
check = checks[check_id]
|
|
553
|
+
outcome["environment_hash"] = execution_environment_hash(check_environment(check, str(project)))
|
|
554
|
+
if len(result.get("check_results", {})) == 1:
|
|
555
|
+
result["environment_hash"] = next(iter(result["check_results"].values()))["environment_hash"]
|
|
556
|
+
_attach_check_hashes(data, results, check_hashes, fingerprint)
|
|
557
|
+
if not results:
|
|
558
|
+
raise EvidenceImportError("check_without_scenarios", "check", "report checks have no scenario")
|
|
559
|
+
decision = "pass" if derived_exit == 0 else "block"
|
|
560
|
+
if _already_imported(data, results, run_id):
|
|
561
|
+
return _payload("unchanged", decision, results, mapped, outcomes, fmt=fmt, head=head,
|
|
562
|
+
run_url=url, fingerprint=fingerprint, run_id=run_id, report_sha256=digest)
|
|
563
|
+
persist_results(plan_path, data, results, fingerprint=fingerprint, run_id=run_id)
|
|
564
|
+
return _payload("imported", decision, results, mapped, outcomes, fmt=fmt, head=head,
|
|
565
|
+
run_url=url, fingerprint=fingerprint, run_id=run_id, report_sha256=digest)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _rejection(code: str, field: str, message: str, path: str = "") -> dict[str, object]:
|
|
569
|
+
return {"status": "rejected", "decision": "block", "error_code": code,
|
|
570
|
+
"field": field, "error": message, "message": message, "path": path}
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _parser() -> argparse.ArgumentParser:
|
|
574
|
+
parser = argparse.ArgumentParser(prog=os.environ.get("CF_RUNTIME_COMMAND") or "code-flow evidence")
|
|
575
|
+
actions = parser.add_subparsers(dest="action", required=True)
|
|
576
|
+
importer = actions.add_parser("import", help="Import a CI JUnit/Playwright report as evidence")
|
|
577
|
+
importer.add_argument("--format", required=True, choices=FORMATS)
|
|
578
|
+
importer.add_argument("--file", required=True)
|
|
579
|
+
importer.add_argument("--commit", required=True)
|
|
580
|
+
importer.add_argument("--run-url", required=True)
|
|
581
|
+
importer.add_argument("--root", default=".")
|
|
582
|
+
importer.add_argument("--manifest", default="")
|
|
583
|
+
importer.add_argument("--task-dir", default="")
|
|
584
|
+
importer.add_argument("--check", default="", help="Report belongs to one manifest check")
|
|
585
|
+
importer.add_argument("--exit-code", type=int, default=None, help="Cross-check the CI job exit code")
|
|
586
|
+
importer.add_argument("--json", action="store_true")
|
|
587
|
+
return parser
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
|
|
591
|
+
args = _parser().parse_args(argv)
|
|
592
|
+
try:
|
|
593
|
+
result = import_report(
|
|
594
|
+
args.root, report=args.file, fmt=args.format, commit=args.commit, run_url=args.run_url,
|
|
595
|
+
manifest=args.manifest, task_dir=args.task_dir, check=args.check, exit_code=args.exit_code,
|
|
596
|
+
)
|
|
597
|
+
except EvidenceImportError as exc:
|
|
598
|
+
stdout.write(json.dumps(_rejection(exc.code, exc.field, exc.message, exc.path), ensure_ascii=False))
|
|
599
|
+
return 2
|
|
600
|
+
except EvidenceStoreError as exc:
|
|
601
|
+
stdout.write(json.dumps(_rejection(exc.code, exc.field, exc.message, exc.path), ensure_ascii=False))
|
|
602
|
+
return 2
|
|
603
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
604
|
+
stdout.write(json.dumps(_rejection("import_failed", "file", str(exc)), ensure_ascii=False))
|
|
605
|
+
return 2
|
|
606
|
+
stdout.write(json.dumps(result, ensure_ascii=False))
|
|
607
|
+
return 0
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""cf_feedback: mark a check as false positive (FEAT-02 对话即反馈).
|
|
3
|
+
|
|
4
|
+
The violation feedback text tells the user "误报可直接告诉我"; the agent then
|
|
5
|
+
runs this CLI on the user's behalf — no new slash command, no config editing.
|
|
6
|
+
|
|
7
|
+
Usage: python3 cf_feedback.py ignore <check-id>
|
|
8
|
+
Exit codes: 0 = recorded / 1 = usage error / 2 = unknown check id.
|
|
9
|
+
stdout: one JSON line {"check_id", "fp_count", "hit_count", "disabled"}.
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
import argparse
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from codeflow.acceptance.cf_checks import load_check_state, load_spec_checks, record_false_positive
|
|
17
|
+
from codeflow.core.cf_core import build_effective_mapping, load_config, normalize_spec_entry
|
|
18
|
+
from codeflow.core.cf_log import append_event
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def known_check_ids(project_root: str) -> set[str]:
|
|
22
|
+
"""All check ids: declared in any configured spec, or already in state."""
|
|
23
|
+
ids = set(load_check_state(project_root).keys())
|
|
24
|
+
config = load_config(project_root)
|
|
25
|
+
mapping = build_effective_mapping(
|
|
26
|
+
project_root, (config or {}).get("path_mapping") or {}
|
|
27
|
+
)
|
|
28
|
+
specs_root = os.path.join(project_root, ".code-flow", "specs")
|
|
29
|
+
for domain_cfg in mapping.values():
|
|
30
|
+
for entry in (domain_cfg or {}).get("specs") or []:
|
|
31
|
+
rel = normalize_spec_entry(entry).get("path")
|
|
32
|
+
if not rel:
|
|
33
|
+
continue
|
|
34
|
+
checks, _ = load_spec_checks(os.path.join(specs_root, rel))
|
|
35
|
+
ids.update(check["id"] for check in checks)
|
|
36
|
+
return ids
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main(argv: list[str]) -> int:
|
|
40
|
+
parser = argparse.ArgumentParser(prog=os.environ.get("CF_RUNTIME_COMMAND", "cf_feedback.py"))
|
|
41
|
+
parser.add_argument("action", choices=("ignore",), metavar=os.environ.get("CF_RUNTIME_ACTION") or None)
|
|
42
|
+
parser.add_argument("check_id")
|
|
43
|
+
check_id = parser.parse_args(argv).check_id
|
|
44
|
+
project_root = os.getcwd()
|
|
45
|
+
if check_id not in known_check_ids(project_root):
|
|
46
|
+
sys.stderr.write(f"unknown check id: {check_id}\n")
|
|
47
|
+
return 2
|
|
48
|
+
append_event(
|
|
49
|
+
project_root, "false_positive", {"check_id": check_id}, "cf-feedback"
|
|
50
|
+
)
|
|
51
|
+
result = record_false_positive(project_root, check_id)
|
|
52
|
+
sys.stdout.write(json.dumps(result, ensure_ascii=False) + "\n")
|
|
53
|
+
return 0
|