@michaelxwb/code-flow 0.8.1 → 1.0.0
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 +73 -152
- package/bin/code-flow.js +12 -0
- package/docs/README.md +19 -0
- package/docs/USAGE.md +156 -1417
- package/docs/v1-design.md +1858 -0
- package/docs/v1-implementation.md +186 -0
- package/lib/adapters.js +126 -0
- package/lib/cli.js +258 -0
- package/lib/config.js +85 -0
- package/lib/doctor.js +283 -0
- package/lib/frontmatter.js +42 -0
- package/lib/fs.js +141 -0
- package/lib/glob.js +23 -0
- package/lib/hook.js +99 -0
- package/lib/init.js +132 -0
- package/lib/learn.js +170 -0
- package/lib/locks.js +52 -0
- package/lib/markdown.js +13 -0
- package/lib/process.js +116 -0
- package/lib/render.js +115 -0
- package/lib/repo.js +48 -0
- package/lib/runs.js +155 -0
- package/lib/session.js +40 -0
- package/lib/spec.js +80 -0
- package/lib/task.js +258 -0
- package/lib/taskdoc.js +308 -0
- package/lib/toml.js +140 -0
- package/lib/verify.js +168 -0
- package/lib/worktree.js +930 -0
- package/package.json +18 -21
- package/templates/adapters/opencode.mjs +65 -0
- package/templates/config.json +39 -0
- package/templates/instructions.md +13 -0
- package/templates/launcher/bridge.cjs +295 -0
- package/templates/launcher/hook.cmd +15 -0
- package/templates/launcher/hook.ps1 +10 -0
- package/templates/launcher/hook.sh +20 -0
- package/templates/skills/cf-check.md +19 -0
- package/templates/skills/cf-init.md +16 -0
- package/templates/skills/cf-spec.md +20 -0
- package/templates/skills/cf-task-align.md +19 -0
- package/templates/skills/cf-task-archive.md +20 -0
- package/templates/skills/cf-task-plan.md +31 -0
- package/templates/skills/cf-task-prd.md +14 -0
- package/templates/skills/cf-task-start.md +41 -0
- package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-frontend.md +12 -12
- package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-full.md +17 -19
- package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-lite.md +12 -12
- package/{src/core/code-flow/specs/shared/prd-template.md → templates/workflow/prd.md} +1 -1
- package/templates/workflow/project-guidelines.md +48 -0
- package/templates/workflow/scenarios.md +67 -0
- package/templates/workflow/task.md +56 -0
- package/docs/acceptance-contract.md +0 -217
- package/docs/ci/acceptance-evidence.md +0 -77
- package/docs/migrations/runtime-0.7.0.md +0 -41
- package/docs/migrations/runtime-0.8.0.md +0 -154
- package/docs/migrations/runtime-0.9.0.md +0 -40
- package/docs/migrations/spec-workflow-0.6.0.md +0 -42
- package/docs/spec-injection-hardening.md +0 -54
- package/docs/spec-injection-performance.json +0 -73
- package/docs/spec-optimization.md +0 -12
- package/src/adapters/claude/CLAUDE.md +0 -45
- package/src/adapters/claude/commands/cf-init.md +0 -262
- package/src/adapters/claude/commands/cf-learn.md +0 -291
- package/src/adapters/claude/commands/cf-spec.md +0 -87
- package/src/adapters/claude/commands/cf-stats.md +0 -96
- package/src/adapters/claude/commands/cf-sync.md +0 -39
- package/src/adapters/claude/commands/cf-task/align.md +0 -331
- package/src/adapters/claude/commands/cf-task/archive.md +0 -155
- package/src/adapters/claude/commands/cf-task/block.md +0 -49
- package/src/adapters/claude/commands/cf-task/graph.md +0 -92
- package/src/adapters/claude/commands/cf-task/note.md +0 -248
- package/src/adapters/claude/commands/cf-task/plan.md +0 -477
- package/src/adapters/claude/commands/cf-task/prd.md +0 -173
- package/src/adapters/claude/commands/cf-task/start.md +0 -317
- package/src/adapters/claude/commands/cf-task/status.md +0 -71
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +0 -148
- package/src/adapters/claude/commands/cf-validate.md +0 -100
- package/src/adapters/claude/settings.local.json +0 -50
- package/src/adapters/codex/AGENTS.md +0 -45
- package/src/adapters/codex/config.toml +0 -3
- package/src/adapters/codex/hooks.json +0 -54
- package/src/adapters/codex/skills/cf-init/SKILL.md +0 -273
- package/src/adapters/codex/skills/cf-learn/SKILL.md +0 -296
- package/src/adapters/codex/skills/cf-spec/SKILL.md +0 -88
- package/src/adapters/codex/skills/cf-stats/SKILL.md +0 -97
- package/src/adapters/codex/skills/cf-sync/SKILL.md +0 -45
- package/src/adapters/codex/skills/cf-task-align/SKILL.md +0 -332
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +0 -156
- package/src/adapters/codex/skills/cf-task-block/SKILL.md +0 -50
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +0 -93
- package/src/adapters/codex/skills/cf-task-note/SKILL.md +0 -245
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +0 -478
- package/src/adapters/codex/skills/cf-task-prd/SKILL.md +0 -174
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +0 -318
- package/src/adapters/codex/skills/cf-task-status/SKILL.md +0 -72
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +0 -145
- package/src/adapters/codex/skills/cf-validate/SKILL.md +0 -102
- package/src/adapters/commands.json +0 -115
- package/src/adapters/costrict/CLAUDE.md +0 -45
- package/src/adapters/costrict/commands/cf-init.md +0 -262
- package/src/adapters/costrict/commands/cf-learn.md +0 -291
- package/src/adapters/costrict/commands/cf-spec.md +0 -87
- package/src/adapters/costrict/commands/cf-stats.md +0 -96
- package/src/adapters/costrict/commands/cf-sync.md +0 -39
- package/src/adapters/costrict/commands/cf-task/align.md +0 -331
- package/src/adapters/costrict/commands/cf-task/archive.md +0 -155
- package/src/adapters/costrict/commands/cf-task/block.md +0 -49
- package/src/adapters/costrict/commands/cf-task/graph.md +0 -92
- package/src/adapters/costrict/commands/cf-task/note.md +0 -248
- package/src/adapters/costrict/commands/cf-task/plan.md +0 -477
- package/src/adapters/costrict/commands/cf-task/prd.md +0 -173
- package/src/adapters/costrict/commands/cf-task/start.md +0 -317
- package/src/adapters/costrict/commands/cf-task/status.md +0 -71
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +0 -148
- package/src/adapters/costrict/commands/cf-validate.md +0 -100
- package/src/adapters/costrict/settings.local.json +0 -50
- package/src/adapters/install-plan.json +0 -100
- package/src/adapters/opencode/AGENTS.md +0 -45
- package/src/adapters/opencode/commands/cf-init.md +0 -281
- package/src/adapters/opencode/commands/cf-learn.md +0 -295
- package/src/adapters/opencode/commands/cf-spec.md +0 -91
- package/src/adapters/opencode/commands/cf-stats.md +0 -100
- package/src/adapters/opencode/commands/cf-sync.md +0 -44
- package/src/adapters/opencode/commands/cf-task/align.md +0 -331
- package/src/adapters/opencode/commands/cf-task/archive.md +0 -160
- package/src/adapters/opencode/commands/cf-task/block.md +0 -54
- package/src/adapters/opencode/commands/cf-task/graph.md +0 -97
- package/src/adapters/opencode/commands/cf-task/note.md +0 -253
- package/src/adapters/opencode/commands/cf-task/plan.md +0 -477
- package/src/adapters/opencode/commands/cf-task/prd.md +0 -178
- package/src/adapters/opencode/commands/cf-task/start.md +0 -317
- package/src/adapters/opencode/commands/cf-task/status.md +0 -76
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +0 -148
- package/src/adapters/opencode/commands/cf-validate.md +0 -105
- package/src/adapters/opencode/opencode.json +0 -3
- package/src/adapters/opencode/plugins/code-flow/index.js +0 -162
- package/src/adapters/opencode/plugins/code-flow/package.json +0 -6
- package/src/adapters/platform-bindings.json +0 -370
- package/src/cli.js +0 -1076
- package/src/core/code-flow/config.yml +0 -161
- package/src/core/code-flow/runtime-commands.json +0 -259
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +0 -10
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +0 -10
- package/src/core/code-flow/scripts/cf_audit.py +0 -10
- package/src/core/code-flow/scripts/cf_codex_hook.py +0 -10
- package/src/core/code-flow/scripts/cf_design_lint.py +0 -10
- package/src/core/code-flow/scripts/cf_evidence_import.py +0 -10
- package/src/core/code-flow/scripts/cf_feedback.py +0 -10
- package/src/core/code-flow/scripts/cf_opencode_event.py +0 -10
- package/src/core/code-flow/scripts/cf_post_hook.py +0 -11
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +0 -11
- package/src/core/code-flow/scripts/cf_scenarios.py +0 -10
- package/src/core/code-flow/scripts/cf_spec_context.py +0 -10
- package/src/core/code-flow/scripts/cf_spec_gate.py +0 -10
- package/src/core/code-flow/scripts/cf_spec_session.py +0 -10
- package/src/core/code-flow/scripts/cf_stats.py +0 -10
- package/src/core/code-flow/scripts/cf_stop_hook.py +0 -11
- package/src/core/code-flow/scripts/cf_sync.py +0 -10
- package/src/core/code-flow/scripts/cf_task_index.py +0 -10
- package/src/core/code-flow/scripts/cf_task_parallel.py +0 -10
- package/src/core/code-flow/scripts/cf_task_workflow.py +0 -10
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +0 -11
- package/src/core/code-flow/scripts/cf_validation.py +0 -10
- 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 +0 -347
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_manifest_core.py +0 -660
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_plan.py +0 -202
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_registration.py +0 -60
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_risk.py +0 -73
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_schema.py +0 -282
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_selection.py +0 -266
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_inputs.py +0 -172
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_registration.py +0 -326
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_checks.py +0 -333
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_plan_quality.py +0 -197
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_quality.py +0 -174
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_source.py +0 -300
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_traceability.py +0 -502
- package/src/core/code-flow/scripts/codeflow/cli/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_manifest.py +0 -347
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_runner.py +0 -1490
- package/src/core/code-flow/scripts/codeflow/cli/cf_audit.py +0 -533
- package/src/core/code-flow/scripts/codeflow/cli/cf_design_lint.py +0 -172
- package/src/core/code-flow/scripts/codeflow/cli/cf_evidence_import.py +0 -607
- package/src/core/code-flow/scripts/codeflow/cli/cf_feedback.py +0 -53
- package/src/core/code-flow/scripts/codeflow/cli/cf_scenarios.py +0 -58
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_context.py +0 -1175
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_gate.py +0 -60
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_session.py +0 -46
- package/src/core/code-flow/scripts/codeflow/cli/cf_stats.py +0 -540
- package/src/core/code-flow/scripts/codeflow/cli/cf_sync.py +0 -144
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_index.py +0 -177
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_parallel.py +0 -1142
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_workflow.py +0 -742
- package/src/core/code-flow/scripts/codeflow/cli/cf_validation.py +0 -31
- package/src/core/code-flow/scripts/codeflow/core/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_context_cache.py +0 -67
- package/src/core/code-flow/scripts/codeflow/core/cf_core.py +0 -609
- package/src/core/code-flow/scripts/codeflow/core/cf_exec_base.py +0 -325
- package/src/core/code-flow/scripts/codeflow/core/cf_fs.py +0 -57
- package/src/core/code-flow/scripts/codeflow/core/cf_git.py +0 -92
- package/src/core/code-flow/scripts/codeflow/core/cf_log.py +0 -160
- package/src/core/code-flow/scripts/codeflow/core/cf_markdown.py +0 -77
- package/src/core/code-flow/scripts/codeflow/core/cf_native_paths.py +0 -26
- package/src/core/code-flow/scripts/codeflow/core/cf_patch.py +0 -33
- package/src/core/code-flow/scripts/codeflow/core/cf_runtime_install.py +0 -32
- package/src/core/code-flow/scripts/codeflow/core/cf_session_state.py +0 -37
- package/src/core/code-flow/scripts/codeflow/core/cf_stop_guard.py +0 -53
- package/src/core/code-flow/scripts/codeflow/core/cf_task_state.py +0 -9
- package/src/core/code-flow/scripts/codeflow/core/cf_validators.py +0 -408
- package/src/core/code-flow/scripts/codeflow/core/cf_worktree_env.py +0 -253
- package/src/core/code-flow/scripts/codeflow/evidence/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/evidence/cf_evidence_store.py +0 -527
- package/src/core/code-flow/scripts/codeflow/hooks/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_codex_hook.py +0 -95
- package/src/core/code-flow/scripts/codeflow/hooks/cf_opencode_event.py +0 -98
- package/src/core/code-flow/scripts/codeflow/hooks/cf_post_hook.py +0 -30
- package/src/core/code-flow/scripts/codeflow/hooks/cf_pre_tool_hook.py +0 -41
- package/src/core/code-flow/scripts/codeflow/hooks/cf_stop_hook.py +0 -292
- package/src/core/code-flow/scripts/codeflow/hooks/cf_user_prompt_hook.py +0 -161
- package/src/core/code-flow/scripts/codeflow/spec/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_active_task.py +0 -412
- package/src/core/code-flow/scripts/codeflow/spec/cf_context_refresh.py +0 -301
- package/src/core/code-flow/scripts/codeflow/spec/cf_edit_service.py +0 -149
- package/src/core/code-flow/scripts/codeflow/spec/cf_injection.py +0 -111
- package/src/core/code-flow/scripts/codeflow/spec/cf_rule_readiness.py +0 -114
- package/src/core/code-flow/scripts/codeflow/spec/cf_scan.py +0 -367
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_budget.py +0 -55
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_context_model.py +0 -477
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_impact.py +0 -119
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_metadata.py +0 -387
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_migrate.py +0 -529
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize.py +0 -193
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize_apply.py +0 -187
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_resolver.py +0 -369
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_router.py +0 -304
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_verify.py +0 -687
- package/src/core/code-flow/scripts/codeflow/spec/cf_stage_gate.py +0 -330
- package/src/core/code-flow/scripts/codeflow/spec/cf_task_projection.py +0 -83
- package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_service.py +0 -755
- package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_transaction.py +0 -110
- package/src/core/code-flow/scripts/codeflow/task/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_requirement_verification.py +0 -867
- package/src/core/code-flow/scripts/codeflow/task/cf_task_doc.py +0 -178
- package/src/core/code-flow/scripts/codeflow/task/cf_task_merge.py +0 -635
- package/src/core/code-flow/scripts/codeflow/task/cf_task_plan.py +0 -251
- package/src/core/code-flow/scripts/codeflow/task/cf_task_runtime.py +0 -570
- package/src/core/code-flow/scripts/codeflow/task/cf_validation_service.py +0 -73
- package/src/core/code-flow/scripts/runtime-resources.json +0 -29
- package/src/core/code-flow/specs/backend/_map.md +0 -56
- package/src/core/code-flow/specs/backend/code-quality-performance.md +0 -59
- package/src/core/code-flow/specs/backend/database.md +0 -51
- package/src/core/code-flow/specs/backend/directory-structure.md +0 -54
- package/src/core/code-flow/specs/backend/logging.md +0 -50
- package/src/core/code-flow/specs/backend/platform-rules.md +0 -55
- package/src/core/code-flow/specs/frontend/_map.md +0 -52
- package/src/core/code-flow/specs/frontend/component-specs.md +0 -68
- package/src/core/code-flow/specs/frontend/directory-structure.md +0 -53
- package/src/core/code-flow/specs/frontend/quality-standards.md +0 -58
- package/src/core/code-flow/specs/shared/_map.md +0 -57
- package/src/core/code-flow/specs/shared/scenarios-template.md +0 -55
- package/src/core/code-flow/tasks/.gitkeep +0 -0
- package/src/core/code-flow/validation.yml +0 -59
- package/src/migrate/runtime.js +0 -213
- package/src/migrate/spec-workflow.js +0 -253
- package/src/runtime.js +0 -219
|
@@ -1,326 +0,0 @@
|
|
|
1
|
-
"""Validate test registration against a bounded, real collection command."""
|
|
2
|
-
from __future__ import annotations
|
|
3
|
-
|
|
4
|
-
import json
|
|
5
|
-
from pathlib import Path
|
|
6
|
-
import re
|
|
7
|
-
import shlex
|
|
8
|
-
import time
|
|
9
|
-
from typing import Mapping, Sequence
|
|
10
|
-
|
|
11
|
-
from codeflow.acceptance.cf_acceptance_schema import scenario_check_ids, validate_checks
|
|
12
|
-
from codeflow.core.cf_exec_base import run_command
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
REGISTRATION_FIELDS = ("suite", "test_ids", "markers", "collect", "level", "environment_revision", "evidence_ttl_seconds")
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class CollectionError(ValueError):
|
|
19
|
-
def __init__(self, code: str, details: Mapping[str, object]) -> None:
|
|
20
|
-
super().__init__(code)
|
|
21
|
-
self.code = code
|
|
22
|
-
self.details = dict(details)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def _pytest_markers(argv: Sequence[str]) -> list[str]:
|
|
26
|
-
positions = [index for index, value in enumerate(argv[:-1]) if value == "-m" and argv[index + 1] != "pytest"]
|
|
27
|
-
expression = argv[positions[-1] + 1] if positions else ""
|
|
28
|
-
markers = [value.strip() for value in expression.split(" and ")] if expression else []
|
|
29
|
-
if any(not re.fullmatch(r"[a-zA-Z_][a-zA-Z0-9_]*", value) for value in markers):
|
|
30
|
-
raise ValueError("check_collection_marker_expression_invalid")
|
|
31
|
-
return markers
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _pytest_collection(output: str, argv: Sequence[str]) -> list[dict[str, object]]:
|
|
35
|
-
markers = _pytest_markers(argv)
|
|
36
|
-
return [{"id": line.strip(), "markers": markers,
|
|
37
|
-
"capabilities": ["browser"] if "browser" in markers else []}
|
|
38
|
-
for line in output.splitlines() if re.fullmatch(r"[^\s]+\.py::[^\s]+", line.strip())]
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def _objects(value: object) -> list[Mapping[str, object]]:
|
|
42
|
-
if not isinstance(value, list) or not all(isinstance(item, Mapping) for item in value):
|
|
43
|
-
raise ValueError("check_collection_invalid_playwright_structure")
|
|
44
|
-
return value
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def _browser_projects(config: Mapping[str, object]) -> set[str]:
|
|
48
|
-
projects: set[str] = set()
|
|
49
|
-
for project in _objects(config.get("projects", [])):
|
|
50
|
-
metadata = project.get("metadata", {})
|
|
51
|
-
capabilities = metadata.get("capabilities", []) if isinstance(metadata, Mapping) else None
|
|
52
|
-
if not isinstance(capabilities, list) or not all(isinstance(value, str) for value in capabilities):
|
|
53
|
-
raise ValueError("check_collection_invalid_project_capabilities")
|
|
54
|
-
if "browser" in capabilities:
|
|
55
|
-
projects.add(str(project.get("name", "")).lower())
|
|
56
|
-
return projects | {"chromium", "firefox", "webkit", "browser"}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def _playwright_spec(spec: Mapping[str, object], suite: Mapping[str, object], report_root: Path,
|
|
60
|
-
root: str, projects: set[str]) -> list[dict[str, object]]:
|
|
61
|
-
file = spec.get("file", suite.get("file", ""))
|
|
62
|
-
tags = spec.get("tags", [])
|
|
63
|
-
if not isinstance(file, str) or not isinstance(tags, list) or not all(isinstance(value, str) for value in tags):
|
|
64
|
-
raise ValueError("check_collection_invalid_playwright_spec")
|
|
65
|
-
if type(spec.get("line")) is not int:
|
|
66
|
-
raise ValueError("check_collection_invalid_playwright_location")
|
|
67
|
-
file = (report_root / file).resolve().relative_to(Path(root).resolve()).as_posix()
|
|
68
|
-
tests: list[dict[str, object]] = []
|
|
69
|
-
for case in _objects(spec.get("tests", [])):
|
|
70
|
-
project = case.get("projectName", "")
|
|
71
|
-
if not isinstance(project, str):
|
|
72
|
-
raise ValueError("check_collection_invalid_playwright_project")
|
|
73
|
-
tests.append({"id": f"{file}:{spec['line']}@{project}", "markers": [tag.lstrip("@") for tag in tags],
|
|
74
|
-
"capabilities": ["browser"] if project.lower() in projects else []})
|
|
75
|
-
return tests
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def _playwright_collection(data: Mapping[str, object], root: str) -> list[dict[str, object]]:
|
|
79
|
-
tests: list[dict[str, object]] = []
|
|
80
|
-
config = data.get("config", {})
|
|
81
|
-
if not isinstance(config, Mapping) or not isinstance(config.get("rootDir", root), str):
|
|
82
|
-
raise ValueError("check_collection_invalid_playwright_config")
|
|
83
|
-
report_root = Path(str(config.get("rootDir", root)))
|
|
84
|
-
projects = _browser_projects(config)
|
|
85
|
-
pending = list(_objects(data.get("suites", [])))
|
|
86
|
-
while pending:
|
|
87
|
-
suite = pending.pop()
|
|
88
|
-
if not isinstance(suite, Mapping):
|
|
89
|
-
raise ValueError("check_collection_invalid_playwright_suite")
|
|
90
|
-
pending.extend(_objects(suite.get("suites", [])))
|
|
91
|
-
for spec in _objects(suite.get("specs", [])):
|
|
92
|
-
tests.extend(_playwright_spec(spec, suite, report_root, root, projects))
|
|
93
|
-
return tests
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def _parse_collection(output: str, config: Mapping[str, object], root: str) -> list[Mapping[str, object]]:
|
|
97
|
-
fmt = config.get("format", "json")
|
|
98
|
-
if fmt == "pytest":
|
|
99
|
-
return _pytest_collection(output, config["argv"])
|
|
100
|
-
try:
|
|
101
|
-
data = json.loads(output)
|
|
102
|
-
except json.JSONDecodeError as exc:
|
|
103
|
-
raise ValueError("check_collection_invalid_json") from exc
|
|
104
|
-
if not isinstance(data, Mapping):
|
|
105
|
-
raise ValueError("check_collection_invalid_tests")
|
|
106
|
-
tests = _playwright_collection(data, root) if fmt == "playwright" else data.get("tests")
|
|
107
|
-
if fmt not in ("json", "pytest", "playwright") or not isinstance(tests, list):
|
|
108
|
-
raise ValueError("check_collection_invalid_format")
|
|
109
|
-
for test in tests:
|
|
110
|
-
if not isinstance(test, Mapping) or not isinstance(test.get("id"), str):
|
|
111
|
-
raise ValueError("check_collection_invalid_tests")
|
|
112
|
-
for field in ("capabilities", "markers"):
|
|
113
|
-
values = test.get(field, [])
|
|
114
|
-
if not isinstance(values, list) or not all(isinstance(value, str) for value in values):
|
|
115
|
-
raise ValueError("check_collection_invalid_tests")
|
|
116
|
-
return tests
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def _finding(code: str, scenario: str, check: str, message: str) -> dict[str, str]:
|
|
120
|
-
return {"code": code, "scenario": scenario, "check_id": check, "message": message}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def _execution_argv(root: str, check: Mapping[str, object]) -> list[str]:
|
|
124
|
-
command = check.get("command")
|
|
125
|
-
if not isinstance(command, list) or not all(isinstance(value, str) for value in command):
|
|
126
|
-
return []
|
|
127
|
-
expanded = list(command)
|
|
128
|
-
for _ in range(4):
|
|
129
|
-
if not expanded or Path(expanded[0]).name not in ("npm", "pnpm", "yarn"):
|
|
130
|
-
return expanded
|
|
131
|
-
offset = 2 if len(expanded) > 1 and expanded[1] == "run" else 1
|
|
132
|
-
if len(expanded) <= offset:
|
|
133
|
-
return []
|
|
134
|
-
package = Path(root) / str(check.get("cwd", ".")) / "package.json"
|
|
135
|
-
data = json.loads(package.read_text(encoding="utf-8"))
|
|
136
|
-
script = data.get("scripts", {}).get(expanded[offset])
|
|
137
|
-
if not isinstance(script, str):
|
|
138
|
-
return []
|
|
139
|
-
lexer = shlex.shlex(script, posix=True, punctuation_chars=True)
|
|
140
|
-
lexer.whitespace_split = True
|
|
141
|
-
tokens = list(lexer)
|
|
142
|
-
if any(value in ("&&", "||", ";", "|", ">", ">>", "&") for value in tokens):
|
|
143
|
-
raise ValueError("check_execution_script_requires_direct_command")
|
|
144
|
-
expanded = tokens + [value for value in expanded[offset + 1:] if value != "--"]
|
|
145
|
-
return []
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
def _execution_matches(root: str, check: Mapping[str, object], selected: Sequence[Mapping[str, object]]) -> bool:
|
|
149
|
-
from codeflow.acceptance.cf_check_inputs import match_path
|
|
150
|
-
argv = _execution_argv(root, check)
|
|
151
|
-
if not argv or not selected or any(value in argv for value in ("-k", "-g", "--grep", "--grep-invert")):
|
|
152
|
-
return False
|
|
153
|
-
pytest = any(Path(value).name in ("pytest", "py.test") for value in argv)
|
|
154
|
-
markers = _pytest_markers(argv) if pytest else []
|
|
155
|
-
if markers and any(not set(markers) <= set(item.get("markers", [])) for item in selected):
|
|
156
|
-
return False
|
|
157
|
-
selectors: list[str] = []
|
|
158
|
-
for value in argv:
|
|
159
|
-
normalized = value.replace("\\", "/").removeprefix("./")
|
|
160
|
-
if normalized.startswith(str(Path(root).resolve()) + "/"):
|
|
161
|
-
normalized = normalized[len(str(Path(root).resolve())) + 1:]
|
|
162
|
-
if re.fullmatch(r"[\w./*?:-]+\.(?:py|[cm]?[jt]sx?)(?::[\w:\[\].-]+)?", normalized):
|
|
163
|
-
selectors.append(normalized)
|
|
164
|
-
if selectors:
|
|
165
|
-
return all(any(match_path(str(item["id"]).split("@", 1)[0], selector)
|
|
166
|
-
or match_path(str(item["id"]).split("@", 1)[0].split(":", 1)[0], selector)
|
|
167
|
-
for selector in selectors) for item in selected)
|
|
168
|
-
if pytest:
|
|
169
|
-
return bool(markers)
|
|
170
|
-
return any(Path(value).name in ("playwright", "cypress") for value in argv)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def _collected(root: str, check: Mapping[str, object], deadline: float | None = None) -> dict[str, Mapping[str, object]]:
|
|
174
|
-
config = check.get("collect")
|
|
175
|
-
if not isinstance(config, Mapping):
|
|
176
|
-
raise ValueError("check_collection_missing")
|
|
177
|
-
argv = config.get("argv")
|
|
178
|
-
if not isinstance(argv, list) or not argv or not all(isinstance(item, str) for item in argv):
|
|
179
|
-
raise ValueError("check_collection_invalid")
|
|
180
|
-
timeout = config.get("timeout", 10)
|
|
181
|
-
if isinstance(timeout, bool) or not isinstance(timeout, (float, int)) or not 0 < timeout <= 60:
|
|
182
|
-
raise ValueError("check_collection_timeout_invalid")
|
|
183
|
-
directory = (Path(root) / str(check.get("cwd", "."))).resolve()
|
|
184
|
-
if directory != Path(root).resolve() and Path(root).resolve() not in directory.parents:
|
|
185
|
-
raise ValueError("check_collection_outside_project")
|
|
186
|
-
result = run_command(argv, str(directory), float(timeout), deadline)
|
|
187
|
-
if result.get("status") != "ok" or result.get("returncode") != 0:
|
|
188
|
-
raise CollectionError("check_collection_failed", {"command": argv, "cwd": str(directory),
|
|
189
|
-
"exit_code": result.get("returncode"), "status": result.get("status"),
|
|
190
|
-
"output": (str(result.get("stdout", "")) + str(result.get("stderr", "")))[-4000:]})
|
|
191
|
-
output = str(result.get("stdout", ""))
|
|
192
|
-
if len(output.encode()) > 2_000_000:
|
|
193
|
-
raise ValueError("check_collection_too_large")
|
|
194
|
-
tests = _parse_collection(output, config, root)
|
|
195
|
-
if len({item["id"] for item in tests}) != len(tests):
|
|
196
|
-
raise ValueError("check_collection_duplicate_test")
|
|
197
|
-
return {str(item["id"]): item for item in tests}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
def _requirements(row: Mapping[str, object]) -> tuple[bool, set[str]]:
|
|
201
|
-
browser = str(row.get("level", row.get("kind", ""))).lower() == "e2e"
|
|
202
|
-
markers: set[str] = set()
|
|
203
|
-
if row.get("smoke"):
|
|
204
|
-
markers.add("smoke")
|
|
205
|
-
if row.get("level") == "capacity" or "capacity" in row.get("tags", []):
|
|
206
|
-
markers.add("capacity")
|
|
207
|
-
return browser, markers
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
def registration_required(row: Mapping[str, object]) -> bool:
|
|
211
|
-
"""Return whether a scenario needs collection-backed registration."""
|
|
212
|
-
browser, markers = _requirements(row)
|
|
213
|
-
return bool(browser or markers)
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
def _collection_config(root: str, check: Mapping[str, object], markers: set[str]) -> dict[str, object]:
|
|
217
|
-
argv = _execution_argv(root, check)
|
|
218
|
-
if any(Path(value).name in ("pytest", "py.test") for value in argv):
|
|
219
|
-
if "-k" in argv:
|
|
220
|
-
raise ValueError("check_execution_selection_mismatch: use concrete pytest node IDs")
|
|
221
|
-
selected_markers = set(_pytest_markers(argv)) | markers
|
|
222
|
-
argv = [value for value in argv if not re.fullmatch(r"-q+|--quiet", value)]
|
|
223
|
-
argv += ["--collect-only", "--verbosity=-1"]
|
|
224
|
-
if selected_markers:
|
|
225
|
-
argv += ["-m", " and ".join(sorted(selected_markers))]
|
|
226
|
-
return {"argv": argv, "format": "pytest", "timeout": 10}
|
|
227
|
-
if any(Path(value).name == "playwright" for value in argv):
|
|
228
|
-
return {"argv": argv + ["--list", "--reporter=json"], "format": "playwright", "timeout": 10}
|
|
229
|
-
raise ValueError("check_collection_inference_unsupported: supply --registration-file")
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
def register_check(root: str, check: dict[str, object], rows: Sequence[Mapping[str, object]],
|
|
233
|
-
deadline: float) -> list[dict[str, str]]:
|
|
234
|
-
"""Collect, populate and validate one check against all of its scenarios."""
|
|
235
|
-
validate_checks([check])
|
|
236
|
-
effective_rows = [{**row, "level": check.get("level", row.get("level"))} for row in rows]
|
|
237
|
-
requirements = [_requirements(row) for row in effective_rows]
|
|
238
|
-
browser = any(item[0] for item in requirements)
|
|
239
|
-
markers = set().union(*(item[1] for item in requirements))
|
|
240
|
-
if "collect" not in check:
|
|
241
|
-
check["collect"] = _collection_config(root, check, markers | ({"browser"} if browser else set()))
|
|
242
|
-
collected = _collected(root, check, deadline)
|
|
243
|
-
if not collected:
|
|
244
|
-
raise ValueError("check_collection_empty")
|
|
245
|
-
check.setdefault("test_ids", sorted(collected))
|
|
246
|
-
selected = [collected[value] for value in check["test_ids"] if value in collected]
|
|
247
|
-
actual_markers = set.intersection(*(set(item.get("markers", [])) for item in selected)) if selected else set()
|
|
248
|
-
check.setdefault("markers", sorted(actual_markers))
|
|
249
|
-
if browser and selected and all("browser" in item.get("capabilities", []) for item in selected):
|
|
250
|
-
check.setdefault("suite", "browser")
|
|
251
|
-
validate_checks([check])
|
|
252
|
-
return [issue for row in effective_rows for issue in _registration_issues(root, row, check, collected)]
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
def _registration_issues(root: str, row: Mapping[str, object], check: Mapping[str, object],
|
|
256
|
-
collected: Mapping[str, Mapping[str, object]]) -> list[dict[str, str]]:
|
|
257
|
-
scenario, check_id = str(row.get("id", "")), str(check.get("id", ""))
|
|
258
|
-
browser, markers = _requirements(row)
|
|
259
|
-
errors: list[dict[str, str]] = []
|
|
260
|
-
test_ids = check.get("test_ids", [])
|
|
261
|
-
if not isinstance(test_ids, list) or not test_ids or not all(isinstance(value, str) for value in test_ids):
|
|
262
|
-
return [_finding("check_test_ids_missing", scenario, check_id, "check 必须登记具体测试 ID")]
|
|
263
|
-
selected = [collected[value] for value in test_ids if value in collected]
|
|
264
|
-
if len(selected) != len(test_ids):
|
|
265
|
-
errors.append(_finding("check_test_not_collected", scenario, check_id, "登记的测试 ID 未全部被真实收集"))
|
|
266
|
-
try:
|
|
267
|
-
matches = _execution_matches(root, check, selected)
|
|
268
|
-
except (OSError, ValueError) as exc:
|
|
269
|
-
errors.append(_finding("check_execution_config_invalid", scenario, check_id, str(exc)))
|
|
270
|
-
matches = False
|
|
271
|
-
if not matches:
|
|
272
|
-
errors.append(_finding("check_execution_selection_mismatch", scenario, check_id,
|
|
273
|
-
"实际执行命令必须选择登记用例;不可验证的 grep/-k 过滤请改用具体测试 ID"))
|
|
274
|
-
if browser and (check.get("suite") != "browser" or any("browser" not in item.get("capabilities", []) for item in selected)):
|
|
275
|
-
errors.append(_finding("e2e_check_not_browser", scenario, check_id, "E2E check 必须属于 browser 套件并收集到浏览器能力用例"))
|
|
276
|
-
if check.get("level") and check.get("level") != "e2e" and any(
|
|
277
|
-
"browser" in item.get("capabilities", []) for item in selected
|
|
278
|
-
):
|
|
279
|
-
errors.append(_finding("check_level_browser_mismatch", scenario, check_id, "浏览器用例必须声明 e2e 层级"))
|
|
280
|
-
for marker in sorted(markers):
|
|
281
|
-
if marker not in check.get("markers", []) or any(marker not in item.get("markers", []) for item in selected):
|
|
282
|
-
code = "capacity_check_not_marked" if marker == "capacity" else "smoke_check_not_registered"
|
|
283
|
-
errors.append(_finding(code, scenario, check_id, f"check 与真实收集用例必须包含 {marker} 标记"))
|
|
284
|
-
return errors
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
def validate_registration(root: str, scenarios: Sequence[Mapping[str, object]],
|
|
288
|
-
checks: Sequence[Mapping[str, object]]) -> dict[str, object]:
|
|
289
|
-
by_id = {str(check["id"]): check for check in checks}
|
|
290
|
-
cache: dict[str, dict[str, Mapping[str, object]] | Exception] = {}
|
|
291
|
-
errors: list[dict[str, object]] = []
|
|
292
|
-
deadline = time.monotonic() + 60
|
|
293
|
-
runnable_smoke = runnable_browser = 0
|
|
294
|
-
for row in scenarios:
|
|
295
|
-
browser, markers = _requirements(row)
|
|
296
|
-
ids = scenario_check_ids(row)
|
|
297
|
-
has_browser_check = any(by_id.get(check_id, {}).get("level") == "e2e" for check_id in ids)
|
|
298
|
-
if not browser and not markers and not has_browser_check:
|
|
299
|
-
continue
|
|
300
|
-
row_errors: list[dict[str, object]] = []
|
|
301
|
-
if not ids:
|
|
302
|
-
row_errors.append(_finding("scenario_check_missing", str(row["id"]), "", "需要可执行 check 绑定"))
|
|
303
|
-
for check_id in ids:
|
|
304
|
-
check = by_id.get(check_id)
|
|
305
|
-
if check is None:
|
|
306
|
-
row_errors.append(_finding("unknown_check", str(row["id"]), check_id, "引用的 check 不存在"))
|
|
307
|
-
continue
|
|
308
|
-
effective = {**row, "level": check.get("level", row.get("level"))}
|
|
309
|
-
if not registration_required(effective):
|
|
310
|
-
continue
|
|
311
|
-
if check_id not in cache:
|
|
312
|
-
try:
|
|
313
|
-
cache[check_id] = _collected(root, check, deadline)
|
|
314
|
-
except (OSError, ValueError) as exc:
|
|
315
|
-
cache[check_id] = exc
|
|
316
|
-
collected = cache[check_id]
|
|
317
|
-
if isinstance(collected, Exception):
|
|
318
|
-
detail = collected.details if isinstance(collected, CollectionError) else {}
|
|
319
|
-
row_errors.append({**_finding(str(collected), str(row["id"]), check_id, "无法验证测试收集与套件登记"), **detail})
|
|
320
|
-
else:
|
|
321
|
-
row_errors.extend(_registration_issues(root, effective, check, collected))
|
|
322
|
-
errors.extend(row_errors)
|
|
323
|
-
runnable_smoke += int(bool(row.get("smoke")) and not row_errors)
|
|
324
|
-
runnable_browser += int(browser and not row_errors)
|
|
325
|
-
return {"ok": not errors, "errors": errors, "runnable_smoke_count": runnable_smoke,
|
|
326
|
-
"runnable_browser_count": runnable_browser, "collected_checks": len(cache)}
|
|
@@ -1,333 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Spec frontmatter checks: parse, cache and execute (quality loop FEAT-01).
|
|
3
|
-
|
|
4
|
-
Contract (spec-quality-loop.design.md §2.3.2 / §3.4 / §3.5):
|
|
5
|
-
- Checks live in spec frontmatter `checks:` so rule text and machine check
|
|
6
|
-
stay in the same file (ADR: frontmatter over checks.yml).
|
|
7
|
-
- Invalid entries are skipped and surfaced as parse errors (E-01) — they must
|
|
8
|
-
never break parsing of sibling checks or spec injection.
|
|
9
|
-
- Execution guards: per-check timeout (E-02), oversized content skipped
|
|
10
|
-
(B-01), disabled checks filtered (RULE-04 state), matched lines per check
|
|
11
|
-
capped so feedback stays small (RISK-05).
|
|
12
|
-
- yaml import is lazy: specs without `checks:` never pay for it on the
|
|
13
|
-
PostToolUse hot path (NFR-PERF-01).
|
|
14
|
-
"""
|
|
15
|
-
import fnmatch
|
|
16
|
-
import os
|
|
17
|
-
import re
|
|
18
|
-
from typing import Mapping
|
|
19
|
-
from concurrent.futures import ThreadPoolExecutor
|
|
20
|
-
from concurrent.futures import TimeoutError as FutureTimeout
|
|
21
|
-
|
|
22
|
-
from codeflow.core import cf_log
|
|
23
|
-
from codeflow.core.cf_core import normalize_path
|
|
24
|
-
from codeflow.core.cf_markdown import frontmatter_block
|
|
25
|
-
|
|
26
|
-
CHECK_TIMEOUT_SECONDS = 2.0
|
|
27
|
-
MAX_CONTENT_BYTES = 256 * 1024
|
|
28
|
-
MESSAGE_MAX_LEN = 200
|
|
29
|
-
MAX_LINES_PER_CHECK = 3
|
|
30
|
-
|
|
31
|
-
_ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
32
|
-
IMPLEMENTED_TYPES = ("regex",)
|
|
33
|
-
RESERVED_TYPES = ("ast", "cmd") # interface reserved, implementation deferred
|
|
34
|
-
_SEVERITIES = ("warn", "info") # no "error": feedback only, never blocking
|
|
35
|
-
|
|
36
|
-
_checks_cache: dict = {}
|
|
37
|
-
|
|
38
|
-
STATE_NAME = ".check-state.json"
|
|
39
|
-
FP_DISABLE_COUNT = 3
|
|
40
|
-
FP_DISABLE_RATE = 0.10
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def state_path(project_root: str) -> str:
|
|
44
|
-
return os.path.join(project_root, ".code-flow", STATE_NAME)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def load_check_state(project_root: str) -> dict:
|
|
48
|
-
"""Read check-state; corrupt/missing files rebuild as empty (tolerant)."""
|
|
49
|
-
import json
|
|
50
|
-
try:
|
|
51
|
-
with open(state_path(project_root), "r", encoding="utf-8") as f:
|
|
52
|
-
data = json.load(f)
|
|
53
|
-
return data if isinstance(data, dict) else {}
|
|
54
|
-
except Exception:
|
|
55
|
-
return {}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def save_check_state(project_root: str, state: dict) -> bool:
|
|
59
|
-
import json
|
|
60
|
-
try:
|
|
61
|
-
path = state_path(project_root)
|
|
62
|
-
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
63
|
-
with open(path, "w", encoding="utf-8") as f:
|
|
64
|
-
json.dump(state, f, ensure_ascii=False)
|
|
65
|
-
return True
|
|
66
|
-
except Exception as exc:
|
|
67
|
-
cf_log.warn(f"save_check_state failed: {exc}")
|
|
68
|
-
return False
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
def record_hits(project_root: str, check_ids: list) -> dict:
|
|
72
|
-
"""Increment hit_count for triggered checks (误报率分母). Returns state."""
|
|
73
|
-
state = load_check_state(project_root)
|
|
74
|
-
for cid in check_ids:
|
|
75
|
-
entry = state.setdefault(cid, {})
|
|
76
|
-
entry["hit_count"] = int(entry.get("hit_count", 0)) + 1
|
|
77
|
-
save_check_state(project_root, state)
|
|
78
|
-
return state
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def record_false_positive(project_root: str, check_id: str) -> dict:
|
|
82
|
-
"""Mark one false positive; auto-disable past RULE-04 thresholds.
|
|
83
|
-
|
|
84
|
-
Returns {"check_id", "fp_count", "hit_count", "disabled"}.
|
|
85
|
-
"""
|
|
86
|
-
state = load_check_state(project_root)
|
|
87
|
-
entry = state.setdefault(check_id, {})
|
|
88
|
-
entry["fp_count"] = int(entry.get("fp_count", 0)) + 1
|
|
89
|
-
fp = entry["fp_count"]
|
|
90
|
-
hits = int(entry.get("hit_count", 0))
|
|
91
|
-
if not entry.get("disabled") and (
|
|
92
|
-
fp >= FP_DISABLE_COUNT or (hits > 0 and fp / hits > FP_DISABLE_RATE)
|
|
93
|
-
):
|
|
94
|
-
entry["disabled"] = True
|
|
95
|
-
entry["disabled_reason"] = (
|
|
96
|
-
f"auto: fp_count={fp}" if fp >= FP_DISABLE_COUNT
|
|
97
|
-
else f"auto: fp_rate={fp}/{hits}"
|
|
98
|
-
)
|
|
99
|
-
save_check_state(project_root, state)
|
|
100
|
-
return {
|
|
101
|
-
"check_id": check_id,
|
|
102
|
-
"fp_count": fp,
|
|
103
|
-
"hit_count": hits,
|
|
104
|
-
"disabled": bool(entry.get("disabled")),
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def _validate_entry(entry: object, index: int, seen_ids: set) -> tuple:
|
|
109
|
-
"""Validate one raw checks entry → (check_dict | None, error | None)."""
|
|
110
|
-
if not isinstance(entry, dict):
|
|
111
|
-
return None, f"checks[{index}]: 不是对象,已跳过"
|
|
112
|
-
cid = entry.get("id")
|
|
113
|
-
if not isinstance(cid, str) or not _ID_RE.match(cid):
|
|
114
|
-
return None, f"checks[{index}]: id 缺失或非 kebab-case,已跳过"
|
|
115
|
-
if cid in seen_ids:
|
|
116
|
-
return None, f"checks[{index}]: id '{cid}' 重复,已跳过"
|
|
117
|
-
ctype = entry.get("type")
|
|
118
|
-
if ctype in RESERVED_TYPES:
|
|
119
|
-
return None, f"{cid}: type '{ctype}' 暂未实现(接口预留),已跳过"
|
|
120
|
-
if ctype not in IMPLEMENTED_TYPES:
|
|
121
|
-
return None, f"{cid}: 未知 type '{ctype}',已跳过"
|
|
122
|
-
pattern = entry.get("pattern")
|
|
123
|
-
if not isinstance(pattern, str) or not pattern:
|
|
124
|
-
return None, f"{cid}: pattern 缺失,已跳过"
|
|
125
|
-
try:
|
|
126
|
-
compiled = re.compile(pattern)
|
|
127
|
-
except re.error as exc:
|
|
128
|
-
return None, f"{cid}: pattern 非法正则({exc}),已跳过"
|
|
129
|
-
|
|
130
|
-
message = entry.get("message")
|
|
131
|
-
if not isinstance(message, str) or not message.strip():
|
|
132
|
-
return None, f"{cid}: message 缺失,已跳过"
|
|
133
|
-
error = None
|
|
134
|
-
if len(message) > MESSAGE_MAX_LEN:
|
|
135
|
-
error = f"{cid}: message 超过 {MESSAGE_MAX_LEN} 字符,运行时截断"
|
|
136
|
-
message = message[:MESSAGE_MAX_LEN]
|
|
137
|
-
|
|
138
|
-
severity = entry.get("severity", "warn")
|
|
139
|
-
if severity not in _SEVERITIES:
|
|
140
|
-
error = error or f"{cid}: severity '{severity}' 非法,按 warn 处理"
|
|
141
|
-
severity = "warn"
|
|
142
|
-
|
|
143
|
-
# default "*": fnmatch's "*" crosses "/" so it matches any depth;
|
|
144
|
-
# "**/*" under fnmatch would demand at least one directory level and
|
|
145
|
-
# silently skip repo-root files (same pitfall fixed in config patterns)
|
|
146
|
-
files = entry.get("files")
|
|
147
|
-
if not isinstance(files, str) or not files:
|
|
148
|
-
files = "*"
|
|
149
|
-
|
|
150
|
-
return {
|
|
151
|
-
"id": cid,
|
|
152
|
-
"type": ctype,
|
|
153
|
-
"pattern": pattern,
|
|
154
|
-
"regex": compiled,
|
|
155
|
-
"files": files,
|
|
156
|
-
"message": message,
|
|
157
|
-
"severity": severity,
|
|
158
|
-
}, error
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
def parse_spec_checks(content: str) -> tuple:
|
|
162
|
-
"""Parse frontmatter checks → (checks, errors). Both lists, never raises."""
|
|
163
|
-
if not isinstance(content, str):
|
|
164
|
-
return [], []
|
|
165
|
-
match = frontmatter_block(content)
|
|
166
|
-
if match is None or "checks:" not in match:
|
|
167
|
-
return [], []
|
|
168
|
-
try:
|
|
169
|
-
import yaml
|
|
170
|
-
meta = yaml.safe_load(match) or {}
|
|
171
|
-
except Exception as exc:
|
|
172
|
-
return [], [f"frontmatter YAML 解析失败: {exc}"]
|
|
173
|
-
raw = meta.get("checks")
|
|
174
|
-
if raw is None:
|
|
175
|
-
return [], []
|
|
176
|
-
if not isinstance(raw, list):
|
|
177
|
-
return [], ["checks 必须是列表"]
|
|
178
|
-
checks: list = []
|
|
179
|
-
errors: list = []
|
|
180
|
-
seen_ids: set = set()
|
|
181
|
-
for index, entry in enumerate(raw):
|
|
182
|
-
check, error = _validate_entry(entry, index, seen_ids)
|
|
183
|
-
if error:
|
|
184
|
-
errors.append(error)
|
|
185
|
-
if check:
|
|
186
|
-
checks.append(check)
|
|
187
|
-
seen_ids.add(check["id"])
|
|
188
|
-
return checks, errors
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
def load_spec_checks(spec_abs_path: str) -> tuple:
|
|
192
|
-
"""Parse a spec file's checks with mtime cache → (checks, errors)."""
|
|
193
|
-
try:
|
|
194
|
-
mtime = os.path.getmtime(spec_abs_path)
|
|
195
|
-
except OSError:
|
|
196
|
-
return [], []
|
|
197
|
-
cached = _checks_cache.get(spec_abs_path)
|
|
198
|
-
if cached and cached["mtime"] == mtime:
|
|
199
|
-
return cached["checks"], cached["errors"]
|
|
200
|
-
try:
|
|
201
|
-
with open(spec_abs_path, "r", encoding="utf-8") as f:
|
|
202
|
-
checks, errors = parse_spec_checks(f.read())
|
|
203
|
-
except Exception as exc:
|
|
204
|
-
cf_log.warn(f"load_spec_checks failed {spec_abs_path}: {exc}")
|
|
205
|
-
return [], []
|
|
206
|
-
_checks_cache[spec_abs_path] = {
|
|
207
|
-
"mtime": mtime, "checks": checks, "errors": errors,
|
|
208
|
-
}
|
|
209
|
-
return checks, errors
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
# 纠正句式(FEAT-04 信号①):保守收录、宁漏勿误(E-06)。
|
|
213
|
-
# 否定排除:不要紧/不要紧张、对不对、不对外/不对称。
|
|
214
|
-
_CORRECTION_PATTERNS = [
|
|
215
|
-
r"不要(?!紧)",
|
|
216
|
-
r"(?<!对)不对(?![外称])",
|
|
217
|
-
r"不是这样",
|
|
218
|
-
r"我说过",
|
|
219
|
-
r"改回[去来]",
|
|
220
|
-
r"别再?用",
|
|
221
|
-
r"写错了",
|
|
222
|
-
r"\bdon'?t\s+use\b",
|
|
223
|
-
r"\bthat'?s\s+wrong\b",
|
|
224
|
-
r"\bI\s+said\b",
|
|
225
|
-
r"\brevert\s+(that|this|it)\b",
|
|
226
|
-
r"\bstop\s+using\b",
|
|
227
|
-
]
|
|
228
|
-
_CORRECTION_RE = re.compile("|".join(_CORRECTION_PATTERNS), re.IGNORECASE)
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
def detect_correction(prompt: str) -> dict:
|
|
232
|
-
"""Detect a correction phrase in a user prompt → {"phrase", "span"} | None.
|
|
233
|
-
|
|
234
|
-
Single combined regex, first hit wins; the wordlist is deliberately
|
|
235
|
-
conservative — a missed correction only delays learning, a false hit
|
|
236
|
-
pollutes the candidate stream.
|
|
237
|
-
"""
|
|
238
|
-
if not isinstance(prompt, str) or not prompt:
|
|
239
|
-
return None
|
|
240
|
-
match = _CORRECTION_RE.search(prompt)
|
|
241
|
-
if not match:
|
|
242
|
-
return None
|
|
243
|
-
return {"phrase": match.group(0), "span": [match.start(), match.end()]}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
def _match_lines(regex, content: str) -> list:
|
|
247
|
-
hits = []
|
|
248
|
-
for line_no, line in enumerate(content.splitlines(), 1):
|
|
249
|
-
if regex.search(line):
|
|
250
|
-
hits.append((line_no, line.strip()))
|
|
251
|
-
if len(hits) >= MAX_LINES_PER_CHECK:
|
|
252
|
-
break
|
|
253
|
-
return hits
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
def run_checks(
|
|
257
|
-
checks: list,
|
|
258
|
-
rel_path: str,
|
|
259
|
-
content: str,
|
|
260
|
-
state: dict = None,
|
|
261
|
-
timeout: float = CHECK_TIMEOUT_SECONDS,
|
|
262
|
-
) -> tuple:
|
|
263
|
-
"""Execute checks against one file's content → (violations, skipped).
|
|
264
|
-
|
|
265
|
-
violations: [{check_id, file, line_no, line, message, severity}]
|
|
266
|
-
skipped: [{check_id, reason}] — timeout / oversize, caller records
|
|
267
|
-
degrade events (E-02 / B-01).
|
|
268
|
-
Disabled checks (state[check_id]["disabled"]) are silently filtered.
|
|
269
|
-
"""
|
|
270
|
-
violations: list = []
|
|
271
|
-
skipped: list = []
|
|
272
|
-
if not checks or not isinstance(content, str):
|
|
273
|
-
return violations, skipped
|
|
274
|
-
if len(content.encode("utf-8", errors="ignore")) > MAX_CONTENT_BYTES:
|
|
275
|
-
return violations, [{"check_id": "*", "reason": "content_too_large"}]
|
|
276
|
-
|
|
277
|
-
rel_path = normalize_path(rel_path)
|
|
278
|
-
state = state or {}
|
|
279
|
-
with ThreadPoolExecutor(max_workers=1) as pool:
|
|
280
|
-
for check in checks:
|
|
281
|
-
if state.get(check["id"], {}).get("disabled"):
|
|
282
|
-
continue
|
|
283
|
-
if not fnmatch.fnmatch(rel_path, check["files"]):
|
|
284
|
-
continue
|
|
285
|
-
future = pool.submit(_match_lines, check["regex"], content)
|
|
286
|
-
try:
|
|
287
|
-
hits = future.result(timeout=timeout)
|
|
288
|
-
except FutureTimeout:
|
|
289
|
-
skipped.append({"check_id": check["id"], "reason": "timeout"})
|
|
290
|
-
future.cancel()
|
|
291
|
-
continue
|
|
292
|
-
except Exception as exc:
|
|
293
|
-
skipped.append({"check_id": check["id"], "reason": f"error:{exc}"})
|
|
294
|
-
continue
|
|
295
|
-
for line_no, line in hits:
|
|
296
|
-
violations.append({
|
|
297
|
-
"check_id": check["id"],
|
|
298
|
-
"file": rel_path,
|
|
299
|
-
"line_no": line_no,
|
|
300
|
-
"line": line[:160],
|
|
301
|
-
"message": check["message"],
|
|
302
|
-
"severity": check["severity"],
|
|
303
|
-
})
|
|
304
|
-
return violations, skipped
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
def run_regex_verifier(
|
|
308
|
-
pattern: str,
|
|
309
|
-
files: str,
|
|
310
|
-
contents: Mapping[str, str],
|
|
311
|
-
timeout: float = CHECK_TIMEOUT_SECONDS,
|
|
312
|
-
) -> tuple[list[dict[str, object]], list[dict[str, object]]]:
|
|
313
|
-
"""Execute one verifier regex over explicit file contents."""
|
|
314
|
-
try:
|
|
315
|
-
compiled = re.compile(pattern)
|
|
316
|
-
except re.error as exc:
|
|
317
|
-
return [], [{"check_id": "verifier", "reason": f"invalid_regex:{exc}"}]
|
|
318
|
-
check = {
|
|
319
|
-
"id": "verifier",
|
|
320
|
-
"type": "regex",
|
|
321
|
-
"pattern": pattern,
|
|
322
|
-
"regex": compiled,
|
|
323
|
-
"files": files,
|
|
324
|
-
"message": "Rule verifier matched forbidden content",
|
|
325
|
-
"severity": "warn",
|
|
326
|
-
}
|
|
327
|
-
violations: list[dict[str, object]] = []
|
|
328
|
-
skipped: list[dict[str, object]] = []
|
|
329
|
-
for relative, content in contents.items():
|
|
330
|
-
file_violations, file_skipped = run_checks([check], relative, content, timeout=timeout)
|
|
331
|
-
violations.extend(file_violations)
|
|
332
|
-
skipped.extend(file_skipped)
|
|
333
|
-
return violations, skipped
|