@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,301 @@
|
|
|
1
|
+
"""Bind and refresh Spec Context against real Specs and task artifacts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import time
|
|
9
|
+
from typing import Optional, Sequence
|
|
10
|
+
|
|
11
|
+
from codeflow.core.cf_core import phase_timing
|
|
12
|
+
from codeflow.core.cf_log import warn
|
|
13
|
+
from codeflow.spec.cf_spec_context_model import (
|
|
14
|
+
BindingInput, ContextError, DriftChange, DriftResult, RuleBinding, RuleStageStatus,
|
|
15
|
+
SpecBinding, SpecContext, now_iso,
|
|
16
|
+
)
|
|
17
|
+
from codeflow.spec.cf_spec_metadata import SpecMetadata, SpecRule, load_spec_metadata
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
FILE_HASH_CACHE: dict[str, tuple[int, int, int, str]] = {}
|
|
21
|
+
_PERSISTENT_HASH_CACHE: dict[str, dict[str, object]] = {}
|
|
22
|
+
_PERSISTENT_HASH_PATHS: set[str] = set()
|
|
23
|
+
_MAX_HASH_CACHE_ENTRIES = 4096
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _binding_from_input(selection: BindingInput) -> SpecBinding:
|
|
27
|
+
if not selection.selected_by.strip() or not selection.reason.strip():
|
|
28
|
+
raise ContextError("invalid_binding", "selection", "selected_by/reason 必须非空")
|
|
29
|
+
candidate = selection.candidate
|
|
30
|
+
verifier_by_rule = {item.rule: item for item in candidate.metadata.verifiers}
|
|
31
|
+
rules: list[RuleBinding] = []
|
|
32
|
+
for rule in candidate.metadata.rules:
|
|
33
|
+
if rule.enforcement == "informational":
|
|
34
|
+
continue
|
|
35
|
+
enforcement = "advisory" if candidate.metadata.enforcement == "advisory" else rule.enforcement
|
|
36
|
+
statuses = {
|
|
37
|
+
stage: RuleStageStatus("pending", (), None, ()) for stage in candidate.metadata.stages
|
|
38
|
+
}
|
|
39
|
+
verifier = verifier_by_rule.get(rule.ref)
|
|
40
|
+
verifier_ref = f"{candidate.spec_id}#{rule.ref}" if verifier is not None else "advisory:none"
|
|
41
|
+
rules.append(
|
|
42
|
+
RuleBinding(
|
|
43
|
+
rule.ref, rule.text, rule.text_sha256, enforcement, verifier_ref, statuses,
|
|
44
|
+
verifier.stage if verifier is not None else "code",
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
return SpecBinding(
|
|
48
|
+
candidate.spec_id,
|
|
49
|
+
candidate.path,
|
|
50
|
+
candidate.metadata.hashes,
|
|
51
|
+
selection.selected_by.strip(),
|
|
52
|
+
selection.reason.strip(),
|
|
53
|
+
candidate.metadata.enforcement,
|
|
54
|
+
candidate.metadata.stages,
|
|
55
|
+
tuple(rules),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _with_declared_stages(rule: RuleBinding, stages: Sequence[str]) -> RuleBinding:
|
|
60
|
+
"""兼容补齐:绑定旧版本时声明的 stages 缺状态,按声明补 pending。"""
|
|
61
|
+
missing = [stage for stage in stages if stage not in rule.stage_status]
|
|
62
|
+
if not missing:
|
|
63
|
+
return rule
|
|
64
|
+
statuses = dict(rule.stage_status)
|
|
65
|
+
for stage in missing:
|
|
66
|
+
statuses[stage] = RuleStageStatus("pending", (), None, ())
|
|
67
|
+
return replace(rule, stage_status=statuses)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _rule_from_metadata(spec_id: str, metadata: SpecMetadata, rule: SpecRule) -> RuleBinding:
|
|
71
|
+
enforcement = "advisory" if metadata.enforcement == "advisory" else rule.enforcement
|
|
72
|
+
statuses = {stage: RuleStageStatus("pending", (), None, ()) for stage in metadata.stages}
|
|
73
|
+
verifier = next((item for item in metadata.verifiers if item.rule == rule.ref), None)
|
|
74
|
+
verifier_ref = f"{spec_id}#{rule.ref}" if verifier is not None else "advisory:none"
|
|
75
|
+
return RuleBinding(
|
|
76
|
+
rule.ref, rule.text, rule.text_sha256, enforcement, verifier_ref, statuses,
|
|
77
|
+
verifier.stage if verifier is not None else "code",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def bind_specs(context: SpecContext, selections: Sequence[BindingInput]) -> SpecContext:
|
|
82
|
+
bindings = {binding.spec_id: binding for binding in context.bindings}
|
|
83
|
+
for selection in selections:
|
|
84
|
+
incoming = _binding_from_input(selection)
|
|
85
|
+
existing = bindings.get(incoming.spec_id)
|
|
86
|
+
if existing is not None and existing.hashes != incoming.hashes:
|
|
87
|
+
raise ContextError(
|
|
88
|
+
"stale_binding",
|
|
89
|
+
"selection",
|
|
90
|
+
f"{incoming.spec_id} 已绑定版本发生变化,请先 refresh",
|
|
91
|
+
)
|
|
92
|
+
if existing is not None:
|
|
93
|
+
incoming = replace(
|
|
94
|
+
existing,
|
|
95
|
+
selected_by=incoming.selected_by,
|
|
96
|
+
reason=incoming.reason,
|
|
97
|
+
enforcement=incoming.enforcement,
|
|
98
|
+
stages=incoming.stages,
|
|
99
|
+
)
|
|
100
|
+
bindings[incoming.spec_id] = incoming
|
|
101
|
+
ordered = tuple(bindings[key] for key in sorted(bindings))
|
|
102
|
+
if ordered == context.bindings:
|
|
103
|
+
return context
|
|
104
|
+
return replace(context, updated_at=now_iso(), bindings=ordered)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def refresh_missing_specs(context: SpecContext, root: str) -> SpecContext:
|
|
108
|
+
spec_root = Path(root) / ".code-flow" / "specs"
|
|
109
|
+
bindings: list[SpecBinding] = []
|
|
110
|
+
for binding in context.bindings:
|
|
111
|
+
if (spec_root / binding.path).is_file():
|
|
112
|
+
bindings.append(binding)
|
|
113
|
+
continue
|
|
114
|
+
rules = []
|
|
115
|
+
for rule in binding.rules:
|
|
116
|
+
stages = {stage: replace(status, status="stale") for stage, status in rule.stage_status.items()}
|
|
117
|
+
rules.append(replace(rule, stage_status=stages))
|
|
118
|
+
bindings.append(replace(binding, status="missing", rules=tuple(rules)))
|
|
119
|
+
return replace(context, updated_at=now_iso(), bindings=tuple(bindings))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _stale_rule(rule: RuleBinding) -> RuleBinding:
|
|
123
|
+
statuses = {stage: replace(status, status="stale") for stage, status in rule.stage_status.items()}
|
|
124
|
+
return replace(rule, stage_status=statuses)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _removed_rule(rule: RuleBinding) -> RuleBinding:
|
|
128
|
+
statuses = {
|
|
129
|
+
stage: status
|
|
130
|
+
if status.status in ("not_applicable", "waived") and status.decision is not None
|
|
131
|
+
else replace(status, status="stale")
|
|
132
|
+
for stage, status in rule.stage_status.items()
|
|
133
|
+
}
|
|
134
|
+
return replace(rule, stage_status=statuses)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _load_hash_cache(cache_path: Optional[Path]) -> None:
|
|
138
|
+
if cache_path is None or str(cache_path) in _PERSISTENT_HASH_PATHS:
|
|
139
|
+
return
|
|
140
|
+
try:
|
|
141
|
+
loaded = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
142
|
+
if not isinstance(loaded, dict):
|
|
143
|
+
raise ValueError("expected an object")
|
|
144
|
+
_PERSISTENT_HASH_CACHE.update({str(k): v for k, v in loaded.items() if isinstance(v, dict)})
|
|
145
|
+
except FileNotFoundError:
|
|
146
|
+
loaded = {}
|
|
147
|
+
except (OSError, ValueError) as exc:
|
|
148
|
+
warn(f"artifact hash cache unavailable; rebuilding: {exc}")
|
|
149
|
+
_PERSISTENT_HASH_PATHS.add(str(cache_path))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _store_hash_cache(cache_path: Optional[Path], key: str, signature: tuple[int, int, int], digest: str) -> None:
|
|
153
|
+
if cache_path is None:
|
|
154
|
+
return
|
|
155
|
+
_PERSISTENT_HASH_CACHE[key] = dict(zip(("mtime_ns", "size", "ino", "sha256"), (*signature, digest)))
|
|
156
|
+
while len(_PERSISTENT_HASH_CACHE) > _MAX_HASH_CACHE_ENTRIES:
|
|
157
|
+
_PERSISTENT_HASH_CACHE.pop(next(iter(_PERSISTENT_HASH_CACHE)))
|
|
158
|
+
try:
|
|
159
|
+
temporary = cache_path.with_suffix(".json.tmp")
|
|
160
|
+
temporary.write_text(json.dumps(_PERSISTENT_HASH_CACHE, ensure_ascii=False, sort_keys=True), encoding="utf-8")
|
|
161
|
+
temporary.replace(cache_path)
|
|
162
|
+
except OSError as exc:
|
|
163
|
+
warn(f"artifact hash cache could not be saved: {exc}")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def file_sha256(path: Path) -> str:
|
|
167
|
+
try:
|
|
168
|
+
stat = path.stat()
|
|
169
|
+
key = str(path)
|
|
170
|
+
signature = (stat.st_mtime_ns, stat.st_size, stat.st_ino)
|
|
171
|
+
cached = FILE_HASH_CACHE.get(key)
|
|
172
|
+
if cached is not None and cached[:3] == signature:
|
|
173
|
+
return cached[3]
|
|
174
|
+
cache_path = next((parent / ".artifact-hash-cache.json" for parent in path.parents
|
|
175
|
+
if parent.name == ".code-flow"), None)
|
|
176
|
+
_load_hash_cache(cache_path)
|
|
177
|
+
persistent = _PERSISTENT_HASH_CACHE.get(key)
|
|
178
|
+
if isinstance(persistent, dict) and tuple(persistent.get(k) for k in ("mtime_ns", "size", "ino")) == signature:
|
|
179
|
+
digest = persistent.get("sha256")
|
|
180
|
+
if isinstance(digest, str) and digest:
|
|
181
|
+
FILE_HASH_CACHE[key] = (*signature, digest)
|
|
182
|
+
return digest
|
|
183
|
+
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
184
|
+
FILE_HASH_CACHE[key] = (*signature, digest)
|
|
185
|
+
_store_hash_cache(cache_path, key, signature, digest)
|
|
186
|
+
return digest
|
|
187
|
+
except OSError as exc:
|
|
188
|
+
raise ContextError("artifact_read_error", "artifact", str(exc), str(path)) from exc
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _refresh_artifacts(
|
|
192
|
+
spec_id: str, rule: RuleBinding, artifact_root: Path
|
|
193
|
+
) -> tuple[RuleBinding, tuple[DriftChange, ...]]:
|
|
194
|
+
statuses = dict(rule.stage_status)
|
|
195
|
+
changes: list[DriftChange] = []
|
|
196
|
+
for stage, status in rule.stage_status.items():
|
|
197
|
+
for reference in status.refs:
|
|
198
|
+
path = artifact_root / reference.artifact
|
|
199
|
+
current = file_sha256(path) if path.is_file() else "missing"
|
|
200
|
+
if current == reference.artifact_sha256:
|
|
201
|
+
continue
|
|
202
|
+
if status.status == "applied" and current != "missing":
|
|
203
|
+
statuses[stage] = replace(
|
|
204
|
+
status,
|
|
205
|
+
refs=tuple(
|
|
206
|
+
replace(item, artifact_sha256=current) if item is reference else item
|
|
207
|
+
for item in status.refs
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
else:
|
|
211
|
+
statuses[stage] = replace(status, status="stale")
|
|
212
|
+
changes.append(
|
|
213
|
+
DriftChange("artifact_changed", spec_id, rule.ref, reference.artifact_sha256, current, (stage,))
|
|
214
|
+
)
|
|
215
|
+
break
|
|
216
|
+
return replace(rule, stage_status=statuses), tuple(changes)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _refresh_existing_rule(
|
|
220
|
+
binding: SpecBinding, existing: RuleBinding, current: SpecRule, metadata: SpecMetadata
|
|
221
|
+
) -> tuple[RuleBinding, Optional[DriftChange]]:
|
|
222
|
+
enforcement = "advisory" if metadata.enforcement == "advisory" else current.enforcement
|
|
223
|
+
verifier = next((item for item in metadata.verifiers if item.rule == current.ref), None)
|
|
224
|
+
verifier_ref = f"{binding.spec_id}#{current.ref}" if verifier is not None else "advisory:none"
|
|
225
|
+
updated = replace(
|
|
226
|
+
existing,
|
|
227
|
+
summary=current.text,
|
|
228
|
+
text_sha256=current.text_sha256,
|
|
229
|
+
enforcement=enforcement,
|
|
230
|
+
verifier_ref=verifier_ref,
|
|
231
|
+
verifier_stage=verifier.stage if verifier is not None else "code",
|
|
232
|
+
)
|
|
233
|
+
if existing.text_sha256 == current.text_sha256:
|
|
234
|
+
return updated, None
|
|
235
|
+
change = DriftChange(
|
|
236
|
+
"rule_changed", binding.spec_id, current.ref, existing.text_sha256, current.text_sha256, tuple(existing.stage_status)
|
|
237
|
+
)
|
|
238
|
+
return _stale_rule(updated), change
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _refresh_binding(
|
|
242
|
+
binding: SpecBinding, spec_root: Path, artifact_root: Path
|
|
243
|
+
) -> tuple[SpecBinding, tuple[DriftChange, ...]]:
|
|
244
|
+
path = spec_root / binding.path
|
|
245
|
+
if not path.is_file():
|
|
246
|
+
stale = replace(binding, status="missing", rules=tuple(_stale_rule(rule) for rule in binding.rules))
|
|
247
|
+
change = DriftChange("spec_missing", binding.spec_id, "", binding.hashes.file_sha256, "missing", binding.stages)
|
|
248
|
+
return stale, (change,)
|
|
249
|
+
metadata = load_spec_metadata(str(path))
|
|
250
|
+
existing = {rule.ref: rule for rule in binding.rules}
|
|
251
|
+
rules: list[RuleBinding] = []
|
|
252
|
+
changes: list[DriftChange] = []
|
|
253
|
+
if metadata.hashes.metadata_sha256 != binding.hashes.metadata_sha256:
|
|
254
|
+
changes.append(
|
|
255
|
+
DriftChange("metadata_changed", binding.spec_id, "", binding.hashes.metadata_sha256, metadata.hashes.metadata_sha256, ())
|
|
256
|
+
)
|
|
257
|
+
for current in metadata.rules:
|
|
258
|
+
if current.enforcement == "informational":
|
|
259
|
+
continue
|
|
260
|
+
prior = existing.pop(current.ref, None)
|
|
261
|
+
if prior is None:
|
|
262
|
+
rule = _rule_from_metadata(binding.spec_id, metadata, current)
|
|
263
|
+
changes.append(DriftChange("rule_added", binding.spec_id, current.ref, "", current.text_sha256, metadata.stages))
|
|
264
|
+
else:
|
|
265
|
+
rule, change = _refresh_existing_rule(binding, prior, current, metadata)
|
|
266
|
+
if change is not None:
|
|
267
|
+
changes.append(change)
|
|
268
|
+
rule = _with_declared_stages(rule, metadata.stages)
|
|
269
|
+
rule, artifact_changes = _refresh_artifacts(binding.spec_id, rule, artifact_root)
|
|
270
|
+
rules.append(rule)
|
|
271
|
+
changes.extend(artifact_changes)
|
|
272
|
+
for removed in existing.values():
|
|
273
|
+
rules.append(_removed_rule(removed))
|
|
274
|
+
changes.append(DriftChange("rule_removed", binding.spec_id, removed.ref, removed.text_sha256, "missing", tuple(removed.stage_status)))
|
|
275
|
+
updated = replace(binding, hashes=metadata.hashes, enforcement=metadata.enforcement, stages=metadata.stages, rules=tuple(rules), status="active")
|
|
276
|
+
return updated, tuple(changes)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def refresh_context(context: SpecContext, root: str, artifact_root: Optional[str] = None) -> DriftResult:
|
|
280
|
+
started = time.monotonic()
|
|
281
|
+
specs = Path(root) / ".code-flow" / "specs"
|
|
282
|
+
artifacts = Path(artifact_root) if artifact_root is not None else Path(root)
|
|
283
|
+
bindings: list[SpecBinding] = []
|
|
284
|
+
changes: list[DriftChange] = []
|
|
285
|
+
for binding in context.bindings:
|
|
286
|
+
phase_started = time.monotonic()
|
|
287
|
+
updated, binding_changes = _refresh_binding(binding, specs, artifacts)
|
|
288
|
+
phase_timing(f"context.refresh.{binding.spec_id}", phase_started)
|
|
289
|
+
bindings.append(updated)
|
|
290
|
+
changes.extend(binding_changes)
|
|
291
|
+
refreshed = replace(context, updated_at=now_iso(), bindings=tuple(bindings))
|
|
292
|
+
phase_timing("context.refresh.total", started)
|
|
293
|
+
if refreshed.bindings == context.bindings:
|
|
294
|
+
return DriftResult(context, tuple(changes))
|
|
295
|
+
return DriftResult(refreshed, tuple(changes))
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def diff_spec_hashes(context: SpecContext, root: str) -> DriftResult:
|
|
299
|
+
return refresh_context(context, root)
|
|
300
|
+
|
|
301
|
+
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Platform-neutral edit routing and compliance services."""
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Sequence
|
|
5
|
+
|
|
6
|
+
from codeflow.core import cf_log
|
|
7
|
+
from codeflow.acceptance.cf_checks import load_check_state, load_spec_checks, record_hits, run_checks, save_check_state
|
|
8
|
+
from codeflow.core.cf_core import (build_effective_mapping, effective_project_root, is_code_file, load_config,
|
|
9
|
+
match_domains, normalize_path, normalize_spec_entry, resolve_enforcement, resolve_quality_loop)
|
|
10
|
+
from codeflow.spec.cf_injection import injection_text
|
|
11
|
+
from codeflow.spec.cf_spec_router import RouterError, route_prompt
|
|
12
|
+
|
|
13
|
+
FEEDBACK_HINT = '如认为误报,直接告诉我"这是误报",我会标记忽略。'
|
|
14
|
+
_REPORTED_KEY = "_reported"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _collect_checks(project_root: str, domains: list[str], mapping: dict[str, object]) -> list[dict[str, object]]:
|
|
18
|
+
"""Aggregate checks of all constraint specs in matched domains.
|
|
19
|
+
|
|
20
|
+
Each check is annotated with its source spec path. Checks apply whenever
|
|
21
|
+
their own `files` glob matches — independent of injection tag gating.
|
|
22
|
+
"""
|
|
23
|
+
specs_root = os.path.join(project_root, ".code-flow", "specs")
|
|
24
|
+
collected = []
|
|
25
|
+
seen_specs = set()
|
|
26
|
+
for domain in domains:
|
|
27
|
+
for entry in (mapping.get(domain) or {}).get("specs") or []:
|
|
28
|
+
cfg = normalize_spec_entry(entry)
|
|
29
|
+
rel = cfg.get("path")
|
|
30
|
+
if not rel or rel in seen_specs or not cfg.get("tags"):
|
|
31
|
+
continue
|
|
32
|
+
seen_specs.add(rel)
|
|
33
|
+
checks, _ = load_spec_checks(os.path.join(specs_root, rel))
|
|
34
|
+
for check in checks:
|
|
35
|
+
item = dict(check)
|
|
36
|
+
item["spec"] = rel
|
|
37
|
+
collected.append(item)
|
|
38
|
+
return collected
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _session_reported(state: dict[str, object], sid: str) -> set[str]:
|
|
42
|
+
session = state.get(_REPORTED_KEY) or {}
|
|
43
|
+
if session.get("sid") != sid:
|
|
44
|
+
return set()
|
|
45
|
+
return set(session.get("keys") or [])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _save_reported(project_root: str, state: dict[str, object], sid: str, keys: set[str]) -> None:
|
|
49
|
+
state[_REPORTED_KEY] = {"sid": sid, "keys": sorted(keys)}
|
|
50
|
+
save_check_state(project_root, state)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _feedback_text(violations: list[dict[str, object]]) -> str:
|
|
54
|
+
lines = ["## Spec 合规反馈 (auto-check)", ""]
|
|
55
|
+
for v in violations:
|
|
56
|
+
lines.append(f"⚠ {v['message']}(规则: {v['spec']}#{v['check_id']})")
|
|
57
|
+
lines.append(f" 违规行 {v['line_no']}: {v['line']}")
|
|
58
|
+
lines.append("")
|
|
59
|
+
lines.append("请修正以上违规后再继续。" + FEEDBACK_HINT)
|
|
60
|
+
return "\n".join(lines)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _routing_result(root: str, relative: str, sid: str) -> tuple[str, tuple[str, ...], bool]:
|
|
65
|
+
return _route_edits(root, (relative,), sid)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _route_edits(root: str, paths: Sequence[str], sid: str) -> tuple[str, tuple[str, ...], bool]:
|
|
69
|
+
try:
|
|
70
|
+
result = route_prompt(root, paths, sid, check_scope=True)
|
|
71
|
+
except RouterError as exc:
|
|
72
|
+
return f"SPEC_WORKFLOW_BLOCKED [{exc.code}]: {exc}. Run cf-spec doctor before continuing.", (), True
|
|
73
|
+
text = injection_text(root, sid, result)
|
|
74
|
+
emitted = tuple(result.specs) if text else ()
|
|
75
|
+
if result.expanded_specs:
|
|
76
|
+
warning = f"⚠ 该路径引入未绑定 required Spec:{', '.join(result.expanded_specs)} — 建议先 refresh Context / Plan 再继续编辑。"
|
|
77
|
+
text = "\n\n".join(part for part in (warning, text) if part)
|
|
78
|
+
return text, emitted, False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def prepare_edit(session_root: str, path: str, sid: str, tool: str) -> tuple[str, bool]:
|
|
82
|
+
return prepare_edits(session_root, (path,), sid, tool)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def prepare_edits(session_root: str, paths: Sequence[str], sid: str, tool: str) -> tuple[str, bool]:
|
|
86
|
+
groups: dict[str, list[str]] = {}
|
|
87
|
+
for path in paths:
|
|
88
|
+
absolute = path if os.path.isabs(path) else os.path.join(session_root, path)
|
|
89
|
+
root = effective_project_root(session_root, absolute)
|
|
90
|
+
relative = normalize_path(os.path.relpath(absolute, root))
|
|
91
|
+
if relative not in groups.setdefault(root, []):
|
|
92
|
+
groups[root].append(relative)
|
|
93
|
+
feedback = []
|
|
94
|
+
rejected = False
|
|
95
|
+
for root, relative_paths in groups.items():
|
|
96
|
+
text, specs, blocked = _route_edits(root, relative_paths, sid)
|
|
97
|
+
rejected = rejected or blocked
|
|
98
|
+
if text:
|
|
99
|
+
feedback.append(text)
|
|
100
|
+
for relative in relative_paths:
|
|
101
|
+
cf_log.append_event(root, "edit_intent", {"file": relative, "tool": tool,
|
|
102
|
+
"specs": list(specs), "blocked": blocked}, sid)
|
|
103
|
+
return "\n\n".join(dict.fromkeys(feedback)), rejected
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _fresh_violations(root: str, path: str, content: str, checks: list[dict[str, object]], sid: str) -> list[dict[str, object]]:
|
|
107
|
+
state = load_check_state(root)
|
|
108
|
+
violations, skipped = run_checks(checks, path, content, state)
|
|
109
|
+
for item in skipped:
|
|
110
|
+
cf_log.degrade(root, "post_check", f"{item['check_id']}:{item['reason']}", sid)
|
|
111
|
+
spec_by_check = {check["id"]: check["spec"] for check in checks}
|
|
112
|
+
reported = _session_reported(state, sid)
|
|
113
|
+
fresh = []
|
|
114
|
+
for violation in violations:
|
|
115
|
+
key = f"{violation['check_id']}|{violation['file']}"
|
|
116
|
+
if key not in reported:
|
|
117
|
+
violation["spec"] = spec_by_check.get(violation["check_id"], "")
|
|
118
|
+
fresh.append(violation)
|
|
119
|
+
reported.add(key)
|
|
120
|
+
if fresh:
|
|
121
|
+
record_hits(root, sorted({v["check_id"] for v in fresh}))
|
|
122
|
+
_save_reported(root, load_check_state(root), sid, reported)
|
|
123
|
+
return fresh
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def after_edit(session_root: str, path: str, sid: str, tool: str, deleted: bool = False) -> str:
|
|
127
|
+
absolute = path if os.path.isabs(path) else os.path.join(session_root, path)
|
|
128
|
+
root = effective_project_root(session_root, absolute)
|
|
129
|
+
config = load_config(root)
|
|
130
|
+
if not config or resolve_enforcement(config) == "inject" or not resolve_quality_loop(config)["post_check"]:
|
|
131
|
+
return ""
|
|
132
|
+
relative = normalize_path(os.path.relpath(absolute, root))
|
|
133
|
+
cf_log.append_event(root, "edit", {"file": relative, "tool": tool}, sid)
|
|
134
|
+
if deleted or not is_code_file(relative, config.get("quality_loop") or {}):
|
|
135
|
+
return ""
|
|
136
|
+
mapping = build_effective_mapping(root, config.get("path_mapping") or {})
|
|
137
|
+
checks = _collect_checks(root, match_domains(relative, mapping), mapping)
|
|
138
|
+
if not checks:
|
|
139
|
+
return ""
|
|
140
|
+
try:
|
|
141
|
+
content = Path(absolute).read_text(encoding="utf-8")
|
|
142
|
+
except (OSError, UnicodeError) as exc:
|
|
143
|
+
cf_log.degrade(root, "post_check", f"read_failed:{relative}:{exc}", sid)
|
|
144
|
+
return ""
|
|
145
|
+
fresh = _fresh_violations(root, relative, content, checks, sid)
|
|
146
|
+
for violation in fresh:
|
|
147
|
+
cf_log.append_event(root, "violation", {"check_id": violation["check_id"], "spec": violation["spec"],
|
|
148
|
+
"file": violation["file"], "severity": violation["severity"]}, sid)
|
|
149
|
+
return _feedback_text(fresh) if fresh else ""
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Session-isolated injection deduplication, committed after successful output."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from contextvars import ContextVar
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Callable, Iterator, TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from codeflow.core import cf_log
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from codeflow.spec.cf_spec_router import RouteResult
|
|
16
|
+
|
|
17
|
+
_PENDING: ContextVar[list[Callable[[], None]] | None] = ContextVar("injection_output", default=None)
|
|
18
|
+
_INTERVAL = 25
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def defer_commit(action: Callable[[], None]) -> None:
|
|
22
|
+
pending = _PENDING.get()
|
|
23
|
+
if pending is None:
|
|
24
|
+
action()
|
|
25
|
+
else:
|
|
26
|
+
pending.append(action)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@contextmanager
|
|
30
|
+
def injection_output() -> Iterator[None]:
|
|
31
|
+
"""Discard pending dedup state if building or writing the output fails."""
|
|
32
|
+
if _PENDING.get() is not None:
|
|
33
|
+
yield
|
|
34
|
+
return
|
|
35
|
+
actions: list[Callable[[], None]] = []
|
|
36
|
+
token = _PENDING.set(actions)
|
|
37
|
+
try:
|
|
38
|
+
yield
|
|
39
|
+
except BaseException:
|
|
40
|
+
raise
|
|
41
|
+
else:
|
|
42
|
+
for action in actions:
|
|
43
|
+
action()
|
|
44
|
+
finally:
|
|
45
|
+
_PENDING.reset(token)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _state_path(root: str, sid: str) -> Path:
|
|
49
|
+
name = hashlib.sha256(sid.encode("utf-8")).hexdigest()
|
|
50
|
+
return Path(root) / ".code-flow/sessions/injection" / f"{name}.json"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _load_state(path: Path) -> dict[str, object]:
|
|
54
|
+
try:
|
|
55
|
+
state = json.loads(path.read_text(encoding="utf-8"))
|
|
56
|
+
return state if isinstance(state, dict) and state.get("version") == 1 else {}
|
|
57
|
+
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
58
|
+
return {}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _number(state: dict[str, object], key: str) -> int:
|
|
62
|
+
value = state.get(key, 0)
|
|
63
|
+
return value if type(value) is int and value >= 0 else 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _save_state(path: Path, state: dict[str, object]) -> None:
|
|
67
|
+
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
68
|
+
try:
|
|
69
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
temporary.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8")
|
|
71
|
+
os.replace(temporary, path)
|
|
72
|
+
except (OSError, UnicodeError, TypeError) as exc:
|
|
73
|
+
cf_log.warn(f"cf_injection: dedup state unavailable: {exc}")
|
|
74
|
+
finally:
|
|
75
|
+
try:
|
|
76
|
+
temporary.unlink(missing_ok=True)
|
|
77
|
+
except OSError as exc:
|
|
78
|
+
cf_log.warn(f"cf_injection: cannot remove temporary dedup state: {exc}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _commit(root: str, sid: str, result: RouteResult, count: int, fingerprint: str, emitted: bool) -> None:
|
|
82
|
+
path = _state_path(root, sid)
|
|
83
|
+
state = _load_state(path)
|
|
84
|
+
state.update(version=1, prompt_count=count)
|
|
85
|
+
if emitted:
|
|
86
|
+
state[f"{result.mode}_fingerprint"] = fingerprint
|
|
87
|
+
state[f"{result.mode}_last_prompt"] = count
|
|
88
|
+
cf_log.append_event(root, "inject", {"specs": list(result.specs), "mode": result.mode,
|
|
89
|
+
"source": result.mode}, sid)
|
|
90
|
+
_save_state(path, state)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def injection_text(root: str, sid: str, result: RouteResult, *, prompt: bool = False) -> str:
|
|
94
|
+
if result.mode not in {"task", "path"} or not result.text:
|
|
95
|
+
return result.text
|
|
96
|
+
state = _load_state(_state_path(root, sid))
|
|
97
|
+
count = _number(state, "prompt_count") + int(prompt)
|
|
98
|
+
fingerprint = hashlib.sha256(
|
|
99
|
+
(result.mode + "\n" + result.text).encode("utf-8")
|
|
100
|
+
).hexdigest()
|
|
101
|
+
emitted = (state.get(f"{result.mode}_fingerprint") != fingerprint
|
|
102
|
+
or count - _number(state, f"{result.mode}_last_prompt") >= _INTERVAL)
|
|
103
|
+
defer_commit(lambda: _commit(root, sid, result, count, fingerprint, emitted))
|
|
104
|
+
return result.text if emitted else ""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def pre_tool_payload(text: str, blocked: bool) -> dict[str, object]:
|
|
108
|
+
output: dict[str, object] = {"hookEventName": "PreToolUse", "additionalContext": text}
|
|
109
|
+
if blocked:
|
|
110
|
+
output.update(permissionDecision="deny", permissionDecisionReason=text)
|
|
111
|
+
return {"hookSpecificOutput": output}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Resolve artifact references and report unfinished rule applications."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import re
|
|
7
|
+
from typing import Mapping, TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from codeflow.spec.cf_spec_context_model import SpecContext
|
|
11
|
+
|
|
12
|
+
_UNRESOLVED = re.compile(r"\b(?:TBD|TODO)\b|待同步|待确认|待定|需同步\s*spec\s*文本", re.I)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _heading_matches(title: str, section: str, pattern: re.Pattern) -> bool:
|
|
16
|
+
if pattern.search(title):
|
|
17
|
+
return True
|
|
18
|
+
plain = re.sub(r"^\d+(?:\.\d+)*[.)、]?\s+", "", title.strip())
|
|
19
|
+
slug = re.sub(r"\s+", "-", re.sub(r"[^\w\s-]", "", plain).strip()).casefold()
|
|
20
|
+
return slug == section.casefold()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def referenced_item(text: str, section_id: str, item_id: str) -> str:
|
|
24
|
+
"""Find the item inside its declared section, excluding fenced examples."""
|
|
25
|
+
clean = re.sub(r"```[^\n]*\n[\s\S]*?```|~~~[^\n]*\n[\s\S]*?~~~", "", text)
|
|
26
|
+
headings = list(re.finditer(r"(?m)^(#{1,6})[ \t]+([^\n]+)$", clean))
|
|
27
|
+
section = section_id.lstrip("#§ ").strip()
|
|
28
|
+
pattern = re.compile(rf"(?<![\w.-]){re.escape(section)}(?![\w.-])", re.I)
|
|
29
|
+
item_pattern = re.compile(rf"(?<![\w-]){re.escape(item_id)}(?![\w-])", re.I)
|
|
30
|
+
if not headings:
|
|
31
|
+
return next((part for part in clean.split("\n\n") if pattern.search(part) and item_pattern.search(part)), "")
|
|
32
|
+
start = next((entry for entry in headings if _heading_matches(entry.group(2), section, pattern)), None)
|
|
33
|
+
if start is None:
|
|
34
|
+
return ""
|
|
35
|
+
end = next((entry.start() for entry in headings
|
|
36
|
+
if entry.start() > start.start() and len(entry.group(1)) <= len(start.group(1))), len(clean))
|
|
37
|
+
lines = clean[start.end():end].splitlines()
|
|
38
|
+
if re.fullmatch(r"TASK-\d+", section, re.I) and item_id.casefold() == section.casefold():
|
|
39
|
+
return "\n".join(lines).strip()
|
|
40
|
+
selected: list[str] = []
|
|
41
|
+
for line in lines:
|
|
42
|
+
if item_pattern.search(line):
|
|
43
|
+
selected.append(line)
|
|
44
|
+
elif selected and (re.match(r"\s*(?:#{1,6}\s|\|?\s*\[?RULE-|[-*]\s+\[?RULE-)", line)):
|
|
45
|
+
break
|
|
46
|
+
elif selected:
|
|
47
|
+
selected.append(line)
|
|
48
|
+
return "\n".join(selected).strip()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def unresolved_text(text: str) -> bool:
|
|
52
|
+
# A prohibition mentioning TBD is a valid rule, not an unfinished result.
|
|
53
|
+
without_prohibitions = re.sub(
|
|
54
|
+
r"(?:禁止|不得|不能)[^,。;\n]*(?:TBD|TODO|待同步|待确认)[^,。;\n]*", "", text, flags=re.I)
|
|
55
|
+
return bool(_UNRESOLVED.search(without_prohibitions))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def artifact_problem(root: str, artifact: str, section: str, item: str, expected_hash: str = "",
|
|
59
|
+
*, task_dir: str = "") -> str:
|
|
60
|
+
project = Path(root).resolve()
|
|
61
|
+
base = Path(task_dir).resolve() if task_dir else project
|
|
62
|
+
target = (base / artifact).resolve()
|
|
63
|
+
if base not in target.parents or project not in target.parents:
|
|
64
|
+
return "rule_artifact_outside_project"
|
|
65
|
+
try:
|
|
66
|
+
source = target.read_bytes()
|
|
67
|
+
content = source.decode("utf-8")
|
|
68
|
+
except (OSError, UnicodeError):
|
|
69
|
+
return "rule_artifact_missing"
|
|
70
|
+
if expected_hash and hashlib.sha256(source).hexdigest() != expected_hash:
|
|
71
|
+
return "rule_artifact_stale"
|
|
72
|
+
selected = referenced_item(content, section, item)
|
|
73
|
+
if not selected:
|
|
74
|
+
return "rule_artifact_reference_missing"
|
|
75
|
+
return "rule_applied_unresolved" if unresolved_text(selected) else ""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def applied_findings(context: SpecContext, root: str, only_stage: str = "",
|
|
79
|
+
*, task_dir: str = "") -> list[dict[str, str]]:
|
|
80
|
+
findings: list[dict[str, str]] = []
|
|
81
|
+
for binding in context.bindings:
|
|
82
|
+
for rule in binding.rules:
|
|
83
|
+
if rule.enforcement != "required":
|
|
84
|
+
continue
|
|
85
|
+
for stage, status in rule.stage_status.items():
|
|
86
|
+
if only_stage and stage != only_stage:
|
|
87
|
+
continue
|
|
88
|
+
if status.status != "applied":
|
|
89
|
+
continue
|
|
90
|
+
for ref in status.refs:
|
|
91
|
+
code = artifact_problem(root, ref.artifact, ref.section_id, ref.item_id,
|
|
92
|
+
ref.artifact_sha256, task_dir=task_dir)
|
|
93
|
+
if code:
|
|
94
|
+
findings.append({"code": code, "severity": "block", "path": ref.artifact,
|
|
95
|
+
"message": f"{binding.spec_id}#{rule.ref} ({stage}): {code}",
|
|
96
|
+
"fix": "完成引用条目并运行 code-flow spec refresh,再执行对应阶段验证"})
|
|
97
|
+
return findings
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def convention_findings(root: str, paths: list[str]) -> list[dict[str, str]]:
|
|
101
|
+
findings: list[dict[str, str]] = []
|
|
102
|
+
for relative in paths:
|
|
103
|
+
target = Path(root) / ".code-flow/specs" / relative
|
|
104
|
+
try:
|
|
105
|
+
text = target.read_text(encoding="utf-8")
|
|
106
|
+
except (OSError, UnicodeError) as exc:
|
|
107
|
+
findings.append({"code": "convention_review_unreadable", "severity": "block", "path": str(target),
|
|
108
|
+
"message": str(exc), "fix": "修复绑定 Spec 文件后重跑 doctor"})
|
|
109
|
+
continue
|
|
110
|
+
if re.search(r"(?mi)^##\s+(?:Conventions|约定)\s*$", text):
|
|
111
|
+
findings.append({"code": "convention_review_required", "severity": "warn", "path": str(target),
|
|
112
|
+
"message": "Conventions 尚未成为可验证约束;关键条目应提升为有稳定 ID 的 Rules",
|
|
113
|
+
"fix": "审查约定与本次设计的一致性;关键约束补充 Rule 和 verifier"})
|
|
114
|
+
return findings
|