@michaelxwb/code-flow 0.6.5 → 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.
Files changed (177) hide show
  1. package/README.md +75 -110
  2. package/bin/code-flow.js +12 -0
  3. package/docs/README.md +19 -0
  4. package/docs/USAGE.md +268 -0
  5. package/docs/v1-design.md +1858 -0
  6. package/docs/v1-implementation.md +186 -0
  7. package/lib/adapters.js +126 -0
  8. package/lib/cli.js +258 -0
  9. package/lib/config.js +85 -0
  10. package/lib/doctor.js +283 -0
  11. package/lib/frontmatter.js +42 -0
  12. package/lib/fs.js +141 -0
  13. package/lib/glob.js +23 -0
  14. package/lib/hook.js +99 -0
  15. package/lib/init.js +132 -0
  16. package/lib/learn.js +170 -0
  17. package/lib/locks.js +52 -0
  18. package/lib/markdown.js +13 -0
  19. package/lib/process.js +116 -0
  20. package/lib/render.js +115 -0
  21. package/lib/repo.js +48 -0
  22. package/lib/runs.js +155 -0
  23. package/lib/session.js +40 -0
  24. package/lib/spec.js +80 -0
  25. package/lib/task.js +258 -0
  26. package/lib/taskdoc.js +308 -0
  27. package/lib/toml.js +140 -0
  28. package/lib/verify.js +168 -0
  29. package/lib/worktree.js +930 -0
  30. package/package.json +21 -9
  31. package/templates/adapters/opencode.mjs +65 -0
  32. package/templates/config.json +39 -0
  33. package/templates/instructions.md +13 -0
  34. package/templates/launcher/bridge.cjs +295 -0
  35. package/templates/launcher/hook.cmd +15 -0
  36. package/templates/launcher/hook.ps1 +10 -0
  37. package/templates/launcher/hook.sh +20 -0
  38. package/templates/skills/cf-check.md +19 -0
  39. package/templates/skills/cf-init.md +16 -0
  40. package/templates/skills/cf-spec.md +20 -0
  41. package/templates/skills/cf-task-align.md +19 -0
  42. package/templates/skills/cf-task-archive.md +20 -0
  43. package/templates/skills/cf-task-plan.md +31 -0
  44. package/templates/skills/cf-task-prd.md +14 -0
  45. package/templates/skills/cf-task-start.md +41 -0
  46. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-frontend.md +22 -19
  47. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-full.md +36 -28
  48. package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-lite.md +27 -17
  49. package/{src/core/code-flow/specs/shared/prd-template.md → templates/workflow/prd.md} +1 -1
  50. package/templates/workflow/project-guidelines.md +48 -0
  51. package/templates/workflow/scenarios.md +67 -0
  52. package/templates/workflow/task.md +56 -0
  53. package/docs/migrations/spec-workflow-0.6.0.md +0 -42
  54. package/src/adapters/claude/CLAUDE.md +0 -45
  55. package/src/adapters/claude/commands/cf-init.md +0 -264
  56. package/src/adapters/claude/commands/cf-learn.md +0 -291
  57. package/src/adapters/claude/commands/cf-spec.md +0 -67
  58. package/src/adapters/claude/commands/cf-stats.md +0 -86
  59. package/src/adapters/claude/commands/cf-sync.md +0 -25
  60. package/src/adapters/claude/commands/cf-task/align.md +0 -309
  61. package/src/adapters/claude/commands/cf-task/archive.md +0 -161
  62. package/src/adapters/claude/commands/cf-task/block.md +0 -36
  63. package/src/adapters/claude/commands/cf-task/graph.md +0 -80
  64. package/src/adapters/claude/commands/cf-task/note.md +0 -236
  65. package/src/adapters/claude/commands/cf-task/plan.md +0 -363
  66. package/src/adapters/claude/commands/cf-task/prd.md +0 -162
  67. package/src/adapters/claude/commands/cf-task/start.md +0 -304
  68. package/src/adapters/claude/commands/cf-task/status.md +0 -59
  69. package/src/adapters/claude/commands/cf-task/verify-e2e.md +0 -115
  70. package/src/adapters/claude/commands/cf-validate.md +0 -75
  71. package/src/adapters/claude/settings.local.json +0 -50
  72. package/src/adapters/codex/AGENTS.md +0 -45
  73. package/src/adapters/codex/config.toml +0 -3
  74. package/src/adapters/codex/hooks.json +0 -50
  75. package/src/adapters/codex/skills/cf-init/SKILL.md +0 -273
  76. package/src/adapters/codex/skills/cf-learn/SKILL.md +0 -296
  77. package/src/adapters/codex/skills/cf-spec/SKILL.md +0 -60
  78. package/src/adapters/codex/skills/cf-stats/SKILL.md +0 -87
  79. package/src/adapters/codex/skills/cf-sync/SKILL.md +0 -30
  80. package/src/adapters/codex/skills/cf-task-align/SKILL.md +0 -310
  81. package/src/adapters/codex/skills/cf-task-archive/SKILL.md +0 -162
  82. package/src/adapters/codex/skills/cf-task-block/SKILL.md +0 -37
  83. package/src/adapters/codex/skills/cf-task-graph/SKILL.md +0 -81
  84. package/src/adapters/codex/skills/cf-task-note/SKILL.md +0 -233
  85. package/src/adapters/codex/skills/cf-task-plan/SKILL.md +0 -324
  86. package/src/adapters/codex/skills/cf-task-prd/SKILL.md +0 -163
  87. package/src/adapters/codex/skills/cf-task-start/SKILL.md +0 -305
  88. package/src/adapters/codex/skills/cf-task-status/SKILL.md +0 -60
  89. package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +0 -111
  90. package/src/adapters/codex/skills/cf-validate/SKILL.md +0 -76
  91. package/src/adapters/costrict/CLAUDE.md +0 -45
  92. package/src/adapters/costrict/commands/cf-init.md +0 -264
  93. package/src/adapters/costrict/commands/cf-learn.md +0 -291
  94. package/src/adapters/costrict/commands/cf-spec.md +0 -67
  95. package/src/adapters/costrict/commands/cf-stats.md +0 -86
  96. package/src/adapters/costrict/commands/cf-sync.md +0 -25
  97. package/src/adapters/costrict/commands/cf-task/align.md +0 -309
  98. package/src/adapters/costrict/commands/cf-task/archive.md +0 -161
  99. package/src/adapters/costrict/commands/cf-task/block.md +0 -36
  100. package/src/adapters/costrict/commands/cf-task/graph.md +0 -80
  101. package/src/adapters/costrict/commands/cf-task/note.md +0 -236
  102. package/src/adapters/costrict/commands/cf-task/plan.md +0 -363
  103. package/src/adapters/costrict/commands/cf-task/prd.md +0 -162
  104. package/src/adapters/costrict/commands/cf-task/start.md +0 -304
  105. package/src/adapters/costrict/commands/cf-task/status.md +0 -59
  106. package/src/adapters/costrict/commands/cf-task/verify-e2e.md +0 -115
  107. package/src/adapters/costrict/commands/cf-validate.md +0 -75
  108. package/src/adapters/costrict/settings.local.json +0 -50
  109. package/src/adapters/opencode/AGENTS.md +0 -45
  110. package/src/adapters/opencode/commands/cf-init.md +0 -281
  111. package/src/adapters/opencode/commands/cf-learn.md +0 -295
  112. package/src/adapters/opencode/commands/cf-spec.md +0 -59
  113. package/src/adapters/opencode/commands/cf-stats.md +0 -90
  114. package/src/adapters/opencode/commands/cf-sync.md +0 -29
  115. package/src/adapters/opencode/commands/cf-task/align.md +0 -314
  116. package/src/adapters/opencode/commands/cf-task/archive.md +0 -166
  117. package/src/adapters/opencode/commands/cf-task/block.md +0 -40
  118. package/src/adapters/opencode/commands/cf-task/graph.md +0 -84
  119. package/src/adapters/opencode/commands/cf-task/note.md +0 -240
  120. package/src/adapters/opencode/commands/cf-task/plan.md +0 -363
  121. package/src/adapters/opencode/commands/cf-task/prd.md +0 -167
  122. package/src/adapters/opencode/commands/cf-task/start.md +0 -309
  123. package/src/adapters/opencode/commands/cf-task/status.md +0 -63
  124. package/src/adapters/opencode/commands/cf-task/verify-e2e.md +0 -115
  125. package/src/adapters/opencode/commands/cf-validate.md +0 -79
  126. package/src/adapters/opencode/opencode.json +0 -3
  127. package/src/adapters/opencode/plugins/code-flow/index.js +0 -213
  128. package/src/adapters/opencode/plugins/code-flow/package.json +0 -6
  129. package/src/cli.js +0 -1023
  130. package/src/core/code-flow/config.yml +0 -158
  131. package/src/core/code-flow/scripts/cf_acceptance_evidence.py +0 -100
  132. package/src/core/code-flow/scripts/cf_acceptance_manifest.py +0 -294
  133. package/src/core/code-flow/scripts/cf_acceptance_runner.py +0 -192
  134. package/src/core/code-flow/scripts/cf_acceptance_schema.py +0 -83
  135. package/src/core/code-flow/scripts/cf_checks.py +0 -336
  136. package/src/core/code-flow/scripts/cf_core.py +0 -787
  137. package/src/core/code-flow/scripts/cf_exec_base.py +0 -112
  138. package/src/core/code-flow/scripts/cf_feedback.py +0 -56
  139. package/src/core/code-flow/scripts/cf_log.py +0 -154
  140. package/src/core/code-flow/scripts/cf_post_hook.py +0 -196
  141. package/src/core/code-flow/scripts/cf_pre_tool_hook.py +0 -104
  142. package/src/core/code-flow/scripts/cf_scan.py +0 -295
  143. package/src/core/code-flow/scripts/cf_session_state.py +0 -37
  144. package/src/core/code-flow/scripts/cf_spec_context.py +0 -1746
  145. package/src/core/code-flow/scripts/cf_spec_gate.py +0 -264
  146. package/src/core/code-flow/scripts/cf_spec_metadata.py +0 -384
  147. package/src/core/code-flow/scripts/cf_spec_migrate.py +0 -512
  148. package/src/core/code-flow/scripts/cf_spec_resolver.py +0 -347
  149. package/src/core/code-flow/scripts/cf_spec_router.py +0 -244
  150. package/src/core/code-flow/scripts/cf_spec_session.py +0 -119
  151. package/src/core/code-flow/scripts/cf_spec_verify.py +0 -559
  152. package/src/core/code-flow/scripts/cf_stats.py +0 -506
  153. package/src/core/code-flow/scripts/cf_stop_hook.py +0 -381
  154. package/src/core/code-flow/scripts/cf_sync.py +0 -140
  155. package/src/core/code-flow/scripts/cf_task_index.py +0 -148
  156. package/src/core/code-flow/scripts/cf_task_parallel.py +0 -394
  157. package/src/core/code-flow/scripts/cf_task_runtime.py +0 -304
  158. package/src/core/code-flow/scripts/cf_task_state.py +0 -3
  159. package/src/core/code-flow/scripts/cf_task_workflow.py +0 -205
  160. package/src/core/code-flow/scripts/cf_user_prompt_hook.py +0 -178
  161. package/src/core/code-flow/scripts/cf_validation.py +0 -81
  162. package/src/core/code-flow/scripts/cf_workflow_service.py +0 -366
  163. package/src/core/code-flow/scripts/cf_workflow_transaction.py +0 -96
  164. package/src/core/code-flow/specs/backend/_map.md +0 -56
  165. package/src/core/code-flow/specs/backend/code-quality-performance.md +0 -59
  166. package/src/core/code-flow/specs/backend/database.md +0 -51
  167. package/src/core/code-flow/specs/backend/directory-structure.md +0 -54
  168. package/src/core/code-flow/specs/backend/logging.md +0 -50
  169. package/src/core/code-flow/specs/backend/platform-rules.md +0 -55
  170. package/src/core/code-flow/specs/frontend/_map.md +0 -52
  171. package/src/core/code-flow/specs/frontend/component-specs.md +0 -68
  172. package/src/core/code-flow/specs/frontend/directory-structure.md +0 -53
  173. package/src/core/code-flow/specs/frontend/quality-standards.md +0 -58
  174. package/src/core/code-flow/specs/shared/_map.md +0 -57
  175. package/src/core/code-flow/tasks/.gitkeep +0 -0
  176. package/src/core/code-flow/validation.yml +0 -53
  177. package/src/migrate/spec-workflow.js +0 -238
