@michaelxwb/code-flow 0.6.5 → 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 -42
- 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 +52 -39
- package/src/adapters/claude/commands/cf-task/status.md +12 -0
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +68 -35
- 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 -42
- 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 +52 -39
- package/src/adapters/codex/skills/cf-task-status/SKILL.md +12 -0
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +69 -35
- 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 -42
- 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 +52 -39
- package/src/adapters/costrict/commands/cf-task/status.md +12 -0
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +68 -35
- 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 -42
- 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 +53 -45
- package/src/adapters/opencode/commands/cf-task/status.md +13 -0
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +68 -35
- 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 -1741
- package/src/core/code-flow/scripts/cf_spec_gate.py +5 -259
- 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 -389
- package/src/core/code-flow/scripts/cf_task_workflow.py +6 -201
- 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} +5 -2
- 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} +163 -35
- 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 -304
- 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,32 @@
|
|
|
1
|
+
"""Integrity check for the installed runtime contract and managed artifacts."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def verify_install(root: str) -> None:
|
|
8
|
+
directory = Path(root)
|
|
9
|
+
if (directory / ".code-flow/.runtime-migration.lock").exists():
|
|
10
|
+
raise ValueError("runtime migration is in progress or needs recovery")
|
|
11
|
+
manifest = json.loads((directory / ".code-flow/.runtime-install.json").read_text(encoding="utf-8"))
|
|
12
|
+
version = (directory / ".code-flow/.version").read_text(encoding="utf-8").strip()
|
|
13
|
+
if manifest.get("schema_version") != 1 or manifest.get("version") != version:
|
|
14
|
+
raise ValueError("runtime installation version is inconsistent")
|
|
15
|
+
files = manifest.get("files")
|
|
16
|
+
if not isinstance(files, dict) or not files:
|
|
17
|
+
raise ValueError("runtime installation manifest has no artifacts")
|
|
18
|
+
for relative, expected in files.items():
|
|
19
|
+
path = directory / relative
|
|
20
|
+
if not path.resolve().is_relative_to(directory.resolve()):
|
|
21
|
+
raise ValueError(f"installation artifact escapes project: {relative}")
|
|
22
|
+
if hashlib.sha256(path.read_bytes()).hexdigest() != expected:
|
|
23
|
+
raise ValueError(f"managed runtime artifact has drifted: {relative}")
|
|
24
|
+
for relative, expected in manifest.get("hook_contracts", {}).items():
|
|
25
|
+
actual = json.loads((directory / relative).read_text(encoding="utf-8")).get("hooks", {})
|
|
26
|
+
for event, groups in expected.items():
|
|
27
|
+
for group in groups:
|
|
28
|
+
candidates = [item for item in actual.get(event, [])
|
|
29
|
+
if item.get("matcher", "") == group.get("matcher", "")]
|
|
30
|
+
for hook in group["hooks"]:
|
|
31
|
+
if not any(hook in item.get("hooks", []) for item in candidates):
|
|
32
|
+
raise ValueError(f"managed hook has drifted: {relative}:{event}")
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Nonblocking process lock for Stop gates; the OS releases abandoned locks."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import IO, Iterator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class StopAlreadyRunning(ValueError):
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _lock(handle: IO[str]) -> None:
|
|
16
|
+
if os.name == "nt":
|
|
17
|
+
import msvcrt
|
|
18
|
+
handle.seek(0)
|
|
19
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
20
|
+
else:
|
|
21
|
+
import fcntl
|
|
22
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _unlock(handle: IO[str]) -> None:
|
|
26
|
+
if os.name == "nt":
|
|
27
|
+
import msvcrt
|
|
28
|
+
handle.seek(0)
|
|
29
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
30
|
+
else:
|
|
31
|
+
import fcntl
|
|
32
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@contextmanager
|
|
36
|
+
def stop_guard(root: str, sid: str) -> Iterator[None]:
|
|
37
|
+
path = Path(root) / ".code-flow/.stop.lock"
|
|
38
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
with path.open("a+", encoding="utf-8") as handle:
|
|
40
|
+
try:
|
|
41
|
+
_lock(handle)
|
|
42
|
+
except OSError as exc:
|
|
43
|
+
handle.seek(0)
|
|
44
|
+
owner = handle.read(512).strip() or "owner unavailable"
|
|
45
|
+
raise StopAlreadyRunning(f"stop_already_running: {owner}; 等待该进程结束后重试 Stop") from exc
|
|
46
|
+
try:
|
|
47
|
+
handle.seek(0)
|
|
48
|
+
handle.truncate()
|
|
49
|
+
json.dump({"pid": os.getpid(), "session_id": sid}, handle)
|
|
50
|
+
handle.flush()
|
|
51
|
+
yield
|
|
52
|
+
finally:
|
|
53
|
+
_unlock(handle)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Shared lifecycle predicate without loading the workflow runtime.
|
|
2
|
+
|
|
3
|
+
0.8 adds an explicit provisional state: a TASK whose required code checks were
|
|
4
|
+
not actually executed must never look finished. Keep the finished set narrow so
|
|
5
|
+
dependency resolution, parallel collect and archive cannot treat provisional
|
|
6
|
+
work as complete.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
FINISHED_STATUSES = ("done", "verified")
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validator engine: parse validation.yml, match triggers, execute commands.
|
|
3
|
+
|
|
4
|
+
L1 domain module. It owns the single interpretation of `.code-flow/validation.yml`
|
|
5
|
+
(four states: missing / empty / valid / parse_error), trigger matching,
|
|
6
|
+
deadline-bound execution, and the content-fingerprint result cache. Consumers:
|
|
7
|
+
`cf_validation` (validate), `cf_task_runtime` (finish), `cf_stop_hook` (Stop),
|
|
8
|
+
and `cf_task_workflow` (layer counts). Dependencies stay in L0/L1: `cf_core`,
|
|
9
|
+
`cf_exec_base`, and `cf_log`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, replace
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
import fnmatch
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import math
|
|
20
|
+
import os
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
import re
|
|
23
|
+
import time
|
|
24
|
+
from typing import Mapping, NamedTuple, Optional, Sequence
|
|
25
|
+
|
|
26
|
+
from codeflow.core import cf_log
|
|
27
|
+
from codeflow.core.cf_core import normalize_path
|
|
28
|
+
from codeflow.core.cf_exec_base import (build_argv, record_report, redact_sensitive_text, report_requested,
|
|
29
|
+
remaining_seconds, run_command, worktree_fingerprint)
|
|
30
|
+
|
|
31
|
+
MISSING = "missing"
|
|
32
|
+
EMPTY = "empty"
|
|
33
|
+
VALID = "valid"
|
|
34
|
+
PARSE_ERROR = "parse_error"
|
|
35
|
+
|
|
36
|
+
VALIDATION_CACHE_FILE = ".validation-cache.json"
|
|
37
|
+
MAX_VALIDATION_CACHE_ENTRIES = 256
|
|
38
|
+
_BRACE_RE = re.compile(r"\{([^{}]+)\}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class ValidatorConfig:
|
|
43
|
+
"""Single interpretation of .code-flow/validation.yml."""
|
|
44
|
+
|
|
45
|
+
state: str
|
|
46
|
+
validators: tuple[Mapping[str, object], ...] = ()
|
|
47
|
+
error: str = ""
|
|
48
|
+
path: str = ""
|
|
49
|
+
|
|
50
|
+
def without_heavy(self) -> "ValidatorConfig":
|
|
51
|
+
"""Return a config with only lightweight validators (Stop policy)."""
|
|
52
|
+
return replace(self, validators=tuple(item for item in self.validators if not is_heavy(item)))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ValidatorRun(NamedTuple):
|
|
56
|
+
failures: list[dict[str, object]]
|
|
57
|
+
truncated: bool
|
|
58
|
+
reused: int
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def expand_braces(pattern: str) -> list:
|
|
62
|
+
"""Expand one level of `{a,b}` alternation → list of fnmatch patterns."""
|
|
63
|
+
match = _BRACE_RE.search(pattern)
|
|
64
|
+
if not match:
|
|
65
|
+
return [pattern]
|
|
66
|
+
head, tail = pattern[: match.start()], pattern[match.end():]
|
|
67
|
+
out = []
|
|
68
|
+
for option in match.group(1).split(","):
|
|
69
|
+
out.extend(expand_braces(head + option + tail))
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def trigger_matches(trigger: str, rel_path: str) -> bool:
|
|
74
|
+
"""fnmatch with brace expansion; `**/` prefix also matches root files."""
|
|
75
|
+
rel_path = normalize_path(rel_path)
|
|
76
|
+
for pattern in expand_braces(trigger or ""):
|
|
77
|
+
candidates = [pattern]
|
|
78
|
+
if pattern.startswith("**/"):
|
|
79
|
+
candidates.append(pattern[3:])
|
|
80
|
+
if any(fnmatch.fnmatch(rel_path, candidate) for candidate in candidates):
|
|
81
|
+
return True
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _parse_error(path: str, message: str) -> ValidatorConfig:
|
|
86
|
+
cf_log.warn(f"{path}: {message}")
|
|
87
|
+
return ValidatorConfig(PARSE_ERROR, error=message, path=path)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _classify(path: str, data: object) -> ValidatorConfig:
|
|
91
|
+
if data is None or data == {}:
|
|
92
|
+
return ValidatorConfig(EMPTY, path=path)
|
|
93
|
+
if not isinstance(data, dict):
|
|
94
|
+
return _parse_error(path, "validation.yml must be a YAML mapping")
|
|
95
|
+
validators = data.get("validators")
|
|
96
|
+
if validators is None or validators == []:
|
|
97
|
+
return ValidatorConfig(EMPTY, path=path)
|
|
98
|
+
if not isinstance(validators, list):
|
|
99
|
+
return _parse_error(path, "validators must be a list")
|
|
100
|
+
for index, item in enumerate(validators, 1):
|
|
101
|
+
if not isinstance(item, Mapping):
|
|
102
|
+
return _parse_error(path, f"validator #{index} must be a mapping")
|
|
103
|
+
return ValidatorConfig(VALID, tuple(validators), path=path)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def load_validators(project_root: str) -> ValidatorConfig:
|
|
107
|
+
"""Read validation.yml into one of four explicit states (API-01)."""
|
|
108
|
+
path = os.path.join(project_root, ".code-flow", "validation.yml")
|
|
109
|
+
if not os.path.exists(path):
|
|
110
|
+
return ValidatorConfig(MISSING, path=path)
|
|
111
|
+
try:
|
|
112
|
+
import yaml
|
|
113
|
+
except ImportError as exc:
|
|
114
|
+
return _parse_error(path, f"PyYAML unavailable: {exc}")
|
|
115
|
+
try:
|
|
116
|
+
with open(path, "r", encoding="utf-8") as file:
|
|
117
|
+
data = yaml.safe_load(file)
|
|
118
|
+
except (OSError, UnicodeError, yaml.YAMLError) as exc:
|
|
119
|
+
return _parse_error(path, str(exc))
|
|
120
|
+
return _classify(path, data)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def package_script_validators(project_root: str) -> tuple[Mapping[str, object], ...]:
|
|
124
|
+
"""Existing product policy for a missing/empty validation.yml."""
|
|
125
|
+
package = Path(project_root) / "package.json"
|
|
126
|
+
if not package.is_file():
|
|
127
|
+
return ()
|
|
128
|
+
data = json.loads(package.read_text(encoding="utf-8"))
|
|
129
|
+
scripts = data.get("scripts", {}) if isinstance(data, dict) else {}
|
|
130
|
+
if not isinstance(scripts, dict):
|
|
131
|
+
raise ValueError("invalid package scripts")
|
|
132
|
+
return tuple(
|
|
133
|
+
{"name": name, "trigger": "*", "command": f"npm run {name}", "timeout": 60000}
|
|
134
|
+
for name in ("test", "lint") if isinstance(scripts.get(name), str)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def resolve_validators(
|
|
139
|
+
project_root: str,
|
|
140
|
+
) -> tuple[ValidatorConfig, tuple[Mapping[str, object], ...]]:
|
|
141
|
+
"""Config state plus the effective validators for this project.
|
|
142
|
+
|
|
143
|
+
missing/empty keep the product policy (package.json test/lint fallback);
|
|
144
|
+
parse_error returns no validators so callers must fail closed on state.
|
|
145
|
+
"""
|
|
146
|
+
config = load_validators(project_root)
|
|
147
|
+
if config.state == VALID:
|
|
148
|
+
return config, config.validators
|
|
149
|
+
if config.state == PARSE_ERROR:
|
|
150
|
+
return config, ()
|
|
151
|
+
return config, package_script_validators(project_root)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def is_heavy(validator: object) -> bool:
|
|
155
|
+
return isinstance(validator, Mapping) and validator.get("heavy") is True
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _validator_failure(validator: Mapping[str, object], detail: str,
|
|
159
|
+
incomplete: bool = False) -> dict[str, object]:
|
|
160
|
+
result: dict[str, object] = {
|
|
161
|
+
"name": validator.get("name", "validator"),
|
|
162
|
+
"on_fail": validator.get("on_fail", ""),
|
|
163
|
+
"detail": detail,
|
|
164
|
+
}
|
|
165
|
+
if incomplete:
|
|
166
|
+
result["incomplete"] = True
|
|
167
|
+
return result
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _validator_argv(root: str, validator: Mapping[str, object], matched: list,
|
|
171
|
+
strict: bool) -> list:
|
|
172
|
+
existing = [name for name in matched if os.path.isfile(os.path.join(root, name))]
|
|
173
|
+
template = str(validator.get("command", ""))
|
|
174
|
+
if "{files}" in template and not existing:
|
|
175
|
+
return []
|
|
176
|
+
argv = build_argv(template, existing)
|
|
177
|
+
if not argv and strict:
|
|
178
|
+
raise ValueError("validator command missing")
|
|
179
|
+
return argv
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _tool_missing(detail: str) -> bool:
|
|
183
|
+
"""Recognize 'tool not installed' output so template validators (mypy, npx …)
|
|
184
|
+
do not block projects that have not installed the optional tool yet."""
|
|
185
|
+
lowered = detail.lower()
|
|
186
|
+
markers = (
|
|
187
|
+
"no module named",
|
|
188
|
+
"modulenotfounderror",
|
|
189
|
+
"command not found",
|
|
190
|
+
"is not recognized as an internal or external command",
|
|
191
|
+
"could not determine executable to run",
|
|
192
|
+
"not found in $path",
|
|
193
|
+
)
|
|
194
|
+
return any(marker in lowered for marker in markers)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _validator_result(root: str, validator: Mapping[str, object], argv: list, sid: str,
|
|
198
|
+
deadline: Optional[float], strict: bool,
|
|
199
|
+
timings: Optional[dict[str, float]] = None) -> tuple[list, bool, bool]:
|
|
200
|
+
try:
|
|
201
|
+
timeout = float(validator.get("timeout", 30000)) / 1000.0
|
|
202
|
+
except (ValueError, TypeError):
|
|
203
|
+
if strict:
|
|
204
|
+
return [_validator_failure(validator, "invalid validator timeout")], False, False
|
|
205
|
+
timeout = 30.0
|
|
206
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
207
|
+
return [_validator_failure(validator, "invalid validator timeout")], False, False
|
|
208
|
+
outcome = run_command(argv, root, timeout, deadline)
|
|
209
|
+
if timings is not None:
|
|
210
|
+
timings["execution_ms"] += float(outcome.get("execution_ms", 0))
|
|
211
|
+
reused = bool(outcome.get("execution_reused"))
|
|
212
|
+
passed = outcome["status"] == "ok" and outcome["returncode"] == 0
|
|
213
|
+
if outcome["status"] == "deadline_exceeded":
|
|
214
|
+
return [_validator_failure(validator, "预算耗尽未执行", True)], True, reused
|
|
215
|
+
timed_out = outcome["status"] == "timeout"
|
|
216
|
+
detail = (f"超时(>{timeout:.0f}s),未完成" if timed_out else
|
|
217
|
+
(str(outcome.get("stdout", "")) + str(outcome.get("stderr", ""))).strip()[-400:])
|
|
218
|
+
if outcome["status"] == "spawn_error":
|
|
219
|
+
if validator.get("skip_if_missing") is True:
|
|
220
|
+
cf_log.degrade(root, "stop_check", f"{validator.get('name')}:tool_missing", sid)
|
|
221
|
+
return [], False, reused
|
|
222
|
+
cf_log.degrade(root, "stop_check", f"{validator.get('name')}:{outcome['stderr']}", sid)
|
|
223
|
+
if not strict:
|
|
224
|
+
return [], False, reused
|
|
225
|
+
elif not passed and not timed_out and validator.get("skip_if_missing") is True and _tool_missing(detail):
|
|
226
|
+
cf_log.degrade(root, "stop_check", f"{validator.get('name')}:tool_missing", sid)
|
|
227
|
+
return [], False, reused
|
|
228
|
+
cf_log.append_event(root, "stop_check",
|
|
229
|
+
{"trigger": validator.get("trigger", ""),
|
|
230
|
+
"cmd": Path(argv[0]).name, "passed": passed}, sid)
|
|
231
|
+
return ([] if passed else [_validator_failure(validator, redact_sensitive_text(detail))]), timed_out, reused
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _tree_fingerprint(project_root: str, validators: Sequence[object]) -> str:
|
|
235
|
+
"""工作树内容指纹(内容寻址,忽略 .code-flow),与提交状态无关。
|
|
236
|
+
|
|
237
|
+
已提交内容取 blob SHA,未提交/未跟踪内容用工作区文件哈希补齐;未跟踪文件
|
|
238
|
+
只纳入命中任一 validator trigger 的路径(构建/测试产物不使缓存失效)。
|
|
239
|
+
同一内容在提交前后得到相同指纹;返回 "" 表示无法判定(禁用缓存)。
|
|
240
|
+
"""
|
|
241
|
+
triggers = [str(item.get("trigger", "")) for item in validators if isinstance(item, Mapping)]
|
|
242
|
+
if triggers:
|
|
243
|
+
untracked_filter = lambda path: any(trigger_matches(trigger, path) for trigger in triggers)
|
|
244
|
+
else:
|
|
245
|
+
untracked_filter = None
|
|
246
|
+
return worktree_fingerprint(project_root, untracked_filter)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _validation_cache_path(project_root: str) -> Path:
|
|
250
|
+
return Path(project_root) / ".code-flow" / VALIDATION_CACHE_FILE
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _load_validation_cache(project_root: str) -> dict:
|
|
254
|
+
try:
|
|
255
|
+
data = json.loads(_validation_cache_path(project_root).read_text(encoding="utf-8"))
|
|
256
|
+
except (OSError, ValueError):
|
|
257
|
+
return {}
|
|
258
|
+
return data if isinstance(data, dict) else {}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _save_validation_cache(project_root: str, cache: dict) -> None:
|
|
262
|
+
if len(cache) > MAX_VALIDATION_CACHE_ENTRIES:
|
|
263
|
+
ordered = sorted(
|
|
264
|
+
cache.items(),
|
|
265
|
+
key=lambda item: str(item[1].get("at", "")) if isinstance(item[1], dict) else "",
|
|
266
|
+
)
|
|
267
|
+
cache = dict(ordered[-MAX_VALIDATION_CACHE_ENTRIES:])
|
|
268
|
+
try:
|
|
269
|
+
path = _validation_cache_path(project_root)
|
|
270
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
271
|
+
path.write_text(json.dumps(cache, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
272
|
+
except OSError:
|
|
273
|
+
return # 缓存写入失败不影响验证结论
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _cache_key(argv: Sequence[str]) -> str:
|
|
277
|
+
return hashlib.sha256(json.dumps({"argv": list(argv)}, ensure_ascii=False).encode()).hexdigest()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
_CACHE_ENV_KEYS = frozenset({
|
|
281
|
+
"DATABASE_URL", "REDIS_URL", "INTERNAL_SERVICE_TOKEN", "ENV", "LOG_DIR", "LOG_LEVEL",
|
|
282
|
+
"ARTIFACT_ROOT", "SKILL_CACHE_ROOT", "MIGRATIONS_DIR", "DEFAULT_TENANT_ID", "HOME",
|
|
283
|
+
"TMPDIR", "PYTHONPATH", "PYTHONHASHSEED", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "CI",
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
_CACHE_ENV_PREFIXES = (
|
|
287
|
+
"MUAD_", "DATABASE_", "REDIS_", "POSTGRES", "INTERNAL_", "ARTIFACT_", "SKILL_",
|
|
288
|
+
"MODEL_", "CAPACITY_", "WECOM_", "CHANNEL_", "GATEWAY_", "RUNTIME_", "WORKER_",
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _execution_environment() -> dict[str, str]:
|
|
293
|
+
"""Stable, test-relevant environment fingerprint.
|
|
294
|
+
|
|
295
|
+
Shell/terminal bookkeeping (SHLVL, PATH, UV_*, PWD, ...) differs between a
|
|
296
|
+
direct invocation and the `bash -lc` wrapper used by spec verifiers without
|
|
297
|
+
changing test inputs, so only known test-relevant keys/prefixes participate.
|
|
298
|
+
"""
|
|
299
|
+
return {
|
|
300
|
+
key: value for key, value in os.environ.items()
|
|
301
|
+
if key in _CACHE_ENV_KEYS or key.startswith(_CACHE_ENV_PREFIXES)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _execution_observation(root: str, validator: Mapping[str, object], argv: Sequence[str],
|
|
306
|
+
fingerprint: str, entry: object) -> dict[str, str]:
|
|
307
|
+
"""Describe this execution without exposing commands or environment values."""
|
|
308
|
+
payload = {"argv": list(argv), "cwd": str(Path(root).resolve()), "validator": dict(validator),
|
|
309
|
+
"environment": _execution_environment(), "input_fingerprint": fingerprint}
|
|
310
|
+
identity = hashlib.sha256(json.dumps(payload, sort_keys=True, ensure_ascii=False,
|
|
311
|
+
default=str).encode()).hexdigest()
|
|
312
|
+
prior = entry.get("fingerprint") if isinstance(entry, dict) else None
|
|
313
|
+
previous_identity = entry.get("execution_identity") if isinstance(entry, dict) else None
|
|
314
|
+
category = ("unproven" if not fingerprint else "no_baseline" if prior is None else
|
|
315
|
+
"worktree_changed" if prior != fingerprint else
|
|
316
|
+
"execution_context_changed" if previous_identity != identity else "unchanged")
|
|
317
|
+
return {"identity": identity, "input_change_category": category}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _run_one(project_root: str, validator: object, files: Sequence[str], sid: str,
|
|
321
|
+
deadline: Optional[float], strict: bool, fingerprint: str,
|
|
322
|
+
cache_data: dict, dirty: list[bool], timings: Optional[dict[str, float]] = None,
|
|
323
|
+
observations: Optional[list[dict[str, str]]] = None) -> ValidatorRun:
|
|
324
|
+
if not isinstance(validator, Mapping):
|
|
325
|
+
failures = [_validator_failure({}, "invalid validator record")] if strict else []
|
|
326
|
+
return ValidatorRun(failures, False, 0)
|
|
327
|
+
matched = [path for path in files if trigger_matches(validator.get("trigger", ""), path)]
|
|
328
|
+
if not matched:
|
|
329
|
+
return ValidatorRun([], False, 0)
|
|
330
|
+
remaining = remaining_seconds(deadline)
|
|
331
|
+
if remaining is not None and remaining <= 0:
|
|
332
|
+
return ValidatorRun([_validator_failure(validator, "预算耗尽未执行", True)], True, 0)
|
|
333
|
+
try:
|
|
334
|
+
argv = _validator_argv(project_root, validator, matched, strict)
|
|
335
|
+
except ValueError as exc:
|
|
336
|
+
return ValidatorRun([_validator_failure(validator, f"validator 配置错误: {exc}")], False, 0)
|
|
337
|
+
if not argv:
|
|
338
|
+
return ValidatorRun([], False, 0)
|
|
339
|
+
key = _cache_key(argv)
|
|
340
|
+
entry = cache_data.get(key)
|
|
341
|
+
observation = _execution_observation(project_root, validator, argv, fingerprint, entry)
|
|
342
|
+
if observations is not None:
|
|
343
|
+
observations.append(observation)
|
|
344
|
+
if fingerprint:
|
|
345
|
+
if (isinstance(entry, dict) and entry.get("fingerprint") == fingerprint
|
|
346
|
+
and entry.get("execution_identity") == observation["identity"]):
|
|
347
|
+
return ValidatorRun([], False, 1)
|
|
348
|
+
errors, incomplete, reused = _validator_result(project_root, validator, argv, sid, deadline, strict, timings)
|
|
349
|
+
if fingerprint and not errors and not incomplete:
|
|
350
|
+
cache_data[key] = {
|
|
351
|
+
"fingerprint": fingerprint,
|
|
352
|
+
"at": datetime.now(timezone.utc).isoformat(),
|
|
353
|
+
}
|
|
354
|
+
cache_data[key]["execution_identity"] = observation["identity"]
|
|
355
|
+
dirty[0] = True
|
|
356
|
+
return ValidatorRun(errors, incomplete, int(reused))
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def run_validators(
|
|
360
|
+
project_root: str,
|
|
361
|
+
config: ValidatorConfig,
|
|
362
|
+
files: Sequence[str],
|
|
363
|
+
sid: str,
|
|
364
|
+
deadline: Optional[float] = None,
|
|
365
|
+
*,
|
|
366
|
+
strict: bool = False,
|
|
367
|
+
cache: bool = True,
|
|
368
|
+
timings: Optional[dict[str, float]] = None,
|
|
369
|
+
observations: Optional[list[dict[str, str]]] = None,
|
|
370
|
+
) -> ValidatorRun:
|
|
371
|
+
"""Match validators, execute argv under one deadline, report unrun work.
|
|
372
|
+
|
|
373
|
+
`cache` reuses passing results when the worktree content fingerprint and
|
|
374
|
+
argv are unchanged (failures are never cached). Returns (failures,
|
|
375
|
+
truncated, reused).
|
|
376
|
+
"""
|
|
377
|
+
validators = list(config.validators)
|
|
378
|
+
if report_requested():
|
|
379
|
+
if timings is None:
|
|
380
|
+
timings = {"planning_ms": 0.0, "preflight_ms": 0.0, "execution_ms": 0.0, "evidence_ms": 0.0}
|
|
381
|
+
if observations is None:
|
|
382
|
+
observations = []
|
|
383
|
+
started = time.monotonic()
|
|
384
|
+
fingerprint = _tree_fingerprint(project_root, validators) if cache and validators else ""
|
|
385
|
+
cache_data = _load_validation_cache(project_root) if fingerprint else {}
|
|
386
|
+
if timings is not None:
|
|
387
|
+
timings["preflight_ms"] += (time.monotonic() - started) * 1000
|
|
388
|
+
dirty = [False]
|
|
389
|
+
failures: list[dict[str, object]] = []
|
|
390
|
+
truncated = False
|
|
391
|
+
reused = 0
|
|
392
|
+
for validator in validators:
|
|
393
|
+
outcome = _run_one(project_root, validator, files, sid, deadline, strict,
|
|
394
|
+
fingerprint, cache_data, dirty, timings, observations)
|
|
395
|
+
failures.extend(outcome.failures)
|
|
396
|
+
truncated = truncated or outcome.truncated
|
|
397
|
+
reused += outcome.reused
|
|
398
|
+
if dirty[0]:
|
|
399
|
+
started = time.monotonic()
|
|
400
|
+
_save_validation_cache(project_root, cache_data)
|
|
401
|
+
if timings is not None:
|
|
402
|
+
timings["evidence_ms"] += (time.monotonic() - started) * 1000
|
|
403
|
+
if report_requested():
|
|
404
|
+
record_report({"kind": "validators", "scope": {"files": list(files)}, "timings": dict(timings or {}),
|
|
405
|
+
"execution_identities": [item["identity"] for item in observations or []],
|
|
406
|
+
"input_change_category": [item["input_change_category"] for item in observations or []],
|
|
407
|
+
"reused": reused, "selected": len(observations or [])})
|
|
408
|
+
return ValidatorRun(failures, truncated, reused)
|