@@ -1,119 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Project one TASK's bound rules into a bounded session document."""
3
-
4
- from __future__ import annotations
5
-
6
- import argparse
7
- from dataclasses import dataclass
8
- import json
9
- from pathlib import Path
10
- import re
11
- import sys
12
- from typing import IO, Mapping, Optional, Sequence
13
-
14
- from cf_spec_context import RuleBinding, SpecContext, context_sha256, load_context
15
-
16
-
17
- @dataclass(frozen=True)
18
- class SessionProjection:
19
- text: str
20
- included_rules: int
21
- total_rules: int
22
- truncated: bool
23
-
24
-
25
- # context_sha256 is defined next to the identity projection in cf_spec_context
26
- # and re-exported here for callers that import the session module.
27
-
28
-
29
- def _task_section(text: str, task_id: str) -> str:
30
- match = re.search(rf"(?m)^## {re.escape(task_id)}:[^\n]*$", text)
31
- if match is None:
32
- raise ValueError(f"task_not_found: {task_id}")
33
- following = re.search(r"(?m)^## TASK-\d+:", text[match.end():])
34
- end = match.end() + following.start() if following else len(text)
35
- return text[match.start():end]
36
-
37
-
38
- def _refs(section: str) -> tuple[str, ...]:
39
- # [ \t]* 而非 \s*:空 Spec-Refs 不得跨行吞掉下一字段(Acceptance-Refs 等)
40
- match = re.search(r"(?m)^- \*\*Spec-Refs\*\*:[ \t]*(.*)$", section)
41
- if match is None:
42
- raise ValueError("spec_refs_missing")
43
- return tuple(item.strip() for item in match.group(1).split(",") if item.strip())
44
-
45
-
46
- def _rule_index(context: SpecContext) -> Mapping[str, RuleBinding]:
47
- return {
48
- f"{binding.spec_id}#{rule.ref}": rule
49
- for binding in context.bindings
50
- for rule in binding.rules
51
- }
52
-
53
-
54
- def _acceptance_contract(section: str) -> str:
55
- match = re.search(r"(?ms)^### Acceptance Contract\s*$\n(.*?)(?=^### |^## |\Z)", section)
56
- if match is None:
57
- raise ValueError("acceptance_contract_missing")
58
- return match.group(1).strip()
59
-
60
-
61
- def _rule_line(ref: str, rule: RuleBinding, compact: bool = False) -> str:
62
- hashes = f"rule_sha256={rule.text_sha256} verifier={rule.verifier_ref}"
63
- if compact:
64
- return f"- `{ref}` {hashes}\n"
65
- artifacts = sorted({item.artifact for status in rule.stage_status.values() for item in status.refs})
66
- return f"- `{ref}`: {rule.summary}\n - {hashes}; artifacts={','.join(artifacts) or 'none'}\n"
67
-
68
-
69
- def project_task_session(
70
- context: SpecContext, task_file: str, task_id: str, max_chars: int = 12000
71
- ) -> SessionProjection:
72
- section = _task_section(Path(task_file).read_text(encoding="utf-8"), task_id)
73
- refs = _refs(section)
74
- index = _rule_index(context)
75
- missing = tuple(ref for ref in refs if ref not in index)
76
- if missing:
77
- raise ValueError(f"unknown_spec_refs: {', '.join(missing)}")
78
- acceptance = _acceptance_contract(section)
79
- header = f"# {task_id} Spec Context\n\n- Context-SHA256: `{context_sha256(context)}`\n\n## Required Rules\n"
80
- footer = f"\n## Acceptance Contract\n{acceptance}\n"
81
- lines: list[str] = []
82
- for ref in refs:
83
- candidate = _rule_line(ref, index[ref])
84
- if len(header + "".join(lines) + candidate + footer) > max_chars:
85
- break
86
- lines.append(candidate)
87
- truncated = len(lines) < len(refs) or len(header + "".join(lines) + footer) > max_chars
88
- if truncated:
89
- footer = "\n> 投影超过预算;完整 Context 保留在需求目录,请拆分 TASK。\n" + footer
90
- text = (header + "".join(lines) + footer)[:max_chars]
91
- return SessionProjection(text, len(lines), len(refs), truncated)
92
-
93
-
94
- def _parser() -> argparse.ArgumentParser:
95
- parser = argparse.ArgumentParser(prog="cf_spec_session.py")
96
- parser.add_argument("--task-dir", required=True)
97
- parser.add_argument("--task-file", required=True)
98
- parser.add_argument("--task", required=True)
99
- parser.add_argument("--output", required=True)
100
- parser.add_argument("--budget", type=int, default=12000)
101
- parser.add_argument("--json", action="store_true")
102
- return parser
103
-
104
-
105
- def main(argv: Optional[Sequence[str]] = None, stdout: IO[str] = sys.stdout) -> int:
106
- try:
107
- args = _parser().parse_args(argv)
108
- context = load_context(str(Path(args.task_dir) / "spec-context.yml"))
109
- result = project_task_session(context, args.task_file, args.task, args.budget)
110
- Path(args.output).write_text(result.text, encoding="utf-8")
111
- stdout.write(json.dumps(result.__dict__, ensure_ascii=False))
112
- return 0
113
- except Exception as exc:
114
- stdout.write(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False))
115
- return 3
116
-
117
-
118
- if __name__ == "__main__":
119
- raise SystemExit(main())
@@ -1,559 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Rule verifier registry and hash-bound Verification Evidence."""
3
-
4
- from __future__ import annotations
5
-
6
- import ast
7
- from dataclasses import dataclass, replace
8
- from datetime import datetime, timezone
9
- import fnmatch
10
- import hashlib
11
- import json
12
- from pathlib import Path
13
- import subprocess
14
- from threading import Lock
15
- import time
16
- from concurrent.futures import ThreadPoolExecutor
17
- from typing import Mapping, Optional, Sequence
18
-
19
- from cf_checks import run_regex_verifier
20
- from cf_spec_metadata import SpecMetadata, SpecRule, SpecVerifier
21
- from cf_exec_base import execution_session, run_command
22
-
23
-
24
- _RESULT_CACHE: dict[str, dict[str, object]] = {}
25
- _RESULT_CACHE_ROOT: Optional[str] = None
26
- _RESULT_CACHE_DIRTY = False
27
- _RESULT_CACHE_LOCK = Lock()
28
- _MAX_RESULT_CACHE_ENTRIES = 2048
29
- _PURE_VERIFIER_TYPES = frozenset(("document", "regex", "ast"))
30
-
31
-
32
- @dataclass(frozen=True)
33
- class VerificationScope:
34
- root: str
35
- files: tuple[str, ...]
36
- diff_sha256: Optional[str]
37
-
38
-
39
- @dataclass(frozen=True)
40
- class VerificationEvidence:
41
- verifier_ref: str
42
- executed_at: str
43
- status: str
44
- rule_text_sha256: str
45
- artifact_sha256: Optional[str]
46
- diff_sha256: Optional[str]
47
- result_sha256: str
48
- error_code: Optional[str]
49
- details: Mapping[str, object]
50
-
51
-
52
- @dataclass(frozen=True)
53
- class VerificationResult:
54
- evidence: tuple[VerificationEvidence, ...]
55
- passed: bool
56
-
57
-
58
- def _cache_file(root: str) -> Path:
59
- return Path(root) / ".code-flow" / ".verifier-cache.json"
60
-
61
-
62
- def _cache_key(metadata: SpecMetadata, rule: SpecRule, verifier: SpecVerifier, scope: VerificationScope, confirmation: Optional[Mapping[str, object]], stage: str = "code") -> str:
63
- payload = {
64
- "spec": metadata.hashes.file_sha256,
65
- "rule": rule.text_sha256,
66
- "verifier": {"type": verifier.type, "config": verifier.config},
67
- "files": scope.files,
68
- "diff": scope.diff_sha256,
69
- "stage": stage,
70
- "confirmation": confirmation or {},
71
- "artifact_content": _artifact_fingerprint(verifier, scope),
72
- }
73
- return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str).encode()).hexdigest()
74
-
75
-
76
- def _normalize_scope_path(value: str) -> str:
77
- return value.replace("\\", "/").strip()
78
-
79
-
80
- def _matches_scope(path: str, patterns: Sequence[str]) -> bool:
81
- for pattern in patterns:
82
- if fnmatch.fnmatchcase(path, pattern):
83
- return True
84
- bare = pattern.rstrip("/")
85
- if not any(char in pattern for char in "*?[") and path.startswith(bare + "/"):
86
- return True
87
- return False
88
-
89
-
90
- def _git_tracked_files(root: str) -> tuple[str, ...]:
91
- try:
92
- outcome = subprocess.run(
93
- ("git", "ls-files"), cwd=root, text=True, encoding="utf-8", capture_output=True, check=False
94
- )
95
- except OSError:
96
- return ()
97
- if outcome.returncode != 0:
98
- return ()
99
- return tuple(_normalize_scope_path(line) for line in outcome.stdout.splitlines() if line.strip())
100
-
101
-
102
- def scoped_files(verifier: SpecVerifier, scope: VerificationScope) -> tuple[str, ...]:
103
- """作用域命中的文件集合:git tracked ∪ 任务持有文件,按 verifier.files 过滤。
104
-
105
- 决议仓库级集合(而非仅当前任务 diff)才能让未触及作用域的其他任务命中缓存。
106
- """
107
- if not verifier.files:
108
- return ()
109
- candidates = {_normalize_scope_path(item) for item in scope.files if item.strip()}
110
- candidates.update(_git_tracked_files(scope.root))
111
- return tuple(sorted(path for path in candidates if _matches_scope(path, verifier.files)))
112
-
113
-
114
- def _scope_fingerprint(root: str, files: Sequence[str]) -> str:
115
- digest = hashlib.sha256()
116
- for relative in files:
117
- path = Path(root) / relative
118
- digest.update(relative.encode("utf-8"))
119
- try:
120
- digest.update(path.read_bytes() if path.is_file() else b"<deleted>")
121
- except OSError:
122
- digest.update(b"<unreadable>")
123
- return digest.hexdigest()
124
-
125
-
126
- def _scoped_cache_key(
127
- metadata: SpecMetadata,
128
- rule: SpecRule,
129
- verifier: SpecVerifier,
130
- scope: VerificationScope,
131
- confirmation: Optional[Mapping[str, object]],
132
- stage: str,
133
- ) -> Optional[str]:
134
- """command/test 仅在声明 files 作用域时缓存;未声明保持全量执行。"""
135
- if verifier.type not in ("command", "test") or not verifier.files:
136
- return None
137
- files = scoped_files(verifier, scope)
138
- payload = {
139
- "spec": metadata.hashes.file_sha256,
140
- "rule": rule.text_sha256,
141
- "verifier": {"type": verifier.type, "config": verifier.config, "files": list(verifier.files)},
142
- "stage": stage,
143
- "scope_files": files,
144
- "scope_sha256": _scope_fingerprint(scope.root, files),
145
- "confirmation": confirmation or {},
146
- }
147
- return hashlib.sha256(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str).encode()).hexdigest()
148
-
149
-
150
- def _artifact_fingerprint(verifier: SpecVerifier, scope: VerificationScope) -> Optional[str]:
151
- if verifier.type != "document":
152
- return None
153
- relative = verifier.config.get("artifact")
154
- if not isinstance(relative, str):
155
- return "invalid"
156
- try:
157
- return hashlib.sha256((Path(scope.root) / relative).read_bytes()).hexdigest()
158
- except OSError as exc:
159
- return f"unreadable:{exc}"
160
-
161
-
162
- def _load_result_cache(root: str) -> None:
163
- global _RESULT_CACHE_ROOT, _RESULT_CACHE, _RESULT_CACHE_DIRTY
164
- if _RESULT_CACHE_ROOT == root:
165
- return
166
- _RESULT_CACHE_ROOT = root
167
- _RESULT_CACHE = {}
168
- _RESULT_CACHE_DIRTY = False
169
- path = _cache_file(root)
170
- try:
171
- loaded = json.loads(path.read_text(encoding="utf-8"))
172
- if isinstance(loaded, dict):
173
- _RESULT_CACHE.update({str(key): value for key, value in loaded.items() if isinstance(value, dict)})
174
- except (OSError, ValueError):
175
- pass
176
-
177
-
178
- def _cached_evidence(key: str) -> Optional[VerificationEvidence]:
179
- item = _RESULT_CACHE.get(key)
180
- if not isinstance(item, dict) or item.get("status") != "verified":
181
- return None
182
- try:
183
- return VerificationEvidence(**item)
184
- except (TypeError, ValueError):
185
- return None
186
-
187
-
188
- def _save_result_cache(key: str, evidence: VerificationEvidence) -> None:
189
- global _RESULT_CACHE_DIRTY
190
- with _RESULT_CACHE_LOCK:
191
- _RESULT_CACHE[key] = evidence.__dict__
192
- _RESULT_CACHE_DIRTY = True
193
- while len(_RESULT_CACHE) > _MAX_RESULT_CACHE_ENTRIES:
194
- _RESULT_CACHE.pop(next(iter(_RESULT_CACHE)))
195
-
196
-
197
- def _flush_result_cache(root: str) -> None:
198
- global _RESULT_CACHE_DIRTY
199
- with _RESULT_CACHE_LOCK:
200
- if not _RESULT_CACHE_DIRTY:
201
- return
202
- path = _cache_file(root)
203
- try:
204
- path.parent.mkdir(parents=True, exist_ok=True)
205
- temporary = path.with_suffix(".json.tmp")
206
- temporary.write_text(
207
- json.dumps(_RESULT_CACHE, ensure_ascii=False, sort_keys=True, default=str), encoding="utf-8"
208
- )
209
- temporary.replace(path)
210
- _RESULT_CACHE_DIRTY = False
211
- except OSError:
212
- pass
213
-
214
-
215
- def _cached_or_run(
216
- metadata: SpecMetadata,
217
- rule: SpecRule,
218
- verifier: SpecVerifier,
219
- scope: VerificationScope,
220
- confirmation: Optional[Mapping[str, object]],
221
- stage: str = "code",
222
- ) -> VerificationEvidence:
223
- cacheable = verifier.type in _PURE_VERIFIER_TYPES
224
- scoped_key: Optional[str] = None
225
- if cacheable:
226
- key = _cache_key(metadata, rule, verifier, scope, confirmation, stage)
227
- else:
228
- scoped_key = _scoped_cache_key(metadata, rule, verifier, scope, confirmation, stage)
229
- if scoped_key is None:
230
- key = ""
231
- else:
232
- cacheable, key = True, scoped_key
233
- if cacheable:
234
- cached = _cached_evidence(key)
235
- if cached is not None:
236
- if scoped_key is None:
237
- return cached
238
- details = dict(cached.details or {})
239
- details["cache_reused"] = True
240
- details["scope_files"] = list(scoped_files(verifier, scope))
241
- return replace(
242
- cached,
243
- executed_at=datetime.now(timezone.utc).isoformat(),
244
- diff_sha256=scope.diff_sha256,
245
- details=details,
246
- )
247
- current = _evidence(metadata, rule, verifier, scope, confirmation)
248
- if cacheable and current.status == "verified":
249
- _save_result_cache(key, current)
250
- return current
251
-
252
-
253
- @dataclass(frozen=True)
254
- class _Outcome:
255
- passed: bool
256
- error_code: Optional[str]
257
- artifact_sha256: Optional[str]
258
- details: Mapping[str, object]
259
-
260
-
261
- def _hash_bytes(data: bytes) -> str:
262
- return hashlib.sha256(data).hexdigest()
263
-
264
-
265
- def _result_hash(status: str, error: Optional[str], details: Mapping[str, object]) -> str:
266
- payload = {"status": status, "error_code": error, "details": details}
267
- encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
268
- return _hash_bytes(encoded)
269
-
270
-
271
- def _config_string(config: Mapping[str, object], key: str) -> Optional[str]:
272
- value = config.get(key)
273
- return value if isinstance(value, str) and value else None
274
-
275
-
276
- def _read_scope_files(scope: VerificationScope) -> tuple[dict[str, str], Optional[_Outcome]]:
277
- contents: dict[str, str] = {}
278
- for relative in scope.files:
279
- path = Path(scope.root) / relative
280
- try:
281
- contents[relative] = path.read_text(encoding="utf-8")
282
- except (OSError, UnicodeError) as exc:
283
- outcome = _Outcome(False, "file_read_error", None, {"file": relative, "error": str(exc)})
284
- return {}, outcome
285
- return contents, None
286
-
287
-
288
- def _document(config: Mapping[str, object], scope: VerificationScope) -> _Outcome:
289
- relative = _config_string(config, "artifact")
290
- section = _config_string(config, "section_id")
291
- item = _config_string(config, "item_id")
292
- if not relative or not section or not item:
293
- return _Outcome(False, "invalid_verifier_config", None, {"required": ["artifact", "section_id", "item_id"]})
294
- path = Path(scope.root) / relative
295
- try:
296
- content = path.read_text(encoding="utf-8")
297
- artifact_hash = _hash_bytes(path.read_bytes())
298
- except (OSError, UnicodeError) as exc:
299
- return _Outcome(False, "artifact_read_error", None, {"artifact": relative, "error": str(exc)})
300
- expected = _config_string(config, "artifact_sha256")
301
- present = section in content and item in content
302
- hash_matches = expected is None or expected == artifact_hash
303
- details = {"artifact": relative, "section_present": section in content, "item_present": item in content, "hash_matches": hash_matches}
304
- return _Outcome(present and hash_matches, None if present and hash_matches else "document_mismatch", artifact_hash, details)
305
-
306
-
307
- def _regex(config: Mapping[str, object], metadata: SpecMetadata, scope: VerificationScope) -> _Outcome:
308
- pattern = _config_string(config, "pattern")
309
- files = _config_string(config, "files") or "*"
310
- check_id = _config_string(config, "check_id")
311
- if check_id:
312
- check = next((item for item in metadata.checks if item.get("id") == check_id), None)
313
- if check is None:
314
- return _Outcome(False, "check_not_found", None, {"check_id": check_id})
315
- pattern = check.get("pattern") if isinstance(check.get("pattern"), str) else None
316
- files = check.get("files") if isinstance(check.get("files"), str) else "*"
317
- if pattern is None:
318
- return _Outcome(False, "invalid_verifier_config", None, {"required": ["pattern"]})
319
- selected = tuple(
320
- relative
321
- for relative in scope.files
322
- if fnmatch.fnmatch(relative, files) and (Path(scope.root) / relative).is_file()
323
- )
324
- contents, error = _read_scope_files(VerificationScope(scope.root, selected, scope.diff_sha256))
325
- if error is not None:
326
- return error
327
- violations, skipped = run_regex_verifier(pattern, files, contents)
328
- if skipped:
329
- return _Outcome(False, "regex_unavailable", None, {"skipped": skipped})
330
- details = {"checked_files": len(contents), "violations": violations}
331
- return _Outcome(not violations, None if not violations else "regex_violation", None, details)
332
-
333
-
334
- def _call_name(node: ast.Call) -> str:
335
- if isinstance(node.func, ast.Name):
336
- return node.func.id
337
- if isinstance(node.func, ast.Attribute):
338
- return node.func.attr
339
- return ""
340
-
341
-
342
- def _ast(config: Mapping[str, object], scope: VerificationScope) -> _Outcome:
343
- if _config_string(config, "language") != "python":
344
- return _Outcome(False, "unsupported_ast_language", None, {"language": config.get("language")})
345
- assertion = _config_string(config, "assertion") or "parse"
346
- contents, error = _read_scope_files(scope)
347
- if error is not None:
348
- return error
349
- parsed: list[ast.AST] = []
350
- try:
351
- parsed = [ast.parse(content, filename=relative) for relative, content in contents.items()]
352
- except SyntaxError as exc:
353
- return _Outcome(False, "ast_parse_error", None, {"error": str(exc)})
354
- if assertion == "parse":
355
- return _Outcome(True, None, None, {"parsed_files": len(parsed)})
356
- if assertion == "forbid_call":
357
- call = _config_string(config, "call")
358
- hits = sum(_call_name(node) == call for tree in parsed for node in ast.walk(tree) if isinstance(node, ast.Call))
359
- return _Outcome(hits == 0, None if hits == 0 else "ast_assertion_failed", None, {"call": call, "hits": hits})
360
- return _Outcome(False, "unsupported_ast_assertion", None, {"assertion": assertion})
361
-
362
-
363
- def _argv(config: Mapping[str, object]) -> Optional[list[str]]:
364
- value = config.get("argv")
365
- if not isinstance(value, list) or not value or not all(isinstance(item, str) for item in value):
366
- return None
367
- return list(value)
368
-
369
-
370
- def _command(config: Mapping[str, object], scope: VerificationScope, timeout_cap: Optional[float] = None) -> _Outcome:
371
- argv = _argv(config)
372
- if argv is None:
373
- return _Outcome(False, "invalid_verifier_config", None, {"required": ["argv"]})
374
- cwd = _config_string(config, "cwd") or "."
375
- timeout_value = config.get("timeout", 30)
376
- timeout = float(timeout_value) if isinstance(timeout_value, (int, float)) else 30.0
377
- allowed_value = config.get("allowed_exit_codes", [0])
378
- allowed = tuple(item for item in allowed_value if isinstance(item, int)) if isinstance(allowed_value, list) else (0,)
379
- deadline = None if timeout_cap is None else time.monotonic() + timeout_cap
380
- completed = run_command(argv, str(Path(scope.root) / cwd), timeout, deadline)
381
- if completed["status"] in ("timeout", "deadline_exceeded"):
382
- return _Outcome(False, "verifier_timeout", None, {"timeout": timeout, "argv": argv})
383
- if completed["status"] == "spawn_error":
384
- return _Outcome(False, "command_unavailable", None, {"argv": argv, "error": completed["stderr"]})
385
- details = {
386
- "argv": argv,
387
- "exit_code": completed["returncode"],
388
- "stdout_sha256": _hash_bytes(str(completed["stdout"]).encode()),
389
- "stderr_sha256": _hash_bytes(str(completed["stderr"]).encode()),
390
- "output": (str(completed["stdout"]) + str(completed["stderr"]))[-4000:],
391
- }
392
- passed = completed["returncode"] in allowed
393
- return _Outcome(passed, None if passed else "command_failed", None, details)
394
-
395
-
396
- def _manual(config: Mapping[str, object], confirmation: Optional[Mapping[str, object]]) -> _Outcome:
397
- if confirmation is None:
398
- return _Outcome(False, "manual_confirmation_missing", None, {"checklist": config.get("checklist")})
399
- required = ("reason", "confirmed_by", "confirmed_at", "source")
400
- if not all(isinstance(confirmation.get(key), str) and confirmation.get(key) for key in required):
401
- return _Outcome(False, "manual_confirmation_invalid", None, {"required": list(required)})
402
- identity = str(confirmation["confirmed_by"]).lower().split(":", 1)[0]
403
- if identity in ("agent", "assistant", "codex", "claude", "opencode", "costrict"):
404
- return _Outcome(False, "manual_agent_forbidden", None, {"confirmed_by": confirmation["confirmed_by"]})
405
- return _Outcome(True, None, None, {"source": confirmation["source"], "owner": config.get("owner")})
406
-
407
-
408
- def _run(
409
- verifier: SpecVerifier,
410
- metadata: SpecMetadata,
411
- scope: VerificationScope,
412
- confirmation: Optional[Mapping[str, object]],
413
- timeout_cap: Optional[float] = None,
414
- ) -> _Outcome:
415
- if verifier.type == "document":
416
- return _document(verifier.config, scope)
417
- if verifier.type == "regex":
418
- return _regex(verifier.config, metadata, scope)
419
- if verifier.type == "ast":
420
- return _ast(verifier.config, scope)
421
- if verifier.type in ("command", "test"):
422
- return _command(verifier.config, scope, timeout_cap)
423
- if verifier.type == "manual":
424
- return _manual(verifier.config, confirmation)
425
- return _Outcome(False, "verifier_type_unimplemented", None, {"type": verifier.type})
426
-
427
-
428
- def _evidence(
429
- metadata: SpecMetadata,
430
- rule: SpecRule,
431
- verifier: SpecVerifier,
432
- scope: VerificationScope,
433
- confirmation: Optional[Mapping[str, object]],
434
- timeout_cap: Optional[float] = None,
435
- ) -> VerificationEvidence:
436
- outcome = _run(verifier, metadata, scope, confirmation, timeout_cap)
437
- status = "verified" if outcome.passed else "unverified"
438
- diff_hash = None if verifier.type == "document" else scope.diff_sha256
439
- return VerificationEvidence(
440
- f"{metadata.id}#{rule.ref}",
441
- datetime.now(timezone.utc).isoformat(),
442
- status,
443
- rule.text_sha256,
444
- outcome.artifact_sha256,
445
- diff_hash,
446
- _result_hash(status, outcome.error_code, outcome.details),
447
- outcome.error_code,
448
- outcome.details,
449
- )
450
-
451
-
452
- def _skipped_evidence(metadata: SpecMetadata, rule: SpecRule, scope: VerificationScope) -> VerificationEvidence:
453
- details = {"skipped": True}
454
- return VerificationEvidence(
455
- f"{metadata.id}#{rule.ref}",
456
- datetime.now(timezone.utc).isoformat(),
457
- "unverified",
458
- rule.text_sha256,
459
- None,
460
- scope.diff_sha256,
461
- _result_hash("unverified", "skipped_in_cheap_gate", details),
462
- "skipped_in_cheap_gate",
463
- details,
464
- )
465
-
466
-
467
- def _budget_evidence(metadata: SpecMetadata, rule: SpecRule, scope: VerificationScope, budget: float) -> VerificationEvidence:
468
- details = {"budget": budget}
469
- return VerificationEvidence(
470
- f"{metadata.id}#{rule.ref}",
471
- datetime.now(timezone.utc).isoformat(),
472
- "unverified",
473
- rule.text_sha256,
474
- None,
475
- scope.diff_sha256,
476
- _result_hash("unverified", "verifier_budget_exhausted", details),
477
- "verifier_budget_exhausted",
478
- details,
479
- )
480
-
481
-
482
- def _missing_evidence(metadata: SpecMetadata, rule: SpecRule, scope: VerificationScope) -> VerificationEvidence:
483
- details = {"rule": rule.ref}
484
- return VerificationEvidence(
485
- f"{metadata.id}#{rule.ref}", datetime.now(timezone.utc).isoformat(),
486
- "unverified", rule.text_sha256, None, scope.diff_sha256,
487
- _result_hash("unverified", "verifier_missing", details), "verifier_missing", details,
488
- )
489
-
490
-
491
- def _run_all_verifiers(
492
- metadata: SpecMetadata,
493
- scope: VerificationScope,
494
- confirmations: Optional[Mapping[str, Mapping[str, object]]] = None,
495
- skip_command: bool = False,
496
- timeout_budget: Optional[float] = None,
497
- stage: str = "code",
498
- ) -> VerificationResult:
499
- verifier_by_rule = {item.rule: item for item in metadata.verifiers}
500
- confirmation_by_rule = confirmations or {}
501
- _load_result_cache(scope.root)
502
- evidence_by_ref: dict[str, VerificationEvidence] = {}
503
- parallel: list[tuple[SpecRule, SpecVerifier, Optional[Mapping[str, object]]]] = []
504
- started = time.monotonic()
505
- for rule in metadata.rules:
506
- if rule.enforcement != "required":
507
- continue
508
- verifier = verifier_by_rule.get(rule.ref)
509
- if verifier is not None and verifier.stage != stage:
510
- continue
511
- if timeout_budget is not None:
512
- remaining = timeout_budget - (time.monotonic() - started)
513
- if remaining <= 0:
514
- evidence_by_ref[rule.ref] = _budget_evidence(metadata, rule, scope, timeout_budget)
515
- continue
516
- if verifier is None:
517
- evidence_by_ref[rule.ref] = _missing_evidence(metadata, rule, scope)
518
- continue
519
- if skip_command and verifier.type in ("command", "test"):
520
- evidence_by_ref[rule.ref] = _skipped_evidence(metadata, rule, scope)
521
- continue
522
- confirmation = confirmation_by_rule.get(rule.ref)
523
- if timeout_budget is None and verifier.type in ("document", "regex", "ast"):
524
- parallel.append((rule, verifier, confirmation))
525
- continue
526
- cap = None if timeout_budget is None else max(0.1, timeout_budget - (time.monotonic() - started))
527
- evidence_by_ref[rule.ref] = _cached_or_run(metadata, rule, verifier, scope, confirmation, stage) if cap is None else _evidence(metadata, rule, verifier, scope, confirmation, cap)
528
- if parallel:
529
- with ThreadPoolExecutor(max_workers=min(8, len(parallel))) as pool:
530
- futures = {
531
- rule.ref: pool.submit(_cached_or_run, metadata, rule, verifier, scope, confirmation, stage)
532
- for rule, verifier, confirmation in parallel
533
- }
534
- evidence_by_ref.update({ref: future.result() for ref, future in futures.items()})
535
- result = tuple(evidence_by_ref[rule.ref] for rule in metadata.rules if rule.ref in evidence_by_ref)
536
- _flush_result_cache(scope.root)
537
- return VerificationResult(result, bool(result) and all(item.status == "verified" for item in result))
538
-
539
-
540
- def run_all_verifiers(metadata: SpecMetadata, scope: VerificationScope,
541
- confirmations: Optional[Mapping[str, Mapping[str, object]]] = None,
542
- skip_command: bool = False, timeout_budget: Optional[float] = None,
543
- stage: str = "code") -> VerificationResult:
544
- with execution_session():
545
- return _run_all_verifiers(metadata, scope, confirmations, skip_command, timeout_budget, stage)
546
-
547
-
548
- def evidence_is_fresh(
549
- evidence: VerificationEvidence,
550
- rule_text_sha256: str,
551
- artifact_sha256: Optional[str],
552
- diff_sha256: Optional[str],
553
- ) -> bool:
554
- return (
555
- evidence.status == "verified"
556
- and evidence.rule_text_sha256 == rule_text_sha256
557
- and evidence.artifact_sha256 == artifact_sha256
558
- and evidence.diff_sha256 == diff_sha256
559
- )